Compare commits
87 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 33915c9e4d | |||
| c598978d77 | |||
| af5e062ba4 | |||
| 2e6e89f4a5 | |||
| 96471d2345 | |||
| dc80a3b4f6 | |||
| 53d222e0f7 | |||
| c07bbf7fd3 | |||
| 1f43449942 | |||
| ae9f906f52 | |||
| d6d454347f | |||
| aef9ef8452 | |||
| f986519317 | |||
| d949bcffc1 | |||
| 038ffc161b | |||
| 844f93f64f | |||
| 59ea02d971 | |||
| ada552fbec | |||
| 54ab413396 | |||
| 76cffe1f49 | |||
| 928e06dd01 | |||
| fa9d2af430 | |||
| 9ff224012a | |||
| 76b8325b84 | |||
| b80b27f44b | |||
| fbf3c26c2a | |||
| c3277b52c9 | |||
| 9a896efecd | |||
| a38af16f0f | |||
| 3c34f58bd2 | |||
| 1fb96afd8b | |||
| 86ca1a76d1 | |||
| 17c7e97efb | |||
| 12b9978974 | |||
| 1d7afbb1eb | |||
| f121f8ca16 |
@@ -68,9 +68,45 @@ shared-lib consumption, or per-project commands — update the **OtOpcUa entry i
|
|||||||
gRPC session. The gateway owns the COM apartment + STA pump
|
gRPC session. The gateway owns the COM apartment + STA pump
|
||||||
server-side; the driver speaks `MxCommand` / `MxEvent` protos
|
server-side; the driver speaks `MxCommand` / `MxEvent` protos
|
||||||
exclusively.
|
exclusively.
|
||||||
3. **OPC UA Server** — Exposes authored equipment tags as variable nodes.
|
3. **OPC UA Server** — Exposes the deployed tree under **two OPC UA namespaces**
|
||||||
Galaxy tags are bound by `TagConfig.FullName` (`tag_name.AttributeName`);
|
(see below). Galaxy tags are bound by `TagConfig.FullName`
|
||||||
reads/writes/subscriptions are translated to that reference for MXAccess.
|
(`tag_name.AttributeName`); reads/writes/subscriptions are translated to that
|
||||||
|
reference for MXAccess.
|
||||||
|
|
||||||
|
### v3 OPC UA Address Space (Batch 4): dual namespace
|
||||||
|
|
||||||
|
The server exposes **two OPC UA namespaces** (replacing the single
|
||||||
|
`https://zb.com/otopcua/ns`), built by `AddressSpaceComposer` /
|
||||||
|
`AddressSpaceApplier` and served by `OtOpcUaNodeManager` (which registers both
|
||||||
|
URIs; `V3NodeIds` + `AddressSpaceRealm` are the identity authority):
|
||||||
|
|
||||||
|
| Namespace URI | Realm | Subtree | NodeId `s=` scheme |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `https://zb.com/otopcua/raw` | `AddressSpaceRealm.Raw` | the `/raw` device tree (Folder → Driver → Device → TagGroup → Tag) | the node's **RawPath** (e.g. `Plant/Modbus/dev1/Speed`) |
|
||||||
|
| `https://zb.com/otopcua/uns` | `AddressSpaceRealm.Uns` | the UNS tree (Area → Line → Equipment → signal) | slash-joined **`Area/Line/Equipment/EffectiveName`** |
|
||||||
|
|
||||||
|
- **Single source, fanned to both.** Every device value has exactly one source —
|
||||||
|
the raw tag's node. A `UnsTagReference` projects that raw tag into an equipment
|
||||||
|
as a UNS-namespace variable that carries an **`Organizes` reference to its raw
|
||||||
|
node** and mirrors it. One driver publish for a RawPath fans (in `DriverHostActor`)
|
||||||
|
to the raw NodeId AND every referencing UNS NodeId with identical
|
||||||
|
value/quality/timestamp — the mux key stays single (RawPath).
|
||||||
|
- **Writes via either NodeId** route to the same driver ref under the same
|
||||||
|
`WriteOperate` gating (the node-manager write gate is realm-qualified and fails
|
||||||
|
closed); a failed device write reverts both NodeIds via the shared fan-out
|
||||||
|
(write-outcome self-correction).
|
||||||
|
- **Native alarms** materialize once at the raw tag (`ConditionId = RawPath`) and
|
||||||
|
fan via SDK notifiers to the raw device folder AND every referencing equipment
|
||||||
|
folder — one `ReportEvent`, one Server-object copy; ack/confirm/shelve route on
|
||||||
|
`ConditionId = RawPath`.
|
||||||
|
- **Historian dual-registration.** Both NodeIds register the **same** historian
|
||||||
|
tagname; the mux `HistorizedTagRef` stays single (RawPath). HistoryRead works
|
||||||
|
through either NodeId.
|
||||||
|
|
||||||
|
The retired `EquipmentNodeIds` (`{equipmentId}/{folderPath}/{name}`) scheme and
|
||||||
|
the single-namespace `EquipmentTags` materialization path are gone. See
|
||||||
|
`docs/plans/2026-07-15-raw-uns-two-subtree-v3-design.md` +
|
||||||
|
`docs/plans/2026-07-15-v3-batch4-address-space-plan.md`, and `docs/Uns.md`.
|
||||||
|
|
||||||
### Key Concept: Tag Name and FullName
|
### Key Concept: Tag Name and FullName
|
||||||
|
|
||||||
@@ -215,6 +251,12 @@ Address pickers in AdminUI support live browse for OpcUaClient and Galaxy driver
|
|||||||
|
|
||||||
The AdminUI's global **UNS** page (`/uns`) is the single surface for managing the unified namespace fleet-wide (Area → Line → Equipment → Tag/VirtualTag), replacing the old per-cluster UNS/Equipment/Tags tabs. See `docs/Uns.md`.
|
The AdminUI's global **UNS** page (`/uns`) is the single surface for managing the unified namespace fleet-wide (Area → Line → Equipment → Tag/VirtualTag), replacing the old per-cluster UNS/Equipment/Tags tabs. See `docs/Uns.md`.
|
||||||
|
|
||||||
|
**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`.
|
The `/uns` **TagModal** uses **driver-typed tag-config editors**: it dispatches by the bound driver's `DriverType` to a per-driver editor (Modbus/S7/AbCip/AbLegacy/TwinCAT/Focas/OpcUaClient) via `TagConfigEditorMap`, with client-side validation via `TagConfigValidator`; unmapped drivers (only Galaxy) fall back to the generic raw-`TagConfig`-JSON textarea. Each editor is a thin razor shell over a pure `<Driver>TagConfigModel` (`FromJson`/`ToJson`/`Validate`, preserves unknown keys). To add a driver's editor, copy the Modbus template under `Components/Shared/Uns/TagEditors/` + `Uns/TagEditors/`, reusing the driver's enums + camelCase JSON property names, and register it in `TagConfigEditorMap` + `TagConfigValidator`. See `docs/plans/2026-06-09-driver-typed-tag-editors-design.md`.
|
||||||
|
|
||||||
## Scripting / Script Editor
|
## Scripting / Script Editor
|
||||||
|
|||||||
@@ -132,6 +132,9 @@
|
|||||||
<PackageVersion Include="ZB.MOM.WW.Auth.Abstractions" Version="0.1.1" />
|
<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.Ldap" Version="0.1.1" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.Auth.AspNetCore" 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.Audit" Version="0.1.0" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.Theme" Version="0.3.1" />
|
<PackageVersion Include="ZB.MOM.WW.Theme" Version="0.3.1" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.HistorianGateway.Client" Version="0.3.0" />
|
<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.Theme" />
|
||||||
<package pattern="ZB.MOM.WW.HistorianGateway.Contracts" />
|
<package pattern="ZB.MOM.WW.HistorianGateway.Contracts" />
|
||||||
<package pattern="ZB.MOM.WW.HistorianGateway.Client" />
|
<package pattern="ZB.MOM.WW.HistorianGateway.Client" />
|
||||||
|
<package pattern="ZB.MOM.WW.Secrets" />
|
||||||
|
<package pattern="ZB.MOM.WW.Secrets.*" />
|
||||||
</packageSource>
|
</packageSource>
|
||||||
</packageSourceMapping>
|
</packageSourceMapping>
|
||||||
</configuration>
|
</configuration>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
<Project Path="src/Server/ZB.MOM.WW.OtOpcUa.Security/ZB.MOM.WW.OtOpcUa.Security.csproj" />
|
<Project Path="src/Server/ZB.MOM.WW.OtOpcUa.Security/ZB.MOM.WW.OtOpcUa.Security.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/src/Drivers/">
|
<Folder Name="/src/Drivers/">
|
||||||
|
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/ZB.MOM.WW.OtOpcUa.Driver.Calculation.csproj" />
|
||||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj" />
|
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj" />
|
||||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.csproj" />
|
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.csproj" />
|
||||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts.csproj" />
|
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts.csproj" />
|
||||||
@@ -81,6 +82,7 @@
|
|||||||
<Project Path="tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/ZB.MOM.WW.OtOpcUa.Security.Tests.csproj" />
|
<Project Path="tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/ZB.MOM.WW.OtOpcUa.Security.Tests.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/tests/Drivers/">
|
<Folder Name="/tests/Drivers/">
|
||||||
|
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests.csproj" />
|
||||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.csproj" />
|
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.csproj" />
|
||||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests.csproj" />
|
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests.csproj" />
|
||||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests.csproj" />
|
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests.csproj" />
|
||||||
|
|||||||
@@ -24,6 +24,76 @@ condition — the dedup logic prefers the richer driver-native record
|
|||||||
because it carries the full operator + raise-time + category metadata
|
because it carries the full operator + raise-time + category metadata
|
||||||
that the value-driven path collapses.
|
that the value-driven path collapses.
|
||||||
|
|
||||||
|
## v3 Batch 4 — multi-notifier delivery (raw + equipment folders)
|
||||||
|
|
||||||
|
Under the v3 dual-namespace address space, a native driver alarm is authored on a
|
||||||
|
**raw tag** (`/raw`) and its Part 9 `AlarmConditionState` materializes **once** at the
|
||||||
|
raw tag — `ConditionId` = the tag's **RawPath**, parent notifier = the raw device/group
|
||||||
|
folder (`ns=Raw`). Because equipment references raw tags (v3 reference-only UNS), the
|
||||||
|
same condition is wired as an **event notifier of every referencing equipment folder**
|
||||||
|
(`ns=UNS`) via the SDK `AddNotifier` pattern (`OtOpcUaNodeManager.WireAlarmNotifiers`):
|
||||||
|
`alarm.AddNotifier(equipFolder, isInverse:true)` + `equipFolder.AddNotifier(alarm)` +
|
||||||
|
`EnsureFolderIsEventNotifier(equipFolder)`.
|
||||||
|
|
||||||
|
- A **single** `ReportEvent` fans through the SDK notifier graph to the raw device folder,
|
||||||
|
every referencing equipment folder, and up to the Server object. A subscriber at any one
|
||||||
|
root receives **exactly one** copy of the transition — the Part 9 shared-`InstanceStateSnapshot`
|
||||||
|
dedup (`MonitoredItem.QueueEvent` → `IsEventContainedInQueue`), **never** one copy per root.
|
||||||
|
Duplicating the `ReportEvent` per root is rejected by design (distinct EventIds would break
|
||||||
|
Server-object dedup + Part 9 ack correlation).
|
||||||
|
- **Teardown is symmetric:** the node manager tracks the wired notifier pairs and calls
|
||||||
|
`RemoveNotifier(..., bidirectional:true)` on rebuild / subtree-removal / reference-removal, so
|
||||||
|
inverse-notifier entries never leak across redeploys.
|
||||||
|
- **Ack/confirm/shelve route on `ConditionId = RawPath`** (never `SourceNodeId`) regardless of
|
||||||
|
which notifier root the operator subscribed at — an ack issued from an equipment-folder
|
||||||
|
subscription resolves to the same raw condition.
|
||||||
|
- The `alerts`-topic `AlarmTransitionEvent` carries the (possibly empty) **list of referencing
|
||||||
|
equipment paths**, and the AdminUI `/alerts` page shows **one row per condition** (primary
|
||||||
|
identity RawPath + condition NodeId) with the equipment list as display metadata.
|
||||||
|
|
||||||
|
## 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 issue #473.
|
||||||
|
|
||||||
|
| 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 |
|
||||||
|
|
||||||
|
**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 fields arrive
|
||||||
|
populated on a real subscription using the standard `[EventType, SourceNode, SourceName, Time,
|
||||||
|
Message, Severity]` select clause; `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)
|
## Galaxy driver path (driver-native)
|
||||||
|
|
||||||
Restored in PR B.2 of the epic. `GalaxyDriver` implements
|
Restored in PR B.2 of the epic. `GalaxyDriver` implements
|
||||||
|
|||||||
+23
-6
@@ -199,6 +199,15 @@ The server supports all four OPC UA HistoryRead variants:
|
|||||||
`Historizing=true` and `AccessLevels.HistoryRead` are set at materialization so any compliant
|
`Historizing=true` and `AccessLevels.HistoryRead` are set at materialization so any compliant
|
||||||
OPC UA client can discover historized capability from the node's attributes.
|
OPC UA client can discover historized capability from the node's attributes.
|
||||||
|
|
||||||
|
> **v3 Batch 4 — dual-namespace registration.** A historized raw tag surfaces as **two**
|
||||||
|
> variable nodes: the Raw-namespace node (`ns=Raw, s=<RawPath>`) and, for each referencing
|
||||||
|
> equipment, a UNS-namespace node (`ns=UNS, s=<Area>/<Line>/<Equipment>/<EffectiveName>`).
|
||||||
|
> **Both NodeIds register the SAME historian tagname** (the raw tag's `FullName` /
|
||||||
|
> `historianTagname`), so HistoryRead against either NodeId resolves to the same tagname and
|
||||||
|
> returns the same series. The historization intent is single-sourced — the mux's
|
||||||
|
> `HistorizedTagRef` set stays keyed by RawPath (no doubles), and continuous historization /
|
||||||
|
> `EnsureTags` provisioning run once per raw tag regardless of how many equipment reference it.
|
||||||
|
|
||||||
**Equipment-folder event-notifier nodes** serve Event history. Every equipment folder that
|
**Equipment-folder event-notifier nodes** serve Event history. Every equipment folder that
|
||||||
owns at least one alarm condition is already an event notifier; the server registers a
|
owns at least one alarm condition is already an event notifier; the server registers a
|
||||||
`sourceName` (the equipment id) for each such folder and maps event history reads to the
|
`sourceName` (the equipment id) for each such folder and maps event history reads to the
|
||||||
@@ -355,31 +364,39 @@ for the full alarm-historian routing.
|
|||||||
The `historyread` command reads historical data from any node. Supply start and end times in
|
The `historyread` command reads historical data from any node. Supply start and end times in
|
||||||
ISO 8601 UTC form. See [docs/Client.CLI.md](Client.CLI.md) for the full flag reference.
|
ISO 8601 UTC form. See [docs/Client.CLI.md](Client.CLI.md) for the full flag reference.
|
||||||
|
|
||||||
|
> **v3 Batch 4 NodeIds.** A historized tag is readable through **either** of its two NodeIds —
|
||||||
|
> the Raw-namespace node (`s=<RawPath>`, e.g. `Plant/Modbus/dev1/Speed`) or a referencing
|
||||||
|
> equipment's UNS-namespace node (`s=<Area>/<Line>/<Equipment>/<EffectiveName>`). Both register
|
||||||
|
> the same historian tagname, so the returned series is identical. The `ns=N` index is assigned
|
||||||
|
> at connect time per the namespace URI (`https://zb.com/otopcua/raw` /
|
||||||
|
> `https://zb.com/otopcua/uns`) — resolve it from the server's `NamespaceArray` rather than
|
||||||
|
> hard-coding it. The examples below show a Raw-namespace read (`ns=2` illustrative).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Raw history for a historized Galaxy tag (last 24 hours by default)
|
# Raw history for a historized tag via its Raw-namespace NodeId (last 24 hours by default)
|
||||||
otopcua-cli historyread \
|
otopcua-cli historyread \
|
||||||
-u opc.tcp://localhost:4840/OtOpcUa \
|
-u opc.tcp://localhost:4840/OtOpcUa \
|
||||||
-n "ns=2;s=EQ-55297329838d/GalaxyTestTag" \
|
-n "ns=2;s=Plant/Modbus/dev1/Speed" \
|
||||||
--start "2026-06-13T00:00:00Z" --end "2026-06-14T00:00:00Z"
|
--start "2026-06-13T00:00:00Z" --end "2026-06-14T00:00:00Z"
|
||||||
|
|
||||||
# Limit to 100 values
|
# Same series via a referencing equipment's UNS-namespace NodeId, limited to 100 values
|
||||||
otopcua-cli historyread \
|
otopcua-cli historyread \
|
||||||
-u opc.tcp://localhost:4840/OtOpcUa \
|
-u opc.tcp://localhost:4840/OtOpcUa \
|
||||||
-n "ns=2;s=EQ-55297329838d/GalaxyTestTag" \
|
-n "ns=3;s=filling/line1/station1/Speed" \
|
||||||
--start "2026-06-13T00:00:00Z" --end "2026-06-14T00:00:00Z" \
|
--start "2026-06-13T00:00:00Z" --end "2026-06-14T00:00:00Z" \
|
||||||
--max 100
|
--max 100
|
||||||
|
|
||||||
# 1-hour average aggregate
|
# 1-hour average aggregate
|
||||||
otopcua-cli historyread \
|
otopcua-cli historyread \
|
||||||
-u opc.tcp://localhost:4840/OtOpcUa \
|
-u opc.tcp://localhost:4840/OtOpcUa \
|
||||||
-n "ns=2;s=EQ-55297329838d/GalaxyTestTag" \
|
-n "ns=2;s=Plant/Modbus/dev1/Speed" \
|
||||||
--start "2026-06-13T00:00:00Z" --end "2026-06-14T00:00:00Z" \
|
--start "2026-06-13T00:00:00Z" --end "2026-06-14T00:00:00Z" \
|
||||||
--aggregate Average --interval 3600000
|
--aggregate Average --interval 3600000
|
||||||
|
|
||||||
# Authenticated read (ReadOnly role or higher required)
|
# Authenticated read (ReadOnly role or higher required)
|
||||||
otopcua-cli historyread \
|
otopcua-cli historyread \
|
||||||
-u opc.tcp://localhost:4840/OtOpcUa \
|
-u opc.tcp://localhost:4840/OtOpcUa \
|
||||||
-n "ns=2;s=EQ-55297329838d/GalaxyTestTag" \
|
-n "ns=2;s=Plant/Modbus/dev1/Speed" \
|
||||||
--start "2026-06-13T00:00:00Z" --end "2026-06-14T00:00:00Z" \
|
--start "2026-06-13T00:00:00Z" --end "2026-06-14T00:00:00Z" \
|
||||||
-U reader -P password
|
-U reader -P password
|
||||||
```
|
```
|
||||||
|
|||||||
+91
@@ -0,0 +1,91 @@
|
|||||||
|
# The Raw project tree (`/raw`)
|
||||||
|
|
||||||
|
v3 authors device I/O in a **Raw project tree** — a cluster-rooted, Kepware-style hierarchy
|
||||||
|
that is the single surface for driver/device/tag authoring. It is the peer of the `/uns`
|
||||||
|
unified-namespace page; the two OPC UA namespaces (`ns=Raw` / `ns=UNS`) light up in Batch 4.
|
||||||
|
|
||||||
|
```
|
||||||
|
Enterprise → Cluster → Folder(s) → Driver → Device → TagGroup(s) → Tag
|
||||||
|
```
|
||||||
|
|
||||||
|
Every node's identity is its **RawPath** (`<Folder/…>/<Driver>/<Device>/<TagGroup/…>/<Tag>`,
|
||||||
|
cluster-scoped, `/`-separated, ordinal). Names are validated as RawPath segments (no `/`, no
|
||||||
|
leading/trailing whitespace) at authoring and at the deploy gate.
|
||||||
|
|
||||||
|
## The tree
|
||||||
|
|
||||||
|
`/raw` (`GlobalRaw.razor` → `RawTree.razor`, backed by `IRawTreeService`) lazily loads each
|
||||||
|
container level on expand (unlike `/uns`, which eager-loads). Each node has a right-click
|
||||||
|
context menu **and** a `⋯` fallback (the reusable `ContextMenu` component):
|
||||||
|
|
||||||
|
| Node | Actions |
|
||||||
|
|---|---|
|
||||||
|
| Cluster | New folder · New driver |
|
||||||
|
| Folder | New folder · New driver · Rename · Delete |
|
||||||
|
| Driver | Configure · New device · Enable/Disable · Rename · Delete |
|
||||||
|
| Device | Configure (endpoint + **Test connect**) · New tag-group · Add tags ▸ · Delete |
|
||||||
|
| TagGroup | New group · Add tags ▸ · Rename · Delete |
|
||||||
|
| Tag | Edit · Delete |
|
||||||
|
|
||||||
|
Deletes are blocked while a node has children (or, for a raw tag, while a `UnsTagReference`
|
||||||
|
points at it — the error names the referencing equipment). Renames return non-blocking
|
||||||
|
**warnings**: renaming a node changes the RawPath of every historized / UNS-referenced tag
|
||||||
|
beneath it (its historian tagname / UNS projection path moves).
|
||||||
|
|
||||||
|
## Endpoint → DeviceConfig (the channel/device split)
|
||||||
|
|
||||||
|
v3 moves the **connection endpoint** off the driver and onto the **Device** (`DeviceConfig`),
|
||||||
|
mirroring Kepware's channel/device split. The driver's `DriverConfig` holds protocol/channel
|
||||||
|
settings; the device's `DeviceConfig` holds host/port/endpoint. At deploy/probe time
|
||||||
|
`DriverDeviceConfigMerger` merges the device's endpoint up into the driver config, and Test
|
||||||
|
connect (inside the **Device** modal) probes that merged config. Driver forms no longer
|
||||||
|
serialize the endpoint keys, so `DeviceConfig` is the single source of truth.
|
||||||
|
|
||||||
|
## Authoring tags
|
||||||
|
|
||||||
|
**Add tags ▸** on a Device or TagGroup offers:
|
||||||
|
|
||||||
|
- **Manual entry** — a grid of Name / DataType / AccessLevel / WriteIdempotent / PollGroup +
|
||||||
|
the driver-typed `TagConfig` editor per row.
|
||||||
|
- **Import/Export CSV** — a staged flow (upload → parse → **review grid** with per-row
|
||||||
|
verdicts → commit) over an RFC-4180 parser. Columns: the common set
|
||||||
|
(`Name, TagGroupPath, DataType, AccessLevel, WriteIdempotent, PollGroup, IsHistorized,
|
||||||
|
HistorianTagname, IsArray, ArrayLength, Alarm.*, TagConfigJson`) plus the per-driver typed
|
||||||
|
columns (e.g. Modbus `Region, Address, ModbusDataType, ByteOrder, …`). `TagGroupPath`
|
||||||
|
auto-creates nested groups; typed columns win over `TagConfigJson` on conflict (flagged in
|
||||||
|
the review grid). **Import is all-or-nothing** — any invalid row blocks the whole commit.
|
||||||
|
Export round-trips the same shape.
|
||||||
|
- **Browse device…** — enabled per the two-tier resolution (bespoke `IDriverBrowser` →
|
||||||
|
universal `DiscoveryDriverBrowser` when the driver's `ITagDiscovery.SupportsOnlineDiscovery`
|
||||||
|
is true → grayed out otherwise). Multi-selected leaves become raw `Tag` rows under the
|
||||||
|
target, with the browsed reference written into the driver-typed `TagConfig` **address
|
||||||
|
field** (`nodeId` / `tagPath` / …), never an identity key. An opt-in "create matching
|
||||||
|
tag-groups" toggle mirrors the browse folder nesting. The browser dials a real ephemeral
|
||||||
|
driver against the **merged** Driver+Device config.
|
||||||
|
|
||||||
|
## The `Calculation` driver
|
||||||
|
|
||||||
|
Signal-level calculated tags are ordinary raw tags bound to a `Calculation` driver
|
||||||
|
(`DriverTypeNames.Calculation`), computed by C# scripts that read other tags' live values via
|
||||||
|
`ctx.GetTag("<RawPath>")`. See `docs/plans/2026-07-15-calculation-driver-mini-design.md` for
|
||||||
|
the full design. Highlights:
|
||||||
|
|
||||||
|
- One auto-created default `Engine` device; per-tag `TagConfig` is
|
||||||
|
`{ "scriptId": "…", "changeTriggered": true, "timerIntervalMs": 5000 }`.
|
||||||
|
- The host feeds dependency values via the new `IDependencyConsumer` capability + a
|
||||||
|
`DependencyConsumerMuxAdapter` on the per-node dependency mux; calc-of-calc chains work
|
||||||
|
because a calc output re-enters the mux. The driver rebuilds its tag/dependency table on
|
||||||
|
every redeploy (`ReinitializeAsync`) and re-registers its refs after the delta applies.
|
||||||
|
- **Deploy gates** (`DraftValidator`): `scriptId` existence, and a hard **cycle gate**
|
||||||
|
(`DependencyGraph.DetectCycles` over calc→calc edges; cross-driver refs are terminal) that
|
||||||
|
rejects an A→B→A oscillation loop naming the members. Compile is not hard-gated (VirtualTag
|
||||||
|
parity); a runtime compile/throw/timeout lands as Bad quality + a `script-logs` entry.
|
||||||
|
|
||||||
|
## Retirement note
|
||||||
|
|
||||||
|
The routed `/clusters/{id}/drivers` driver-authoring flow (`DriverTypePicker`,
|
||||||
|
`DriverEditRouter`, the 8 per-type `*DriverPage` shells, and the per-cluster Drivers list) is
|
||||||
|
**retired** — authoring lives in `/raw`. The extracted driver/device **form bodies** live on
|
||||||
|
inside the `/raw` modals. The `DriverType` dispatch maps (`TagConfigEditorMap`,
|
||||||
|
`TagConfigValidator`, `EquipmentTagConfigInspector`) are keyed off the single-source-of-truth
|
||||||
|
`DriverTypeNames` constants (fixing the historical `TwinCat`/`Focas` drift).
|
||||||
+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
|
`DbContext` via the pooled factory (same pattern as `UnsTreeService`). Results
|
||||||
are bounded to 200 entries to keep the completion list responsive on large fleets.
|
are bounded to 200 entries to keep the completion list responsive on large fleets.
|
||||||
|
|
||||||
|
> **v3 Batch 4 (dual namespace) — unchanged for scripts.** A `ctx.GetTag(...)` literal
|
||||||
|
> still resolves by the tag's `FullName` / **RawPath** — which is precisely the identity
|
||||||
|
> of the Raw-namespace node (`ns=Raw, s=<RawPath>`). A `{{equip}}/<RefName>` token resolves
|
||||||
|
> through the equipment's `UnsTagReference` to that same backing RawPath (see below), so
|
||||||
|
> scripts key off the single value source regardless of the UNS projection. The dual
|
||||||
|
> namespace changes only the OPC UA address-space surface, not script tag-path semantics.
|
||||||
|
|
||||||
### Literal gate
|
### Literal gate
|
||||||
|
|
||||||
`TryGetTagPathLiteral` identifies the tag-path context by climbing from the
|
`TryGetTagPathLiteral` identifies the tag-path context by climbing from the
|
||||||
@@ -307,76 +314,88 @@ Register the replacement in `EndpointRouteBuilderExtensions.AddAdminUI`
|
|||||||
|
|
||||||
### Why
|
### Why
|
||||||
|
|
||||||
Today each VirtualTag bound to a script typically needs its own near-duplicate
|
Each VirtualTag bound to a script would otherwise need its own near-duplicate
|
||||||
script because tag paths are hard-coded absolutes (e.g. `TestMachine_001.Speed`).
|
script because tag paths are hard-coded absolute RawPaths (e.g.
|
||||||
The `{{equip}}` token breaks this coupling: point many VirtualTags' `ScriptId` at
|
`Cell1/Modbus/Dev1/Speed`). The `{{equip}}/<RefName>` token breaks this coupling:
|
||||||
a single template script, and each resolves the token to its own equipment's tag
|
point many VirtualTags' `ScriptId` at a single template script, and each resolves
|
||||||
base prefix at deploy time. No schema change is required — sharing a `Script`
|
the token through **its own equipment's tag references** at deploy time. No schema
|
||||||
record across VirtualTags already works; `{{equip}}` is what makes the shared
|
change is required — sharing a `Script` record across VirtualTags already works;
|
||||||
script resolve per-equipment.
|
`{{equip}}/<RefName>` is what makes the shared script resolve per-equipment.
|
||||||
|
|
||||||
### Before / after
|
### Before / after
|
||||||
|
|
||||||
**Before — one script per machine:**
|
**Before — one script per machine (hard-coded RawPath):**
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
// Script "Calc_TestMachine_001" — hard-coded, cannot reuse
|
// 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:**
|
**After — one shared template:**
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
// Script "Calc_Speed" — works for any machine
|
// 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"`.
|
`TestMachine_001` and `TestMachine_002` both bind `ScriptId = "Calc_Speed"`, and
|
||||||
At deploy, each VirtualTag receives its own expanded copy:
|
each has a **tag reference** whose effective name is `Speed`. At deploy, each
|
||||||
`TestMachine_001.Speed` and `TestMachine_002.Speed` respectively.
|
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
|
- It is substituted **only inside `ctx.GetTag(…)` / `ctx.SetVirtualTag(…)` first-argument
|
||||||
string literals** — comments, logger strings, and other code are untouched.
|
string literals** — comments, logger strings, and other code are untouched.
|
||||||
- The operator writes the separator and tail: `ctx.GetTag("{{equip}}.Speed")`,
|
- The whole post-prefix literal content is the reference name, so effective names
|
||||||
`ctx.GetTag("{{equip}}.Sub.Field")`, etc.
|
with internal spaces are supported: `ctx.GetTag("{{equip}}/Motor Speed")`.
|
||||||
- The token expands to the equipment's **tag base prefix** — the common
|
- `{{equip}}/<RefName>` **resolves through the owning equipment's `UnsTagReference`
|
||||||
substring-before-the-first-dot of that equipment's configured driver-tag
|
rows** (by effective name) to the backing raw tag's `RawPath`. Example: a
|
||||||
`FullName` values. Example: tags `TestMachine_001.Speed` and
|
reference named `Speed` backing `Cell1/Modbus/Dev1/Speed` → the token expands to
|
||||||
`TestMachine_001.Temp` → base `TestMachine_001`.
|
`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
|
### Validation requirement
|
||||||
|
|
||||||
Saving a VirtualTag that is bound to a `{{equip}}`-using script is rejected in
|
Saving a VirtualTag (or ScriptedAlarm) whose script uses `{{equip}}/<RefName>` is
|
||||||
the AdminUI if the equipment does not have at least one configured driver tag, or
|
rejected in the AdminUI when `<RefName>` is not one of the owning equipment's
|
||||||
if its tags span multiple object prefixes (i.e. the common prefix is ambiguous or
|
reference effective names. The same rule runs at the deploy gate
|
||||||
absent). The rejection message is surfaced as a clear validation error on the save
|
(`DraftValidator.ValidateEquipReferenceResolution`, error code
|
||||||
form. This check is enforced eagerly so that an unresolved `{{equip}}` token —
|
`EquipReferenceUnresolved`), which also catches a **rename-induced** breakage the
|
||||||
which would leave a path that resolves to nothing at runtime (Bad quality) — can
|
authoring check never saw. The rejection names the script/alarm, the equipment, and
|
||||||
never reach the deployed artifact.
|
the missing ref — so an unresolved token can never reach the deployed artifact.
|
||||||
|
|
||||||
### Editor support
|
### Editor support
|
||||||
|
|
||||||
In the Monaco script editor (ScriptEdit page and the `/uns` virtual-tag modal's
|
In the Monaco script editor (ScriptEdit page and the `/uns` virtual-tag modal's
|
||||||
inline script panel):
|
inline script panel):
|
||||||
|
|
||||||
- **Hover** — hovering a `{{equip}}` path literal shows an
|
- **Hover** — hovering a `{{equip}}/<RefName>` path literal shows an
|
||||||
*"Equipment-relative path — resolved at deploy"* note.
|
*"Equipment-relative path — resolved through the equipment's tag reference at
|
||||||
- **Completions** — typing `{{equip}}.` inside a `GetTag`/`SetVirtualTag` literal
|
deploy"* note.
|
||||||
offers completion of attribute leaf names (the part after the first dot of known
|
- **Completions** — typing `{{equip}}/` inside a `GetTag`/`SetVirtualTag` literal
|
||||||
tag references in the catalog).
|
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
|
### Maintainer note
|
||||||
|
|
||||||
Substitution runs at the two compose seams —
|
Substitution runs at the two compose seams — `AddressSpaceComposer.Compose` and
|
||||||
`Phase7Composer.Compose` and `DeploymentArtifact.BuildEquipmentVirtualTagPlans`
|
`DeploymentArtifact.ParseComposition` — via the shared
|
||||||
— via the shared `ZB.MOM.WW.OtOpcUa.Commons.Types.EquipmentScriptPaths` helper,
|
`ZB.MOM.WW.OtOpcUa.Commons.Types.EquipmentScriptPaths.SubstituteEquipmentToken`
|
||||||
**before** dependency extraction. The runtime, the static change-trigger
|
helper, fed the equipment's reference map (effective name → RawPath) built by
|
||||||
dependency graph, and the literal-only path rule are therefore all unchanged:
|
`EquipmentReferenceMap` over the shared `RawPathResolver`. Substitution runs
|
||||||
by the time they see the script, `{{equip}}` has been replaced with a concrete
|
**before** dependency extraction, so the runtime, the static change-trigger
|
||||||
tag-base prefix and the path is a normal string literal.
|
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
|
(unchanged behaviour). `"alarm"` **present** → the tag materialises as a Part 9
|
||||||
`AlarmConditionState` under its equipment folder **instead of** a value variable.
|
`AlarmConditionState` under its equipment folder **instead of** a value variable.
|
||||||
No EF/schema change is required; the intent rides in the schemaless `TagConfig`
|
No EF/schema change is required; the intent rides in the schemaless `TagConfig`
|
||||||
blob and is parsed byte-parity in both the compose (`Phase7Composer`) and deploy
|
blob and is parsed byte-parity in both the compose (`AddressSpaceComposer`) and deploy
|
||||||
(`DeploymentArtifact`) paths.
|
(`DeploymentArtifact`) paths.
|
||||||
|
|
||||||
|
> **v3 Batch 4 — multi-notifier fan-out.** The `"alarm"` object now rides on a **raw
|
||||||
|
> tag** authored in `/raw` (referenced into equipment), and the Part 9 condition
|
||||||
|
> materializes **once at the raw tag** — its `ConditionId` is the tag's **RawPath**, its
|
||||||
|
> parent notifier is the raw device/group folder. For **each referencing equipment**, the
|
||||||
|
> node manager (`WireAlarmNotifiers`) wires the SDK `AddNotifier` pattern
|
||||||
|
> (`alarm.AddNotifier(equipFolder, isInverse:true)` + `equipFolder.AddNotifier(alarm)` +
|
||||||
|
> `EnsureFolderIsEventNotifier(equipFolder)`) so the single condition fans to the raw device
|
||||||
|
> folder AND every equipment folder. A **single** `ReportEvent` fans to all notifier roots —
|
||||||
|
> a Server-object subscriber sees **exactly one** copy (the Part 9 shared-snapshot dedup),
|
||||||
|
> never one-per-root. Teardown is symmetric (`RemoveNotifier(..., bidirectional:true)` on
|
||||||
|
> rebuild / reference removal) so inverse-notifier entries never leak across redeploys.
|
||||||
|
> Ack/confirm/shelve route on **`ConditionId = RawPath`** (never `SourceNodeId`), and the
|
||||||
|
> `/alerts` row lists the referencing equipment paths. See
|
||||||
|
> [AlarmTracking.md](AlarmTracking.md) and the WP4 section of
|
||||||
|
> `docs/plans/2026-07-15-v3-batch4-address-space-plan.md`.
|
||||||
|
|
||||||
### TagConfig alarm fields
|
### TagConfig alarm fields
|
||||||
|
|
||||||
| Field | Values | Default |
|
| Field | Values | Default |
|
||||||
@@ -208,9 +224,10 @@ native alarm transitions identically. Publication to the `alerts` topic is
|
|||||||
the OPC UA condition-node write is ungated on all nodes so a Secondary stays warm
|
the OPC UA condition-node write is ungated on all nodes so a Secondary stays warm
|
||||||
for failover.
|
for failover.
|
||||||
|
|
||||||
The alarm is authored on the `Tags` tab of the equipment page (`/uns/equipment/{id}`)
|
The alarm is authored on the **raw tag** in `/raw` by including the `"alarm"` object in the
|
||||||
by editing the tag's raw `TagConfig` JSON to include the `"alarm"` object. No
|
tag's `TagConfig` JSON (v3 Batch 4 — equipment no longer authors tags; it references raw
|
||||||
other configuration is required.
|
tags). No other configuration is required: any equipment that references the raw tag
|
||||||
|
automatically receives the alarm at its equipment folder via the multi-notifier fan-out above.
|
||||||
|
|
||||||
### Native-alarm OPC UA operator operations
|
### Native-alarm OPC UA operator operations
|
||||||
|
|
||||||
|
|||||||
+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 —
|
whole branch. There is no separate "served-by" concept and no migration —
|
||||||
it is simply `UnsArea.ClusterId`.
|
it is simply `UnsArea.ClusterId`.
|
||||||
|
|
||||||
### Tags
|
### Tags — reference-only (v3)
|
||||||
|
|
||||||
Tags created on the equipment page are **equipment-bound** and require a driver
|
> **v3 (Batch 3):** UNS equipment no longer authors or binds tags. Device I/O is
|
||||||
instance. The driver list on the Tags tab is scoped to the equipment's cluster
|
> authored once in the **Raw project tree** (`/raw` — see [`Raw.md`](Raw.md)); an
|
||||||
and to drivers on an **Equipment-kind** namespace, so a driver-less equipment
|
> equipment's **Tags** tab holds **references** to those raw tags. The old
|
||||||
shows no eligible drivers until you bind one (edit the equipment on the Details
|
> driver-bound Tag modal on this tab is retired.
|
||||||
tab and pick a driver).
|
|
||||||
|
|
||||||
**Galaxy / AVEVA System Platform points are ordinary equipment tags** bound to
|
The **Tags** tab is a list of `UnsTagReference` rows. Each row shows the
|
||||||
a `GalaxyMxGateway` driver instance. Author them on the Tags tab using the
|
**effective name**, the backing tag's **RawPath**, its inherited **DataType** and
|
||||||
standard Tag modal; the Galaxy address picker browses the live Galaxy hierarchy
|
**AccessLevel** (read-only — they come from the raw tag), and an editable
|
||||||
so you can select the attribute and set `TagConfig.FullName`. There is no
|
**display-name override**. The effective name is the override else the raw tag's
|
||||||
separate alias concept or `SystemPlatform`-kind namespace.
|
`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
|
### Virtual tags
|
||||||
|
|
||||||
A virtual tag is bound to an equipment and driven by a **script** (no driver).
|
A virtual tag is bound to an equipment and driven by a **script** (no driver).
|
||||||
Add and edit virtual tags on the equipment page's **Virtual Tags** tab; the
|
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
|
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
|
### 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,74 @@
|
|||||||
|
# v3 Batch 2 — `/raw` project-tree AdminUI + `Calculation` driver
|
||||||
|
|
||||||
|
Implements `docs/plans/2026-07-15-v3-batch2-raw-ui-calculation-plan.md` (Track 0 + WP1–WP8),
|
||||||
|
executed via the `/v3-batch 2` 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-item live gate on docker-dev.
|
||||||
|
|
||||||
|
## What landed
|
||||||
|
|
||||||
|
- **Track 0** — reusable `ContextMenu` (right-click + `⋯`, keyboard/focus); RFC-4180
|
||||||
|
`CsvParser`/`CsvWriter`; `DriverTypeNames` constants + a reflection-discovery guard;
|
||||||
|
`CalculationEvaluator` core (VirtualTag-evaluator parity).
|
||||||
|
- **Wave A** — `IRawTreeService`/`RawTreeService` (lazy tree + mutations, integrity guards,
|
||||||
|
rename-warnings); `/raw` page + lazy `RawTree` with per-node context menus.
|
||||||
|
- **Wave B** — driver→embeddable-form refactor + `DriverConfigModal`/`DeviceModal`
|
||||||
|
(endpoint→`DeviceConfig`, Test-connect via merged config); manual tag entry + tag edit
|
||||||
|
modal + `Calculation` tag editor; CSV import (staged review grid) / export; all wired into
|
||||||
|
the tree (name/confirm/driver-type dialogs + post-mutation refresh).
|
||||||
|
- **Wave C** — browse re-target (two-tier gate, merged-config feed, commit → raw tags with
|
||||||
|
address-field mapping + folder mirroring); `Calculation` driver (`IDependencyConsumer` +
|
||||||
|
mux adapter, timer + change triggers, `DraftValidator` scriptId + cycle gates);
|
||||||
|
`DriverTypeNames` rewire + retirement of the routed driver flow.
|
||||||
|
|
||||||
|
**Address space stays dark** (values light up in Batch 4). Full solution builds 0 errors;
|
||||||
|
`AdminUI.Tests` 638 passed / 3 skipped, `Runtime.Tests` 357 passed, driver + guard suites green.
|
||||||
|
|
||||||
|
## Reviewer findings fixed in-branch
|
||||||
|
|
||||||
|
- **Wave B H1** — driver forms still serialized the endpoint into `DriverConfig`; stripped
|
||||||
|
the endpoint keys (Modbus/S7/OpcUaClient) so `DeviceConfig` is the sole source (a 2nd
|
||||||
|
device on a single-Host driver no longer reverts to `127.0.0.1`).
|
||||||
|
- **Wave C HIGH** — `CalculationDriver.ReinitializeAsync` ignored the new config, so calc
|
||||||
|
tag/script edits were silently inert after a redeploy and the re-register loop was
|
||||||
|
decorative. Now rebuilds the tag/dependency table on reinit and re-registers deps after
|
||||||
|
the delta applies (`DeltaApplied` notification, race-free). New redeploy test proves it.
|
||||||
|
- Wave-A/B/0 mediums (auto-expand, friendly create-race/delete failures, discovery-driven
|
||||||
|
guard, CSV comment). Deferred (documented): pre-existing Modbus/S7 form `TimeSpan`↔`*Ms`
|
||||||
|
DTO mismatch; CSV alarm-subfield/flag-precedence edge cases; OpcUaClient endpoint
|
||||||
|
precedence; unused `LoadMergedProbeConfigAsync`; refresh collapses sibling expansion.
|
||||||
|
|
||||||
|
## Live `/run` gate (docker-dev `:9200`, both central nodes rebuilt)
|
||||||
|
|
||||||
|
1. **Authoring + Test-connect** — `/raw` → New Modbus driver `gate-modbus` (form body renders,
|
||||||
|
`PLC family`/`MELSEC` enum dropdowns correct) → Device1 endpoint `10.100.0.35:5020` in
|
||||||
|
`DeviceConfig` → **Test connect OK · 94 ms** against the live Modbus fixture; SQL confirms
|
||||||
|
endpoint in `DeviceConfig` (`{"Host":"10.100.0.35","Port":5020,"UnitId":1}`).
|
||||||
|
2. **Historian-tagname deploy block** — a historized tag with a 260-char effective tagname →
|
||||||
|
`POST /api/deployments` **422** `[HistorianTagnameTooLong] tag 'TAG-gatelong' … is 260 chars
|
||||||
|
(max 255)`; shortened override → **202 Accepted**.
|
||||||
|
3. **CSV round-trip + bad-enum** — export produces the exact column dictionary
|
||||||
|
(`…,Region,Address,ModbusDataType,ByteOrder,…`); importing 1 valid + 1 bad-enum row →
|
||||||
|
review grid "2 rows, 1 valid, 1 invalid", row-2 error *"Column 'ModbusDataType':
|
||||||
|
'NOTATYPE' is not a valid ModbusDataType (expected one of: Bool, Int16, UInt16, …)"*,
|
||||||
|
**Commit disabled** (all-or-nothing; SQL confirms nothing committed); a valid-only import →
|
||||||
|
Commit → `ImportedTag` lands with typed columns assembled into `TagConfig`.
|
||||||
|
4. **Browse-commit** — OpcUaClient device → opc-plc (`opc.tcp://10.100.0.35:50000`): two-tier
|
||||||
|
gate enabled browse, merged config connected, real tree walked (NetworkSet/…/OpcPlc);
|
||||||
|
multi-selected 2 leaves with "create matching tag-groups" → raw tags land under the
|
||||||
|
mirrored `Basic` group with DataType + the browse ref in the `nodeId` address field
|
||||||
|
(`nsu=…;s=AlternatingBoolean`), not an identity key.
|
||||||
|
5. **Calculation deploy + cycle gate** — a calc tag reading a Modbus RawPath deploys **202**;
|
||||||
|
a 2-cycle (`calc1/Engine/A`↔`calc1/Engine/B`) → **422** `[CalculationDependencyCycle] …
|
||||||
|
(members: calc1/Engine/A, calc1/Engine/B)`; clean redeploy 202, no crash loops (both
|
||||||
|
central nodes stable; only a deploy-reject WRN in the log).
|
||||||
|
6. **Rename warning** — renaming `gate-modbus`→`gate-modbus-renamed` fired the warning modal
|
||||||
|
*"1 historized tag beneath this node will change its RawPath (historian tagname)…"*.
|
||||||
|
7. **Retired routes** — `/clusters/{id}/drivers`, `…/new`, `…/new/{slug}`, `…/{id}` all
|
||||||
|
return **404**; `/raw` + `/uns` still 200.
|
||||||
|
|
||||||
|
## Docs
|
||||||
|
|
||||||
|
`docs/Raw.md` (new — the `/raw` authoring tree + endpoint split + CSV + Calculation +
|
||||||
|
retirement note); `CLAUDE.md` AdminUI section updated; the Calculation mini-design remains the
|
||||||
|
driver's authority.
|
||||||
@@ -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
|
## Troubleshooting
|
||||||
|
|
||||||
### Certificate trust failure
|
### Certificate trust failure
|
||||||
|
|||||||
@@ -0,0 +1,293 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Commons.Csv;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A pure, allocation-lean RFC 4180 CSV reader with no file/stream I/O of its own — it consumes a
|
||||||
|
/// <see cref="string"/> or a <see cref="TextReader"/> and yields rows of fields. This is the single
|
||||||
|
/// CSV-reading authority in the tree; keep the RFC contract pinned here (and mirrored in
|
||||||
|
/// <see cref="CsvWriter"/>) rather than re-deriving it at each call site.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para><b>Faithful to RFC 4180.</b> The grammar handled:</para>
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>Fields are separated by the delimiter (default <c>','</c>); records are separated by
|
||||||
|
/// newlines.</item>
|
||||||
|
/// <item>A field may be quoted with double-quotes (<c>"</c>). A quoted field may contain the
|
||||||
|
/// delimiter, CR, LF, and CRLF literally, and represents a literal double-quote by doubling it
|
||||||
|
/// (<c>""</c> → <c>"</c>).</item>
|
||||||
|
/// <item>An unquoted field runs literally up to the next delimiter or newline; leading and
|
||||||
|
/// trailing spaces are preserved (RFC 4180 §2.4 — spaces are part of a field).</item>
|
||||||
|
/// <item>CRLF, bare LF, and bare CR are all accepted as record terminators.</item>
|
||||||
|
/// </list>
|
||||||
|
/// <para><b>Empty-line policy.</b> Faithful to the RFC: a line with no characters yields a single row
|
||||||
|
/// containing one empty field (<c>[""]</c>); it is NOT silently dropped. Callers that want blank lines
|
||||||
|
/// skipped should filter the result (e.g. <c>rows.Where(r => r.Length > 1 || r[0].Length > 0)</c>).
|
||||||
|
/// A trailing record terminator at end-of-input does NOT produce a phantom empty final row, and its
|
||||||
|
/// absence does not lose the last row.</para>
|
||||||
|
/// <para><b>Malformed-input policy.</b> This parser is <b>strict</b>. It throws
|
||||||
|
/// <see cref="System.FormatException"/> (carrying a 1-based line/column position) for the malformed
|
||||||
|
/// shapes RFC 4180 forbids: (1) a bare double-quote inside an otherwise-unquoted field (e.g.
|
||||||
|
/// <c>ab"c</c>), (2) a stray character after a closing quote other than the delimiter or a newline
|
||||||
|
/// (e.g. <c>"ab"c</c>), and (3) an unterminated quoted field at end-of-input. Strictness is deliberate
|
||||||
|
/// — silent lenient recovery hides data-shape bugs in imported files.</para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class CsvParser
|
||||||
|
{
|
||||||
|
private const char Quote = '"';
|
||||||
|
private const char Cr = '\r';
|
||||||
|
private const char Lf = '\n';
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses <paramref name="text"/> fully into rows of fields. Convenience wrapper over
|
||||||
|
/// <see cref="Parse(TextReader,char)"/>; materialises the whole document.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="text">The CSV document. <c>null</c> is treated as empty.</param>
|
||||||
|
/// <param name="delimiter">The field separator (default comma).</param>
|
||||||
|
/// <returns>The rows, each an array of field values. Empty input yields zero rows.</returns>
|
||||||
|
/// <exception cref="System.FormatException">The input violates the strict RFC 4180 grammar.</exception>
|
||||||
|
public static IReadOnlyList<string[]> Parse(string? text, char delimiter = ',')
|
||||||
|
{
|
||||||
|
using var reader = new StringReader(text ?? string.Empty);
|
||||||
|
var rows = new List<string[]>();
|
||||||
|
foreach (var row in Parse(reader, delimiter))
|
||||||
|
{
|
||||||
|
rows.Add(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Streams rows from <paramref name="reader"/> lazily — a single forward pass, one row
|
||||||
|
/// materialised at a time. The reader is not disposed by this method.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="reader">The source. Read to end-of-input.</param>
|
||||||
|
/// <param name="delimiter">The field separator (default comma).</param>
|
||||||
|
/// <returns>A lazily-evaluated sequence of rows; each row is a freshly allocated field array.</returns>
|
||||||
|
/// <exception cref="System.ArgumentNullException"><paramref name="reader"/> is <c>null</c>.</exception>
|
||||||
|
/// <exception cref="System.ArgumentException"><paramref name="delimiter"/> is a quote, CR, or LF.</exception>
|
||||||
|
/// <exception cref="System.FormatException">The input violates the strict RFC 4180 grammar.</exception>
|
||||||
|
public static IEnumerable<string[]> Parse(TextReader reader, char delimiter = ',')
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(reader);
|
||||||
|
if (delimiter is Quote or Cr or Lf)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Delimiter must not be a double-quote, CR, or LF.", nameof(delimiter));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Iterate(reader, delimiter);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<string[]> Iterate(TextReader reader, char delimiter)
|
||||||
|
{
|
||||||
|
var field = new StringBuilder();
|
||||||
|
var row = new List<string>();
|
||||||
|
|
||||||
|
// True once the current row has produced any field boundary or content — i.e. once we've seen a
|
||||||
|
// char that commits us to emitting at least one field. Reset to false right after a record
|
||||||
|
// terminator closes a row, so a trailing newline at EOF does NOT synthesise a phantom row.
|
||||||
|
var rowOpen = false;
|
||||||
|
|
||||||
|
// 1-based cursor, maintained for FormatException messages.
|
||||||
|
var line = 1;
|
||||||
|
var col = 0;
|
||||||
|
|
||||||
|
int read;
|
||||||
|
while ((read = reader.Read()) != -1)
|
||||||
|
{
|
||||||
|
var c = (char)read;
|
||||||
|
col++;
|
||||||
|
|
||||||
|
if (c == Quote)
|
||||||
|
{
|
||||||
|
if (field.Length != 0)
|
||||||
|
{
|
||||||
|
throw new FormatException(
|
||||||
|
$"Unexpected double-quote inside an unquoted field at line {line}, column {col}. " +
|
||||||
|
"A field is quoted only when the quote is its first character.");
|
||||||
|
}
|
||||||
|
|
||||||
|
rowOpen = true;
|
||||||
|
|
||||||
|
// Consume the quoted body; the opening quote has been read.
|
||||||
|
ReadQuotedField(reader, field, ref line, ref col);
|
||||||
|
|
||||||
|
// A closing quote must be followed by the delimiter, a newline, or EOF.
|
||||||
|
var next = reader.Peek();
|
||||||
|
if (next == -1)
|
||||||
|
{
|
||||||
|
row.Add(field.ToString());
|
||||||
|
field.Clear();
|
||||||
|
yield return row.ToArray();
|
||||||
|
row.Clear();
|
||||||
|
rowOpen = false;
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
var nc = (char)next;
|
||||||
|
if (nc == delimiter)
|
||||||
|
{
|
||||||
|
reader.Read();
|
||||||
|
col++;
|
||||||
|
row.Add(field.ToString());
|
||||||
|
field.Clear();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nc is Cr or Lf)
|
||||||
|
{
|
||||||
|
row.Add(field.ToString());
|
||||||
|
field.Clear();
|
||||||
|
ConsumeNewline(reader, ref line, ref col);
|
||||||
|
yield return row.ToArray();
|
||||||
|
row.Clear();
|
||||||
|
rowOpen = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new FormatException(
|
||||||
|
$"Unexpected character '{nc}' after closing quote at line {line}, column {col + 1}. " +
|
||||||
|
"A quoted field must be followed by a delimiter, a newline, or end-of-input.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (c == delimiter)
|
||||||
|
{
|
||||||
|
row.Add(field.ToString());
|
||||||
|
field.Clear();
|
||||||
|
rowOpen = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (c is Cr or Lf)
|
||||||
|
{
|
||||||
|
row.Add(field.ToString());
|
||||||
|
field.Clear();
|
||||||
|
if (c == Cr && reader.Peek() == Lf)
|
||||||
|
{
|
||||||
|
reader.Read();
|
||||||
|
}
|
||||||
|
|
||||||
|
line++;
|
||||||
|
col = 0;
|
||||||
|
yield return row.ToArray();
|
||||||
|
row.Clear();
|
||||||
|
rowOpen = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
field.Append(c);
|
||||||
|
rowOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// EOF: emit a trailing row only if content is pending. A clean terminator already cleared rowOpen.
|
||||||
|
if (rowOpen || field.Length != 0 || row.Count != 0)
|
||||||
|
{
|
||||||
|
row.Add(field.ToString());
|
||||||
|
yield return row.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads the body of a quoted field into <paramref name="field"/>. The opening quote has already
|
||||||
|
/// been consumed. On return the reader sits immediately after the closing quote.
|
||||||
|
/// </summary>
|
||||||
|
private static void ReadQuotedField(TextReader reader, StringBuilder field, ref int line, ref int col)
|
||||||
|
{
|
||||||
|
var openLine = line;
|
||||||
|
var openCol = col;
|
||||||
|
|
||||||
|
int read;
|
||||||
|
while ((read = reader.Read()) != -1)
|
||||||
|
{
|
||||||
|
var c = (char)read;
|
||||||
|
col++;
|
||||||
|
|
||||||
|
if (c == Quote)
|
||||||
|
{
|
||||||
|
if (reader.Peek() == Quote)
|
||||||
|
{
|
||||||
|
reader.Read();
|
||||||
|
col++;
|
||||||
|
field.Append(Quote);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return; // closing quote
|
||||||
|
}
|
||||||
|
|
||||||
|
if (c == Lf)
|
||||||
|
{
|
||||||
|
line++;
|
||||||
|
col = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
field.Append(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new FormatException(
|
||||||
|
$"Unterminated quoted field opened at line {openLine}, column {openCol}: reached end-of-input " +
|
||||||
|
"before the closing double-quote.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Consumes a newline (CRLF, CR, or LF) whose first character has been peeked but not read.</summary>
|
||||||
|
private static void ConsumeNewline(TextReader reader, ref int line, ref int col)
|
||||||
|
{
|
||||||
|
var first = reader.Read();
|
||||||
|
if (first == Cr && reader.Peek() == Lf)
|
||||||
|
{
|
||||||
|
reader.Read();
|
||||||
|
}
|
||||||
|
|
||||||
|
line++;
|
||||||
|
col = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Header-aware convenience: parses <paramref name="text"/> and maps every subsequent row onto the
|
||||||
|
/// first (header) row's field names. Thin wrapper over <see cref="Parse(string,char)"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="text">The CSV document; the first row is treated as the header.</param>
|
||||||
|
/// <param name="delimiter">The field separator (default comma).</param>
|
||||||
|
/// <returns>
|
||||||
|
/// One dictionary per data row, keyed by header name (ordinal, case-sensitive). A row shorter than
|
||||||
|
/// the header maps only the columns present; a column beyond the header's width is keyed by its
|
||||||
|
/// 0-based index rendered as a string. An empty document yields zero rows.
|
||||||
|
/// </returns>
|
||||||
|
/// <exception cref="System.FormatException">The input violates the strict RFC 4180 grammar, or the header contains a duplicate column name.</exception>
|
||||||
|
public static IReadOnlyList<IReadOnlyDictionary<string, string>> ParseWithHeader(string? text, char delimiter = ',')
|
||||||
|
{
|
||||||
|
var rows = Parse(text, delimiter);
|
||||||
|
if (rows.Count == 0)
|
||||||
|
{
|
||||||
|
return Array.Empty<IReadOnlyDictionary<string, string>>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var header = rows[0];
|
||||||
|
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
foreach (var name in header)
|
||||||
|
{
|
||||||
|
if (!seen.Add(name))
|
||||||
|
{
|
||||||
|
throw new FormatException($"Duplicate header column name '{name}'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = new List<IReadOnlyDictionary<string, string>>(rows.Count - 1);
|
||||||
|
for (var i = 1; i < rows.Count; i++)
|
||||||
|
{
|
||||||
|
var cells = rows[i];
|
||||||
|
var map = new Dictionary<string, string>(cells.Length, StringComparer.Ordinal);
|
||||||
|
for (var c = 0; c < cells.Length; c++)
|
||||||
|
{
|
||||||
|
var key = c < header.Length ? header[c] : c.ToString(CultureInfo.InvariantCulture);
|
||||||
|
map[key] = cells[c];
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Add(map);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Commons.Csv;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A pure RFC 4180 CSV writer with no file/stream I/O of its own — it renders rows of fields to a
|
||||||
|
/// <see cref="string"/> or a <see cref="TextWriter"/>. The inverse of <see cref="CsvParser"/>:
|
||||||
|
/// <c>CsvParser.Parse(CsvWriter.WriteToString(rows))</c> reproduces <c>rows</c> exactly for any field
|
||||||
|
/// content.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para><b>Quote-on-demand.</b> A field is wrapped in double-quotes only when it must be — i.e. when
|
||||||
|
/// it contains the delimiter, a double-quote, CR, or LF — and internal double-quotes are doubled
|
||||||
|
/// (<c>"</c> → <c>""</c>). Fields that need no quoting are emitted verbatim, so ordinary values stay
|
||||||
|
/// human-readable. Pass <c>quoteAllFields: true</c> to force every field quoted.</para>
|
||||||
|
/// <para><b>Newline.</b> The record terminator defaults to CRLF (<c>\r\n</c>) per RFC 4180 and is
|
||||||
|
/// configurable. No terminator is written after the final row (matching the parser's
|
||||||
|
/// no-phantom-trailing-row contract, so a round-trip is exact).</para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class CsvWriter
|
||||||
|
{
|
||||||
|
private const char Quote = '"';
|
||||||
|
private const char Cr = '\r';
|
||||||
|
private const char Lf = '\n';
|
||||||
|
|
||||||
|
/// <summary>The RFC 4180 record terminator, <c>"\r\n"</c>. The default <c>newline</c> for every write.</summary>
|
||||||
|
public const string Crlf = "\r\n";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Renders <paramref name="rows"/> to a CSV string.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="rows">The rows to write; each inner sequence is one record's fields. A <c>null</c> field is written as empty.</param>
|
||||||
|
/// <param name="delimiter">The field separator (default comma).</param>
|
||||||
|
/// <param name="newline">The record terminator between rows (default CRLF). Not appended after the last row.</param>
|
||||||
|
/// <param name="quoteAllFields">When <c>true</c>, every field is quoted regardless of content.</param>
|
||||||
|
/// <returns>The CSV text. An empty <paramref name="rows"/> yields the empty string.</returns>
|
||||||
|
/// <exception cref="System.ArgumentNullException"><paramref name="rows"/> is <c>null</c>.</exception>
|
||||||
|
/// <exception cref="System.ArgumentException"><paramref name="delimiter"/> is a quote, CR, or LF.</exception>
|
||||||
|
public static string WriteToString(
|
||||||
|
IEnumerable<IEnumerable<string?>> rows,
|
||||||
|
char delimiter = ',',
|
||||||
|
string newline = Crlf,
|
||||||
|
bool quoteAllFields = false)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
using var writer = new StringWriter(sb);
|
||||||
|
Write(writer, rows, delimiter, newline, quoteAllFields);
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes <paramref name="rows"/> to <paramref name="writer"/>. The writer is not disposed or
|
||||||
|
/// flushed by this method.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="writer">The destination.</param>
|
||||||
|
/// <param name="rows">The rows to write; each inner sequence is one record's fields. A <c>null</c> field is written as empty.</param>
|
||||||
|
/// <param name="delimiter">The field separator (default comma).</param>
|
||||||
|
/// <param name="newline">The record terminator between rows (default CRLF). Not appended after the last row.</param>
|
||||||
|
/// <param name="quoteAllFields">When <c>true</c>, every field is quoted regardless of content.</param>
|
||||||
|
/// <exception cref="System.ArgumentNullException"><paramref name="writer"/> or <paramref name="rows"/> is <c>null</c>.</exception>
|
||||||
|
/// <exception cref="System.ArgumentException"><paramref name="delimiter"/> is a quote, CR, or LF.</exception>
|
||||||
|
public static void Write(
|
||||||
|
TextWriter writer,
|
||||||
|
IEnumerable<IEnumerable<string?>> rows,
|
||||||
|
char delimiter = ',',
|
||||||
|
string newline = Crlf,
|
||||||
|
bool quoteAllFields = false)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(writer);
|
||||||
|
ArgumentNullException.ThrowIfNull(rows);
|
||||||
|
if (delimiter is Quote or Cr or Lf)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Delimiter must not be a double-quote, CR, or LF.", nameof(delimiter));
|
||||||
|
}
|
||||||
|
|
||||||
|
var firstRow = true;
|
||||||
|
foreach (var row in rows)
|
||||||
|
{
|
||||||
|
if (!firstRow)
|
||||||
|
{
|
||||||
|
writer.Write(newline);
|
||||||
|
}
|
||||||
|
|
||||||
|
firstRow = false;
|
||||||
|
|
||||||
|
WriteRow(writer, row, delimiter, quoteAllFields);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteRow(TextWriter writer, IEnumerable<string?> row, char delimiter, bool quoteAllFields)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(row);
|
||||||
|
|
||||||
|
var firstField = true;
|
||||||
|
foreach (var field in row)
|
||||||
|
{
|
||||||
|
if (!firstField)
|
||||||
|
{
|
||||||
|
writer.Write(delimiter);
|
||||||
|
}
|
||||||
|
|
||||||
|
firstField = false;
|
||||||
|
|
||||||
|
WriteField(writer, field ?? string.Empty, delimiter, quoteAllFields);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteField(TextWriter writer, string field, char delimiter, bool quoteAllFields)
|
||||||
|
{
|
||||||
|
if (!quoteAllFields && !NeedsQuoting(field, delimiter))
|
||||||
|
{
|
||||||
|
writer.Write(field);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.Write(Quote);
|
||||||
|
foreach (var ch in field)
|
||||||
|
{
|
||||||
|
if (ch == Quote)
|
||||||
|
{
|
||||||
|
writer.Write(Quote); // double an internal quote
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.Write(ch);
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.Write(Quote);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool NeedsQuoting(string field, char delimiter)
|
||||||
|
{
|
||||||
|
foreach (var ch in field)
|
||||||
|
{
|
||||||
|
if (ch == delimiter || ch == Quote || ch == Cr || ch == Lf)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts;
|
|||||||
/// <param name="AlarmTypeName">OPC UA Part 9 condition subtype name — one of <c>LimitAlarm</c> / <c>DiscreteAlarm</c> / <c>OffNormalAlarm</c> / <c>AlarmCondition</c> (the base type, used as the default). The historian feed maps this onto the durable alarm-type column.</param>
|
/// <param name="AlarmTypeName">OPC UA Part 9 condition subtype name — one of <c>LimitAlarm</c> / <c>DiscreteAlarm</c> / <c>OffNormalAlarm</c> / <c>AlarmCondition</c> (the base type, used as the default). The historian feed maps this onto the durable alarm-type column.</param>
|
||||||
/// <param name="Comment">Operator-supplied comment on ack / confirm / comment transitions; <c>null</c> for engine-driven transitions (Activated / Cleared / Shelved / …) that carry no comment.</param>
|
/// <param name="Comment">Operator-supplied comment on ack / confirm / comment transitions; <c>null</c> for engine-driven transitions (Activated / Cleared / Shelved / …) that carry no comment.</param>
|
||||||
/// <param name="HistorizeToAveva">When <c>false</c>, the durable historian sink suppresses this transition (the live <c>alerts</c> fan-out is unaffected); <c>null</c> or <c>true</c> historize. <c>null</c> is the cross-version/rolling-restart case: an old-format message missing the field deserializes to <c>null</c> (CLR default for <c>bool?</c>) and is historized (safe default-on), matching the <c>AlarmTypeName</c> null-coalesce in <c>HistorianAdapterActor.Translate</c>. The producer (<c>ScriptedAlarmHostActor</c>) always sets a concrete <c>true</c>/<c>false</c>.</param>
|
/// <param name="HistorizeToAveva">When <c>false</c>, the durable historian sink suppresses this transition (the live <c>alerts</c> fan-out is unaffected); <c>null</c> or <c>true</c> historize. <c>null</c> is the cross-version/rolling-restart case: an old-format message missing the field deserializes to <c>null</c> (CLR default for <c>bool?</c>) and is historized (safe default-on), matching the <c>AlarmTypeName</c> null-coalesce in <c>HistorianAdapterActor.Translate</c>. The producer (<c>ScriptedAlarmHostActor</c>) always sets a concrete <c>true</c>/<c>false</c>.</param>
|
||||||
|
/// <param name="ReferencingEquipmentPaths">v3 Batch 4 (multi-notifier native alarms) — the (possibly empty) list of UNS equipment-folder paths (<c>Area/Line/Equipment</c>) that reference the alarm's backing raw tag. A native alarm is a SINGLE Part 9 condition materialised once at the raw tag (its <see cref="AlarmId"/> is the RawPath); the same condition fans events to every referencing equipment's UNS folder via SDK notifiers, and this list is carried so <c>/alerts</c> shows the one condition row with all its referencing equipment as display metadata. Empty for scripted alarms (they are per-equipment) and for a native alarm whose raw tag no equipment references. Defaults empty so every existing producer + rolling-restart deserialization keeps compiling / working.</param>
|
||||||
public sealed record AlarmTransitionEvent(
|
public sealed record AlarmTransitionEvent(
|
||||||
string AlarmId,
|
string AlarmId,
|
||||||
string EquipmentPath,
|
string EquipmentPath,
|
||||||
@@ -28,4 +29,5 @@ public sealed record AlarmTransitionEvent(
|
|||||||
DateTime TimestampUtc,
|
DateTime TimestampUtc,
|
||||||
string AlarmTypeName = "AlarmCondition",
|
string AlarmTypeName = "AlarmCondition",
|
||||||
string? Comment = null,
|
string? Comment = null,
|
||||||
bool? HistorizeToAveva = null);
|
bool? HistorizeToAveva = null,
|
||||||
|
IReadOnlyList<string>? ReferencingEquipmentPaths = null);
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Which of the two v3 OPC UA namespaces a node lives in. Carried explicitly alongside a
|
||||||
|
/// node's <c>s=</c> identifier at every address-space sink seam so the namespace is chosen at
|
||||||
|
/// the call site, never parsed back out of the id string (explicit beats inferred).
|
||||||
|
/// <see cref="V3NodeIds.NamespaceUri"/> maps each realm to its namespace URI.
|
||||||
|
/// </summary>
|
||||||
|
public enum AddressSpaceRealm
|
||||||
|
{
|
||||||
|
/// <summary>The device-oriented Raw tree (Folder→Driver→Device→TagGroup→Tag); a node's
|
||||||
|
/// <c>s=</c> id is its <see cref="ZB.MOM.WW.OtOpcUa.Commons.Types.RawPaths">RawPath</see>.</summary>
|
||||||
|
Raw,
|
||||||
|
|
||||||
|
/// <summary>The equipment-oriented UNS tree (Area→Line→Equipment→signal); a node's <c>s=</c>
|
||||||
|
/// id is the slash-joined <c>Area/Line/Equipment[/EffectiveName]</c>.</summary>
|
||||||
|
Uns,
|
||||||
|
}
|
||||||
@@ -23,30 +23,41 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical
|
|||||||
_inner = sink ?? NullOpcUaAddressSpaceSink.Instance;
|
_inner = sink ?? NullOpcUaAddressSpaceSink.Instance;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc)
|
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
|
||||||
=> _inner.WriteValue(nodeId, value, quality, sourceTimestampUtc);
|
=> _inner.WriteValue(nodeId, value, quality, sourceTimestampUtc, realm);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc)
|
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
|
||||||
=> _inner.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc);
|
=> _inner.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc, realm);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false)
|
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false)
|
||||||
=> _inner.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, isNative);
|
=> _inner.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, realm, isNative);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
|
// Forward the WP4 multi-notifier wiring to the inner sink. Without this the native-alarm fan-out to the
|
||||||
=> _inner.EnsureFolder(folderNodeId, parentNodeId, displayName);
|
// referencing equipment folders ships INERT on every driver-role host (actors inject THIS wrapper, not the
|
||||||
|
// inner SdkAddressSpaceSink) — the F10b / PR#423 forwarding trap the reflection guard exists to catch.
|
||||||
|
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm)
|
||||||
|
=> _inner.WireAlarmNotifiers(alarmNodeId, alarmRealm, notifierFolderNodeIds, notifierFolderRealm);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
|
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm)
|
||||||
=> _inner.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, historianTagname, isArray, arrayLength);
|
=> _inner.EnsureFolder(folderNodeId, parentNodeId, displayName, realm);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
|
||||||
|
=> _inner.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, realm, historianTagname, isArray, arrayLength);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void RebuildAddressSpace() => _inner.RebuildAddressSpace();
|
public void RebuildAddressSpace() => _inner.RebuildAddressSpace();
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void RaiseNodesAddedModelChange(string affectedNodeId) => _inner.RaiseNodesAddedModelChange(affectedNodeId);
|
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) => _inner.RaiseNodesAddedModelChange(affectedNodeId, realm);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes")
|
||||||
|
=> _inner.AddReference(sourceNodeId, sourceRealm, targetNodeId, targetRealm, referenceType);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
// Forwards to the inner sink when it supports the surgical capability; returns false otherwise —
|
// Forwards to the inner sink when it supports the surgical capability; returns false otherwise —
|
||||||
@@ -55,9 +66,9 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical
|
|||||||
// Without this forward the surgical optimization is inert on every driver-role host, because
|
// Without this forward the surgical optimization is inert on every driver-role host, because
|
||||||
// actors inject THIS wrapper, not the inner sink. ALL six args (including the DataType/array-shape
|
// actors inject THIS wrapper, not the inner sink. ALL six args (including the DataType/array-shape
|
||||||
// ones) MUST be forwarded — a partial forward silently drops the shape update on every driver-role host.
|
// ones) MUST be forwarded — a partial forward silently drops the shape update on every driver-role host.
|
||||||
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength)
|
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm)
|
||||||
=> _inner is ISurgicalAddressSpaceSink surgical
|
=> _inner is ISurgicalAddressSpaceSink surgical
|
||||||
&& surgical.UpdateTagAttributes(variableNodeId, writable, historianTagname, dataType, isArray, arrayLength);
|
&& surgical.UpdateTagAttributes(variableNodeId, writable, historianTagname, dataType, isArray, arrayLength, realm);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
// Forwards to the inner sink when it supports the surgical capability; returns false otherwise —
|
// Forwards to the inner sink when it supports the surgical capability; returns false otherwise —
|
||||||
@@ -65,9 +76,9 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical
|
|||||||
// sink that isn't surgical — so the caller (AddressSpaceApplier) falls back to a full rebuild.
|
// sink that isn't surgical — so the caller (AddressSpaceApplier) falls back to a full rebuild.
|
||||||
// Without this forward the rename-refresh optimization is inert on every driver-role host, because
|
// Without this forward the rename-refresh optimization is inert on every driver-role host, because
|
||||||
// actors inject THIS wrapper, not the inner sink.
|
// actors inject THIS wrapper, not the inner sink.
|
||||||
public bool UpdateFolderDisplayName(string folderNodeId, string displayName)
|
public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm)
|
||||||
=> _inner is ISurgicalAddressSpaceSink surgical
|
=> _inner is ISurgicalAddressSpaceSink surgical
|
||||||
&& surgical.UpdateFolderDisplayName(folderNodeId, displayName);
|
&& surgical.UpdateFolderDisplayName(folderNodeId, displayName, realm);
|
||||||
|
|
||||||
// R2-07 T7 — surgical remove forwards. Same capability-sniffing pattern as UpdateTagAttributes /
|
// R2-07 T7 — surgical remove forwards. Same capability-sniffing pattern as UpdateTagAttributes /
|
||||||
// UpdateFolderDisplayName: forward when the inner sink supports the surgical capability; return false
|
// UpdateFolderDisplayName: forward when the inner sink supports the surgical capability; return false
|
||||||
@@ -75,14 +86,14 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical
|
|||||||
// (AddressSpaceApplier) falls back to a full rebuild. Without these forwards the surgical remove path is
|
// (AddressSpaceApplier) falls back to a full rebuild. Without these forwards the surgical remove path is
|
||||||
// inert on every driver-role host — the F10b / PR#423 trap the reflection guard exists to catch.
|
// inert on every driver-role host — the F10b / PR#423 trap the reflection guard exists to catch.
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public bool RemoveVariableNode(string variableNodeId)
|
public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm)
|
||||||
=> _inner is ISurgicalAddressSpaceSink surgical && surgical.RemoveVariableNode(variableNodeId);
|
=> _inner is ISurgicalAddressSpaceSink surgical && surgical.RemoveVariableNode(variableNodeId, realm);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public bool RemoveAlarmConditionNode(string alarmNodeId)
|
public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm)
|
||||||
=> _inner is ISurgicalAddressSpaceSink surgical && surgical.RemoveAlarmConditionNode(alarmNodeId);
|
=> _inner is ISurgicalAddressSpaceSink surgical && surgical.RemoveAlarmConditionNode(alarmNodeId, realm);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public bool RemoveEquipmentSubtree(string equipmentNodeId)
|
public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm)
|
||||||
=> _inner is ISurgicalAddressSpaceSink surgical && surgical.RemoveEquipmentSubtree(equipmentNodeId);
|
=> _inner is ISurgicalAddressSpaceSink surgical && surgical.RemoveEquipmentSubtree(equipmentNodeId, realm);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Single source of truth for equipment-namespace OPC UA NodeId strings. The variable NodeId is
|
|
||||||
/// FOLDER-SCOPED (<c>{parent}/{Name}</c>), NOT the driver-side FullName — a driver wire ref is not
|
|
||||||
/// unique across identical machines, so FullName-as-NodeId would collide in the sink. Used by the
|
|
||||||
/// materialiser (AddressSpaceApplier), the VirtualTag publish map, and the driver live-value router so all
|
|
||||||
/// three agree on the exact NodeId a variable was placed at.
|
|
||||||
/// </summary>
|
|
||||||
public static class EquipmentNodeIds
|
|
||||||
{
|
|
||||||
/// <summary>The sub-folder NodeId under an equipment for a non-empty FolderPath: <c>{equipmentId}/{folderPath}</c>.</summary>
|
|
||||||
/// <param name="equipmentId">The owning equipment's NodeId.</param>
|
|
||||||
/// <param name="folderPath">The tag/vtag FolderPath (must be non-empty for this to be meaningful).</param>
|
|
||||||
/// <returns>The sub-folder NodeId string.</returns>
|
|
||||||
public static string SubFolder(string equipmentId, string folderPath) => $"{equipmentId}/{folderPath}";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The folder-scoped variable NodeId: <c>{parent}/{name}</c> where <c>parent = equipmentId</c> when
|
|
||||||
/// <paramref name="folderPath"/> is null/empty, else <see cref="SubFolder"/>.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="equipmentId">The owning equipment's NodeId.</param>
|
|
||||||
/// <param name="folderPath">The tag/vtag FolderPath, or null/empty for "directly under the equipment".</param>
|
|
||||||
/// <param name="name">The tag/vtag Name (the leaf browse segment).</param>
|
|
||||||
/// <returns>The folder-scoped variable NodeId string.</returns>
|
|
||||||
public static string Variable(string equipmentId, string? folderPath, string name)
|
|
||||||
{
|
|
||||||
var parent = string.IsNullOrWhiteSpace(folderPath) ? equipmentId : SubFolder(equipmentId, folderPath);
|
|
||||||
return $"{parent}/{name}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -13,7 +13,11 @@ public interface IOpcUaAddressSpaceSink
|
|||||||
/// <param name="value">The value to write.</param>
|
/// <param name="value">The value to write.</param>
|
||||||
/// <param name="quality">The quality status of the value.</param>
|
/// <param name="quality">The quality status of the value.</param>
|
||||||
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
||||||
void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc);
|
/// <param name="realm">Which of the two v3 namespaces (<see cref="AddressSpaceRealm.Raw"/> device tree or
|
||||||
|
/// <see cref="AddressSpaceRealm.Uns"/> equipment tree) the <paramref name="nodeId"/> lives in — the sink
|
||||||
|
/// resolves the namespace index from the realm rather than parsing it out of the id string. WP3's fan-out
|
||||||
|
/// calls this once per NodeId (raw + every referencing UNS node) with the matching realm.</param>
|
||||||
|
void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm);
|
||||||
|
|
||||||
/// <summary>Write an alarm-condition's full Part 9 state. When a real condition node has been
|
/// <summary>Write an alarm-condition's full Part 9 state. When a real condition node has been
|
||||||
/// materialised for <paramref name="alarmNodeId"/> via <see cref="MaterialiseAlarmCondition"/>,
|
/// materialised for <paramref name="alarmNodeId"/> via <see cref="MaterialiseAlarmCondition"/>,
|
||||||
@@ -25,7 +29,9 @@ public interface IOpcUaAddressSpaceSink
|
|||||||
/// <param name="alarmNodeId">The OPC UA node ID of the alarm (== ScriptedAlarmId for materialised conditions).</param>
|
/// <param name="alarmNodeId">The OPC UA node ID of the alarm (== ScriptedAlarmId for materialised conditions).</param>
|
||||||
/// <param name="state">The full condition state to project onto the node.</param>
|
/// <param name="state">The full condition state to project onto the node.</param>
|
||||||
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
||||||
void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc);
|
/// <param name="realm">The namespace realm <paramref name="alarmNodeId"/> lives in (must match the realm
|
||||||
|
/// the condition was materialised under).</param>
|
||||||
|
void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Materialise a real OPC UA Part 9 alarm-condition node under its equipment folder so clients
|
/// Materialise a real OPC UA Part 9 alarm-condition node under its equipment folder so clients
|
||||||
@@ -41,7 +47,31 @@ public interface IOpcUaAddressSpaceSink
|
|||||||
/// <param name="isNative">When true this is a driver-fed (native, e.g. Galaxy) equipment-tag alarm; when
|
/// <param name="isNative">When true this is a driver-fed (native, e.g. Galaxy) equipment-tag alarm; when
|
||||||
/// false (default) it is a scripted alarm. The node manager tracks native condition node ids so a later
|
/// false (default) it is a scripted alarm. The node manager tracks native condition node ids so a later
|
||||||
/// task can route a native condition's inbound Acknowledge to the driver instead of the scripted engine.</param>
|
/// task can route a native condition's inbound Acknowledge to the driver instead of the scripted engine.</param>
|
||||||
void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false);
|
/// <param name="realm">The namespace realm the condition (and its parent folder <paramref name="equipmentNodeId"/>)
|
||||||
|
/// live in.</param>
|
||||||
|
void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// v3 Batch 4 (multi-notifier native alarms) — wire an already-materialised native alarm condition
|
||||||
|
/// (<paramref name="alarmNodeId"/>, materialised at the raw tag via <see cref="MaterialiseAlarmCondition"/>
|
||||||
|
/// with ConditionId = the RawPath) as an event notifier of EACH referencing equipment's UNS folder, so
|
||||||
|
/// the condition's <b>single</b> <c>ReportEvent</c> fans one event to every referencing equipment root
|
||||||
|
/// WITHOUT re-reporting per root (distinct EventIds would break Server-object dedup + Part 9 ack
|
||||||
|
/// correlation). Per folder the sink wires the SDK's bidirectional notifier pattern
|
||||||
|
/// (<c>alarm.AddNotifier(isInverse:true, folder)</c> + <c>folder.AddNotifier(isInverse:false, alarm)</c>)
|
||||||
|
/// and promotes the folder to an event notifier. <b>Idempotent</b> (a re-wire of the same pair updates,
|
||||||
|
/// never duplicates); a <b>missing endpoint is a no-op</b> (logged, never thrown) so a mid-rebuild race
|
||||||
|
/// can't fault a deploy. The sink tracks each wired pair so a later rebuild / condition-removal /
|
||||||
|
/// equipment-subtree-removal tears the notifier down bidirectionally (no inverse-notifier entry leaks
|
||||||
|
/// across redeploys).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="alarmNodeId">The native alarm condition's node id (== the backing tag's RawPath).</param>
|
||||||
|
/// <param name="alarmRealm">The namespace realm the condition lives in (Raw for native alarms).</param>
|
||||||
|
/// <param name="notifierFolderNodeIds">The equipment folder node ids (their <c>s=</c> ids) to wire as
|
||||||
|
/// extra event-notifier roots for this condition. An unknown / not-yet-materialised folder id is skipped.</param>
|
||||||
|
/// <param name="notifierFolderRealm">The namespace realm the notifier folders live in (Uns for equipment folders).</param>
|
||||||
|
void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm,
|
||||||
|
IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Ensure a folder node exists under the given parent. Used by <c>AddressSpaceApplier</c> to
|
/// Ensure a folder node exists under the given parent. Used by <c>AddressSpaceApplier</c> to
|
||||||
@@ -52,7 +82,8 @@ public interface IOpcUaAddressSpaceSink
|
|||||||
/// <param name="folderNodeId">The OPC UA node ID for the folder.</param>
|
/// <param name="folderNodeId">The OPC UA node ID for the folder.</param>
|
||||||
/// <param name="parentNodeId">The parent folder node ID, or null for namespace root.</param>
|
/// <param name="parentNodeId">The parent folder node ID, or null for namespace root.</param>
|
||||||
/// <param name="displayName">The display name for the folder.</param>
|
/// <param name="displayName">The display name for the folder.</param>
|
||||||
void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName);
|
/// <param name="realm">The namespace realm the folder (and its <paramref name="parentNodeId"/>) live in.</param>
|
||||||
|
void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Ensure a Variable node exists at <paramref name="variableNodeId"/>, parented under
|
/// Ensure a Variable node exists at <paramref name="variableNodeId"/>, parented under
|
||||||
@@ -77,7 +108,10 @@ public interface IOpcUaAddressSpaceSink
|
|||||||
/// rank + dimensions carry the array-ness.</param>
|
/// rank + dimensions carry the array-ness.</param>
|
||||||
/// <param name="arrayLength">The declared length of the 1-D array when <paramref name="isArray"/> is
|
/// <param name="arrayLength">The declared length of the 1-D array when <paramref name="isArray"/> is
|
||||||
/// true; ignored for scalars. Null ⇒ length 0 (unbounded).</param>
|
/// true; ignored for scalars. Null ⇒ length 0 (unbounded).</param>
|
||||||
void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null);
|
/// <param name="realm">The namespace realm the variable (and its <paramref name="parentFolderNodeId"/>) live
|
||||||
|
/// in. A historized UNS reference node registers the SAME <paramref name="historianTagname"/> as its backing
|
||||||
|
/// raw node — both NodeIds map to one tagname — so this call carries the shared tagname per realm.</param>
|
||||||
|
void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tear down + repopulate the address space. Called by <c>OpcUaPublishActor</c> after a
|
/// Tear down + repopulate the address space. Called by <c>OpcUaPublishActor</c> after a
|
||||||
@@ -91,7 +125,28 @@ public interface IOpcUaAddressSpaceSink
|
|||||||
/// (Part 3 GeneralModelChangeEvent, verb NodeAdded).
|
/// (Part 3 GeneralModelChangeEvent, verb NodeAdded).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="affectedNodeId">The node under which discovered nodes were added.</param>
|
/// <param name="affectedNodeId">The node under which discovered nodes were added.</param>
|
||||||
void RaiseNodesAddedModelChange(string affectedNodeId);
|
/// <param name="realm">The namespace realm <paramref name="affectedNodeId"/> lives in.</param>
|
||||||
|
void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Add an OPC UA reference from an already-materialised source node to an already-materialised
|
||||||
|
/// target node, both realm-qualified. Used by <c>AddressSpaceApplier</c> to link each UNS reference
|
||||||
|
/// Variable (source, <see cref="AddressSpaceRealm.Uns"/>) to its backing Raw node (target,
|
||||||
|
/// <see cref="AddressSpaceRealm.Raw"/>) with an <c>Organizes</c> edge, so the UNS variable
|
||||||
|
/// browse-resolves to its raw source (and the raw node back via the inverse). The edge is wired
|
||||||
|
/// bidirectionally (forward on the source, inverse on the target) so browse resolves both ways.
|
||||||
|
/// <b>Idempotent</b> (an existing edge is not duplicated); a <b>missing endpoint is a no-op</b>
|
||||||
|
/// (logged, never thrown) so a mid-rebuild race can't fault a deploy.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="sourceNodeId">The source node's <c>s=</c> id (the referencing node — e.g. the UNS variable).</param>
|
||||||
|
/// <param name="sourceRealm">The namespace realm <paramref name="sourceNodeId"/> lives in.</param>
|
||||||
|
/// <param name="targetNodeId">The target node's <c>s=</c> id (the referenced node — e.g. the backing Raw node).</param>
|
||||||
|
/// <param name="targetRealm">The namespace realm <paramref name="targetNodeId"/> lives in.</param>
|
||||||
|
/// <param name="referenceType">The hierarchical reference type name — <c>Organizes</c> (default),
|
||||||
|
/// <c>HasComponent</c>, or <c>HasProperty</c>; unknown names fall back to <c>Organizes</c>.</param>
|
||||||
|
void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm,
|
||||||
|
string targetNodeId, AddressSpaceRealm targetRealm,
|
||||||
|
string referenceType = "Organizes");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>OPC UA status code projection — Good / Uncertain / Bad. Real SDK has finer-grained
|
/// <summary>OPC UA status code projection — Good / Uncertain / Bad. Real SDK has finer-grained
|
||||||
@@ -106,23 +161,29 @@ public sealed class NullOpcUaAddressSpaceSink : IOpcUaAddressSpaceSink
|
|||||||
private NullOpcUaAddressSpaceSink() { }
|
private NullOpcUaAddressSpaceSink() { }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
|
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) { }
|
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) { }
|
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
|
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
|
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm) { }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void RebuildAddressSpace() { }
|
public void RebuildAddressSpace() { }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void RaiseNodesAddedModelChange(string affectedNodeId) { }
|
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) { }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,11 +11,17 @@ public interface IOpcUaNodeWriteGateway
|
|||||||
{
|
{
|
||||||
/// <summary>Route a write of <paramref name="value"/> to the driver backing node
|
/// <summary>Route a write of <paramref name="value"/> to the driver backing node
|
||||||
/// <paramref name="nodeId"/>; the returned task resolves with the device-write outcome.</summary>
|
/// <paramref name="nodeId"/>; the returned task resolves with the device-write outcome.</summary>
|
||||||
/// <param name="nodeId">The folder-scoped equipment-variable node id being written.</param>
|
/// <param name="nodeId">The full ns-qualified node id being written (<c>node.NodeId.ToString()</c>).</param>
|
||||||
/// <param name="value">The value the client wrote.</param>
|
/// <param name="value">The value the client wrote.</param>
|
||||||
|
/// <param name="realm">Which of the two v3 namespaces (<see cref="AddressSpaceRealm.Raw"/> device tree or
|
||||||
|
/// <see cref="AddressSpaceRealm.Uns"/> equipment tree) <paramref name="nodeId"/> lives in — the node
|
||||||
|
/// manager resolves it from the node's namespace index (<c>RealmOf</c>). It is REQUIRED for correct
|
||||||
|
/// routing: a raw <c>s=<RawPath></c> and a UNS <c>s=<Area/Line/Equip/Eff></c> can collide as bare
|
||||||
|
/// strings, so the routing map is keyed by <c>(realm, bareId)</c> — dropping the realm would let an
|
||||||
|
/// operator write route to the WRONG driver ref.</param>
|
||||||
/// <param name="ct">Cancellation token.</param>
|
/// <param name="ct">Cancellation token.</param>
|
||||||
/// <returns>A task resolving to the device-write outcome.</returns>
|
/// <returns>A task resolving to the device-write outcome.</returns>
|
||||||
Task<NodeWriteOutcome> WriteAsync(string nodeId, object? value, CancellationToken ct);
|
Task<NodeWriteOutcome> WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Outcome of routing an inbound node write to the backing driver.</summary>
|
/// <summary>Outcome of routing an inbound node write to the backing driver.</summary>
|
||||||
@@ -31,6 +37,6 @@ public sealed class NullOpcUaNodeWriteGateway : IOpcUaNodeWriteGateway
|
|||||||
public static readonly NullOpcUaNodeWriteGateway Instance = new();
|
public static readonly NullOpcUaNodeWriteGateway Instance = new();
|
||||||
private NullOpcUaNodeWriteGateway() { }
|
private NullOpcUaNodeWriteGateway() { }
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<NodeWriteOutcome> WriteAsync(string nodeId, object? value, CancellationToken ct) =>
|
public Task<NodeWriteOutcome> WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct) =>
|
||||||
Task.FromResult(new NodeWriteOutcome(false, "writes unavailable"));
|
Task.FromResult(new NodeWriteOutcome(false, "writes unavailable"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,8 +21,10 @@ public interface ISurgicalAddressSpaceSink
|
|||||||
/// <param name="dataType">The OPC UA built-in data type name (e.g. "Boolean", "Int32", "Float").</param>
|
/// <param name="dataType">The OPC UA built-in data type name (e.g. "Boolean", "Int32", "Float").</param>
|
||||||
/// <param name="isArray">When true the node is a 1-D array (ValueRank=OneDimension); when false scalar.</param>
|
/// <param name="isArray">When true the node is a 1-D array (ValueRank=OneDimension); when false scalar.</param>
|
||||||
/// <param name="arrayLength">The declared length of the 1-D array when <paramref name="isArray"/> is true; ignored for scalars.</param>
|
/// <param name="arrayLength">The declared length of the 1-D array when <paramref name="isArray"/> is true; ignored for scalars.</param>
|
||||||
|
/// <param name="realm">The namespace realm <paramref name="variableNodeId"/> lives in — the sink resolves the
|
||||||
|
/// namespace index from the realm rather than parsing it out of the id string.</param>
|
||||||
/// <returns>True when the in-place update was applied; false when the node is missing (caller rebuilds).</returns>
|
/// <returns>True when the in-place update was applied; false when the node is missing (caller rebuilds).</returns>
|
||||||
bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength);
|
bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm);
|
||||||
|
|
||||||
/// <summary>Update an existing folder node's <c>DisplayName</c> IN PLACE, notifying subscribers
|
/// <summary>Update an existing folder node's <c>DisplayName</c> IN PLACE, notifying subscribers
|
||||||
/// (ClearChangeMasks) without a rebuild — used by AddressSpaceApplier to apply a UNS Area / Line
|
/// (ClearChangeMasks) without a rebuild — used by AddressSpaceApplier to apply a UNS Area / Line
|
||||||
@@ -32,8 +34,9 @@ public interface ISurgicalAddressSpaceSink
|
|||||||
/// should rebuild instead).</summary>
|
/// should rebuild instead).</summary>
|
||||||
/// <param name="folderNodeId">The node id of the folder whose display name to update in place.</param>
|
/// <param name="folderNodeId">The node id of the folder whose display name to update in place.</param>
|
||||||
/// <param name="displayName">The new display name to apply.</param>
|
/// <param name="displayName">The new display name to apply.</param>
|
||||||
|
/// <param name="realm">The namespace realm <paramref name="folderNodeId"/> lives in.</param>
|
||||||
/// <returns>True when the in-place update was applied; false when the folder is missing (caller rebuilds).</returns>
|
/// <returns>True when the in-place update was applied; false when the folder is missing (caller rebuilds).</returns>
|
||||||
bool UpdateFolderDisplayName(string folderNodeId, string displayName);
|
bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm);
|
||||||
|
|
||||||
/// <summary>Remove ONE existing value-variable node IN PLACE (detach from its parent, drop it from the
|
/// <summary>Remove ONE existing value-variable node IN PLACE (detach from its parent, drop it from the
|
||||||
/// predefined-node set, drop any historized-tagname registration), then raise a Part 3
|
/// predefined-node set, drop any historized-tagname registration), then raise a Part 3
|
||||||
@@ -43,8 +46,9 @@ public interface ISurgicalAddressSpaceSink
|
|||||||
/// re-subscription attempts get BadNodeIdUnknown from the SDK. Returns false when the node id is unknown
|
/// re-subscription attempts get BadNodeIdUnknown from the SDK. Returns false when the node id is unknown
|
||||||
/// (the node-manager maps drifted from the plan — caller falls back to a full rebuild).</summary>
|
/// (the node-manager maps drifted from the plan — caller falls back to a full rebuild).</summary>
|
||||||
/// <param name="variableNodeId">The folder-scoped node id of the variable to remove in place.</param>
|
/// <param name="variableNodeId">The folder-scoped node id of the variable to remove in place.</param>
|
||||||
|
/// <param name="realm">The namespace realm <paramref name="variableNodeId"/> lives in.</param>
|
||||||
/// <returns>True when the node was removed; false when the id is unknown (caller rebuilds).</returns>
|
/// <returns>True when the node was removed; false when the id is unknown (caller rebuilds).</returns>
|
||||||
bool RemoveVariableNode(string variableNodeId);
|
bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm);
|
||||||
|
|
||||||
/// <summary>Remove ONE existing Part 9 alarm-condition node IN PLACE (detach, drop from the
|
/// <summary>Remove ONE existing Part 9 alarm-condition node IN PLACE (detach, drop from the
|
||||||
/// predefined-node set, clear the native-alarm flag), then raise NodeDeleted outside the lock. The
|
/// predefined-node set, clear the native-alarm flag), then raise NodeDeleted outside the lock. The
|
||||||
@@ -52,8 +56,9 @@ public interface ISurgicalAddressSpaceSink
|
|||||||
/// stops replaying on ConditionRefresh. Returns false when the condition id is unknown (caller
|
/// stops replaying on ConditionRefresh. Returns false when the condition id is unknown (caller
|
||||||
/// rebuilds).</summary>
|
/// rebuilds).</summary>
|
||||||
/// <param name="alarmNodeId">The alarm condition node id (== ScriptedAlarmId, or a native alarm tag's folder-scoped id).</param>
|
/// <param name="alarmNodeId">The alarm condition node id (== ScriptedAlarmId, or a native alarm tag's folder-scoped id).</param>
|
||||||
|
/// <param name="realm">The namespace realm <paramref name="alarmNodeId"/> lives in.</param>
|
||||||
/// <returns>True when the condition was removed; false when the id is unknown (caller rebuilds).</returns>
|
/// <returns>True when the condition was removed; false when the id is unknown (caller rebuilds).</returns>
|
||||||
bool RemoveAlarmConditionNode(string alarmNodeId);
|
bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm);
|
||||||
|
|
||||||
/// <summary>Remove an equipment folder AND its entire descendant subtree (sub-folders, value variables,
|
/// <summary>Remove an equipment folder AND its entire descendant subtree (sub-folders, value variables,
|
||||||
/// condition nodes) IN PLACE: per-descendant map/registration cleanup (variables, historized tagnames,
|
/// condition nodes) IN PLACE: per-descendant map/registration cleanup (variables, historized tagnames,
|
||||||
@@ -62,6 +67,7 @@ public interface ISurgicalAddressSpaceSink
|
|||||||
/// the equipment folder outside the lock. Returns false when the folder id is unknown (caller
|
/// the equipment folder outside the lock. Returns false when the folder id is unknown (caller
|
||||||
/// rebuilds).</summary>
|
/// rebuilds).</summary>
|
||||||
/// <param name="equipmentNodeId">The equipment folder node id whose entire subtree to remove.</param>
|
/// <param name="equipmentNodeId">The equipment folder node id whose entire subtree to remove.</param>
|
||||||
|
/// <param name="realm">The namespace realm <paramref name="equipmentNodeId"/> lives in.</param>
|
||||||
/// <returns>True when the subtree was removed; false when the id is unknown (caller rebuilds).</returns>
|
/// <returns>True when the subtree was removed; false when the id is unknown (caller rebuilds).</returns>
|
||||||
bool RemoveEquipmentSubtree(string equipmentNodeId);
|
bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// v3 dual-namespace NodeId + namespace-URI authority. Two OPC UA namespaces expose the same
|
||||||
|
/// underlying values under two identity schemes:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><b>Raw</b> (<see cref="RawNamespaceUri"/>): the device-oriented tree
|
||||||
|
/// Folder→Driver→Device→TagGroup→Tag. A node's <c>s=</c> identifier IS its
|
||||||
|
/// <see cref="RawPaths">RawPath</see> — folders/drivers/devices/groups included (each keys
|
||||||
|
/// on its own RawPath prefix).</item>
|
||||||
|
/// <item><b>Uns</b> (<see cref="UnsNamespaceUri"/>): the equipment-oriented tree
|
||||||
|
/// Area→Line→Equipment→signal. A node's <c>s=</c> identifier is the slash-joined
|
||||||
|
/// <c>Area/Line/Equipment[/EffectiveName]</c>.</item>
|
||||||
|
/// </list>
|
||||||
|
/// These replace the single <c>https://zb.com/otopcua/ns</c> namespace and the retired
|
||||||
|
/// <c>EquipmentNodeIds</c> ({equipmentId}/{folderPath}/{name}) scheme. Realm travels alongside
|
||||||
|
/// the identifier as an <see cref="AddressSpaceRealm"/> at every sink seam — the namespace is
|
||||||
|
/// never parsed back out of the id string. Both schemes share <see cref="RawPaths.Separator"/>
|
||||||
|
/// and its ordinal, case-sensitive segment charset so identity is enforced in one place.
|
||||||
|
/// </summary>
|
||||||
|
public static class V3NodeIds
|
||||||
|
{
|
||||||
|
/// <summary>The Raw namespace URI (device-oriented subtree).</summary>
|
||||||
|
public const string RawNamespaceUri = "https://zb.com/otopcua/raw";
|
||||||
|
|
||||||
|
/// <summary>The UNS namespace URI (equipment-oriented subtree).</summary>
|
||||||
|
public const string UnsNamespaceUri = "https://zb.com/otopcua/uns";
|
||||||
|
|
||||||
|
/// <summary>The namespace URI for a realm.</summary>
|
||||||
|
/// <param name="realm">The address-space realm.</param>
|
||||||
|
/// <returns>The realm's namespace URI.</returns>
|
||||||
|
/// <exception cref="ArgumentOutOfRangeException">Unknown realm.</exception>
|
||||||
|
public static string NamespaceUri(AddressSpaceRealm realm) => realm switch
|
||||||
|
{
|
||||||
|
AddressSpaceRealm.Raw => RawNamespaceUri,
|
||||||
|
AddressSpaceRealm.Uns => UnsNamespaceUri,
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(realm), realm, "Unknown address-space realm."),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ----- Raw realm -----
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The Raw-namespace <c>s=</c> identifier for a raw node — identical to its
|
||||||
|
/// <see cref="RawPaths">RawPath</see> (folders, drivers, devices, groups, and tags all key
|
||||||
|
/// on their own RawPath). A pass-through seam so call sites read intent rather than a bare
|
||||||
|
/// string; the argument is expected to already be a RawPaths-built path.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="rawPath">The (already-built) RawPath.</param>
|
||||||
|
/// <returns>The Raw-namespace <c>s=</c> identifier (== <paramref name="rawPath"/>).</returns>
|
||||||
|
public static string Raw(string rawPath)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrEmpty(rawPath);
|
||||||
|
return rawPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Uns realm -----
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The UNS-namespace <c>s=</c> identifier for a folder or variable, slash-joined from
|
||||||
|
/// ordered segments (Area, then Line, then Equipment, then an optional EffectiveName).
|
||||||
|
/// Every segment is validated via <see cref="RawPaths.ValidateSegment"/> (no embedded
|
||||||
|
/// separator, no leading/trailing whitespace) so a UNS id round-trips like a RawPath.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="segments">The ordered, non-empty segments (Area → leaf).</param>
|
||||||
|
/// <returns>The slash-joined UNS <c>s=</c> identifier.</returns>
|
||||||
|
/// <exception cref="ArgumentException">A segment is invalid, or the sequence is empty.</exception>
|
||||||
|
public static string Uns(params string[] segments) => UnsFromSegments(segments);
|
||||||
|
|
||||||
|
/// <summary>Build a UNS <c>s=</c> identifier from ordered segments. See <see cref="Uns(string[])"/>.</summary>
|
||||||
|
/// <param name="segments">The ordered, non-empty segments (Area → leaf).</param>
|
||||||
|
/// <returns>The slash-joined UNS <c>s=</c> identifier.</returns>
|
||||||
|
public static string Uns(IEnumerable<string> segments) => UnsFromSegments(segments);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Append a child segment (a Line under an Area, an Equipment under a Line, an
|
||||||
|
/// EffectiveName under an Equipment) to an already-built UNS path.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="parentUnsPath">The parent UNS path (assumed already valid).</param>
|
||||||
|
/// <param name="childSegment">The child segment to append.</param>
|
||||||
|
/// <returns>The combined UNS <c>s=</c> identifier.</returns>
|
||||||
|
/// <exception cref="ArgumentException">The child segment is invalid, or the parent is blank.</exception>
|
||||||
|
public static string UnsChild(string parentUnsPath, string childSegment)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrEmpty(parentUnsPath);
|
||||||
|
var error = RawPaths.ValidateSegment(childSegment);
|
||||||
|
if (error is not null)
|
||||||
|
throw new ArgumentException($"Invalid UNS NodeId segment ('{childSegment}'): {error}", nameof(childSegment));
|
||||||
|
|
||||||
|
return parentUnsPath + RawPaths.Separator + childSegment;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string UnsFromSegments(IEnumerable<string> segments)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(segments);
|
||||||
|
var list = segments as IReadOnlyList<string> ?? segments.ToList();
|
||||||
|
if (list.Count == 0)
|
||||||
|
throw new ArgumentException("A UNS NodeId must have at least one segment.", nameof(segments));
|
||||||
|
|
||||||
|
for (var i = 0; i < list.Count; i++)
|
||||||
|
{
|
||||||
|
var error = RawPaths.ValidateSegment(list[i]);
|
||||||
|
if (error is not null)
|
||||||
|
throw new ArgumentException($"Invalid UNS NodeId segment at index {i} ('{list[i]}'): {error}", nameof(segments));
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Join(RawPaths.Separator, list);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
namespace ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Helpers for equipment-relative virtual-tag script paths. The reserved token
|
/// Helpers for equipment-relative virtual-tag / scripted-alarm script paths. The reserved token
|
||||||
/// <c>{{equip}}</c> inside a <c>ctx.GetTag</c>/<c>ctx.SetVirtualTag</c> path literal is
|
/// <c>{{equip}}/<RefName></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
|
/// replaced at the compose seams (<c>AddressSpaceComposer</c> + <c>DeploymentArtifact</c>) with the
|
||||||
/// from its child-tag <c>FullName</c>s). Pure + regex-based (no Roslyn) so the OpcUaServer
|
/// backing raw tag's <c>RawPath</c>, resolved through the owning equipment's
|
||||||
/// composer and the Runtime artifact-decode path can both share it. Also the single home
|
/// <c>UnsTagReference</c> rows by effective name (<c><RefName></c>).
|
||||||
/// for the <c>ctx.GetTag("…")</c> dependency-ref extraction those two seams used to
|
/// <para><b>v3 syntax:</b> the token joint is a <b>slash</b> — <c>{{equip}}/<RefName></c> — replacing
|
||||||
/// duplicate.
|
/// 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>
|
/// </summary>
|
||||||
public static class EquipmentScriptPaths
|
public static class EquipmentScriptPaths
|
||||||
{
|
{
|
||||||
/// <summary>The reserved equipment-base token.</summary>
|
/// <summary>The reserved equipment token stem.</summary>
|
||||||
public const string EquipToken = "{{equip}}";
|
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.
|
// ctx.GetTag("ref") — reads only; the dependency graph subscribes to exactly these.
|
||||||
private static readonly Regex GetTagRefRegex =
|
private static readonly Regex GetTagRefRegex =
|
||||||
new(@"ctx\s*\.\s*GetTag\s*\(\s*""([^""]+)""\s*\)", RegexOptions.Compiled);
|
new(@"ctx\s*\.\s*GetTag\s*\(\s*""([^""]+)""\s*\)", RegexOptions.Compiled);
|
||||||
@@ -32,6 +42,14 @@ public static class EquipmentScriptPaths
|
|||||||
private static readonly Regex PathLiteralRegex =
|
private static readonly Regex PathLiteralRegex =
|
||||||
new(@"(ctx\s*\.\s*(?:GetTag|SetVirtualTag)\s*\(\s*"")([^""]*)("")", RegexOptions.Compiled);
|
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;`
|
// A pure pass-through virtual-tag body: exactly `return ctx.GetTag("<ref>").Value;`
|
||||||
// (whitespace-insensitive). Captures <ref> for relay→alias conversion. Anything with extra
|
// (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.
|
// 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);
|
!string.IsNullOrEmpty(source) && source.Contains(EquipToken, StringComparison.Ordinal);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Equipment tag base = the single shared substring-before-first-dot across the
|
/// Replace each <c>{{equip}}/<RefName></c> reference-relative path with the backing raw tag's
|
||||||
/// equipment's child-tag <c>FullName</c>s. Returns <c>null</c> when there are no usable
|
/// <c>RawPath</c> from <paramref name="referenceMap"/> (effective name → RawPath), inside
|
||||||
/// FullNames or they don't agree on one prefix (equipment spanning multiple objects).
|
/// <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>
|
/// </summary>
|
||||||
/// <param name="childFullNames">The equipment's child-tag driver FullNames.</param>
|
/// <param name="source">The script source.</param>
|
||||||
/// <returns>The shared base prefix, or null when none/ambiguous.</returns>
|
/// <param name="referenceMap">The equipment's reference map: effective name → backing-tag RawPath.</param>
|
||||||
public static string? DeriveEquipmentBase(IEnumerable<string?> childFullNames)
|
/// <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;
|
if (string.IsNullOrEmpty(source) || referenceMap is null || referenceMap.Count == 0) return source;
|
||||||
foreach (var fn in childFullNames)
|
if (!source.Contains(EquipTokenPrefix, StringComparison.Ordinal)) return source;
|
||||||
{
|
return PathLiteralRegex.Replace(source, m =>
|
||||||
if (string.IsNullOrWhiteSpace(fn)) continue;
|
m.Groups[1].Value
|
||||||
var dot = fn.IndexOf('.');
|
+ SubstituteContent(m.Groups[2].Value, referenceMap)
|
||||||
var prefix = dot < 0 ? fn : fn.Substring(0, dot);
|
+ m.Groups[3].Value);
|
||||||
if (prefix.Length == 0) continue;
|
}
|
||||||
if (found is null) found = prefix;
|
|
||||||
else if (!string.Equals(found, prefix, StringComparison.Ordinal)) return null;
|
// 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.
|
||||||
return found;
|
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>
|
/// <summary>
|
||||||
/// Replace <c>{{equip}}</c> with <paramref name="equipBase"/> inside
|
/// Distinct <c><RefName></c> values used via <c>{{equip}}/<RefName></c> inside
|
||||||
/// <c>ctx.GetTag</c>/<c>ctx.SetVirtualTag</c> path literals only. Identity when
|
/// <c>ctx.GetTag</c>/<c>ctx.SetVirtualTag</c> path literals, in first-seen order. Scoped to the SAME
|
||||||
/// <paramref name="equipBase"/> is null/empty or the token is absent (so every existing
|
/// path literals <see cref="SubstituteEquipmentToken"/> operates on (so a token in a comment / logger
|
||||||
/// script — none of which use the token — is byte-unchanged).
|
/// 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>
|
/// </summary>
|
||||||
/// <param name="source">The script source.</param>
|
/// <param name="scriptSource">The virtual-tag / scripted-alarm predicate script source.</param>
|
||||||
/// <param name="equipBase">The equipment base prefix, or null/empty for no substitution.</param>
|
/// <returns>Distinct reference names, first-seen order; empty when none / no token.</returns>
|
||||||
/// <returns>The source with the token substituted inside path literals.</returns>
|
public static IReadOnlyList<string> ExtractEquipReferenceNames(string? scriptSource)
|
||||||
public static string SubstituteEquipmentToken(string source, string? equipBase)
|
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(source) || string.IsNullOrEmpty(equipBase)) return source;
|
if (string.IsNullOrEmpty(scriptSource)
|
||||||
if (!source.Contains(EquipToken, StringComparison.Ordinal)) return source;
|
|| !scriptSource.Contains(EquipTokenPrefix, StringComparison.Ordinal))
|
||||||
return PathLiteralRegex.Replace(source, m =>
|
return Array.Empty<string>();
|
||||||
m.Groups[1].Value
|
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||||
+ m.Groups[2].Value.Replace(EquipToken, equipBase, StringComparison.Ordinal)
|
var result = new List<string>();
|
||||||
+ m.Groups[3].Value);
|
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>
|
/// <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
|
/// <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>)
|
/// 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>
|
/// 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
|
/// equality depends on. The predicate source passed here is the ALREADY-substituted source (the two
|
||||||
/// tags do) — pass the predicate source as-is.
|
/// compose seams substitute <c>{{equip}}/<RefName></c> before extraction, identically), so the
|
||||||
|
/// merged refs are resolved RawPaths.
|
||||||
/// </summary>
|
/// </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>
|
/// <param name="messageTemplate">The alarm message template carrying <c>{TagPath}</c> tokens.</param>
|
||||||
/// <returns>The merged, distinct, deterministically-ordered dependency refs.</returns>
|
/// <returns>The merged, distinct, deterministically-ordered dependency refs.</returns>
|
||||||
public static IReadOnlyList<string> ExtractAlarmDependencyRefs(string? predicateSource, string? messageTemplate)
|
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="source">The virtual-tag script source to inspect.</param>
|
||||||
/// <param name="tagReference">
|
/// <param name="tagReference">
|
||||||
/// When the method returns <see langword="true"/>, the captured <c>GetTag</c> path literal
|
/// 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"/>.
|
/// otherwise <see langword="null"/>.
|
||||||
/// </param>
|
/// </param>
|
||||||
/// <returns>
|
/// <returns>
|
||||||
|
|||||||
@@ -55,6 +55,10 @@ public sealed class DraftSnapshot
|
|||||||
public IReadOnlyList<VirtualTag> VirtualTags { get; init; } = [];
|
public IReadOnlyList<VirtualTag> VirtualTags { get; init; } = [];
|
||||||
/// <summary>Equipment-bound scripted alarms. Part of the UNS effective-leaf uniqueness set.</summary>
|
/// <summary>Equipment-bound scripted alarms. Part of the UNS effective-leaf uniqueness set.</summary>
|
||||||
public IReadOnlyList<ScriptedAlarm> ScriptedAlarms { get; init; } = [];
|
public IReadOnlyList<ScriptedAlarm> ScriptedAlarms { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>User-authored scripts (shared by VirtualTags, ScriptedAlarms, and Calculation tags). Drives
|
||||||
|
/// the WP7 Calculation gates: <c>scriptId</c> existence + the calc→calc dependency-cycle check.</summary>
|
||||||
|
public IReadOnlyList<Script> Scripts { get; init; } = [];
|
||||||
/// <summary>Gets the list of poll groups.</summary>
|
/// <summary>Gets the list of poll groups.</summary>
|
||||||
public IReadOnlyList<PollGroup> PollGroups { get; init; } = [];
|
public IReadOnlyList<PollGroup> PollGroups { get; init; } = [];
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ public static class DraftSnapshotFactory
|
|||||||
UnsTagReferences = await db.UnsTagReferences.AsNoTracking().ToListAsync(ct),
|
UnsTagReferences = await db.UnsTagReferences.AsNoTracking().ToListAsync(ct),
|
||||||
VirtualTags = await db.VirtualTags.AsNoTracking().ToListAsync(ct),
|
VirtualTags = await db.VirtualTags.AsNoTracking().ToListAsync(ct),
|
||||||
ScriptedAlarms = await db.ScriptedAlarms.AsNoTracking().ToListAsync(ct),
|
ScriptedAlarms = await db.ScriptedAlarms.AsNoTracking().ToListAsync(ct),
|
||||||
|
Scripts = await db.Scripts.AsNoTracking().ToListAsync(ct),
|
||||||
PollGroups = await db.PollGroups.AsNoTracking().ToListAsync(ct),
|
PollGroups = await db.PollGroups.AsNoTracking().ToListAsync(ct),
|
||||||
PriorEquipment = [], // intentional: no prior-generation table to diff against at this level
|
PriorEquipment = [], // intentional: no prior-generation table to diff against at this level
|
||||||
ActiveReservations = await db.ExternalIdReservations
|
ActiveReservations = await db.ExternalIdReservations
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.Text.RegularExpressions;
|
|||||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
namespace ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||||
|
|
||||||
@@ -38,9 +39,108 @@ public static class DraftValidator
|
|||||||
ValidateRawNameCharset(draft, errors);
|
ValidateRawNameCharset(draft, errors);
|
||||||
ValidateHistorizedTagnameLength(draft, errors);
|
ValidateHistorizedTagnameLength(draft, errors);
|
||||||
ValidateUnsEffectiveLeafUniqueness(draft, errors);
|
ValidateUnsEffectiveLeafUniqueness(draft, errors);
|
||||||
|
ValidateEquipReferenceResolution(draft, errors);
|
||||||
|
ValidateCalculationTags(draft, errors);
|
||||||
return errors;
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>WP7 Calculation-driver deploy gates. For every tag bound to a <c>Calculation</c> driver:
|
||||||
|
/// <list type="number">
|
||||||
|
/// <item><b>scriptId existence</b> — the tag's <c>TagConfig.scriptId</c> must be present and resolve to
|
||||||
|
/// a <see cref="Script"/> row in the draft.</item>
|
||||||
|
/// <item><b>Cycle gate (hard error)</b> — build the calc→calc edge set (a calc tag → its
|
||||||
|
/// <c>ctx.GetTag</c> deps that are <b>themselves</b> Calculation tags; cross-driver refs are terminal,
|
||||||
|
/// not edges) and run <see cref="DependencyGraph.DetectCycles"/> (Tarjan SCC). Any SCC / self-loop is a
|
||||||
|
/// deploy error naming the cycle members — an undetected calc cycle is a live oscillation loop.</item>
|
||||||
|
/// </list>
|
||||||
|
/// Compile is deliberately NOT hard-gated (VirtualTag parity — the editor's live diagnostics mirror it and
|
||||||
|
/// a runtime compile failure lands as Bad quality + a script-log).</summary>
|
||||||
|
private static void ValidateCalculationTags(DraftSnapshot draft, List<ValidationError> errors)
|
||||||
|
{
|
||||||
|
var driverTypeByInstance = draft.DriverInstances
|
||||||
|
.GroupBy(d => d.DriverInstanceId, StringComparer.Ordinal)
|
||||||
|
.ToDictionary(g => g.Key, g => g.First().DriverType, StringComparer.Ordinal);
|
||||||
|
var driverTypeByDevice = draft.Devices
|
||||||
|
.GroupBy(d => d.DeviceId, StringComparer.Ordinal)
|
||||||
|
.ToDictionary(
|
||||||
|
g => g.Key,
|
||||||
|
g => driverTypeByInstance.TryGetValue(g.First().DriverInstanceId, out var dt) ? dt : null,
|
||||||
|
StringComparer.Ordinal);
|
||||||
|
|
||||||
|
var resolver = BuildRawPathResolver(draft);
|
||||||
|
var scriptExists = new HashSet<string>(draft.Scripts.Select(s => s.ScriptId), StringComparer.Ordinal);
|
||||||
|
var scriptSourceById = draft.Scripts
|
||||||
|
.GroupBy(s => s.ScriptId, StringComparer.Ordinal)
|
||||||
|
.ToDictionary(g => g.Key, g => g.First().SourceCode, StringComparer.Ordinal);
|
||||||
|
|
||||||
|
// Identify calc tags (tags on a Calculation-typed device) + their RawPath + scriptId in one pass.
|
||||||
|
var calcTags = new List<(Tag Tag, string? RawPath, string? ScriptId)>();
|
||||||
|
var calcRawPaths = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
foreach (var t in draft.Tags)
|
||||||
|
{
|
||||||
|
if (!driverTypeByDevice.TryGetValue(t.DeviceId, out var dt)
|
||||||
|
|| !string.Equals(dt, Core.Abstractions.DriverTypeNames.Calculation, StringComparison.Ordinal))
|
||||||
|
continue;
|
||||||
|
var rawPath = resolver.TryBuildTagPath(t.DeviceId, t.TagGroupId, t.Name);
|
||||||
|
calcTags.Add((t, rawPath, ParseScriptId(t.TagConfig)));
|
||||||
|
if (rawPath is not null) calcRawPaths.Add(rawPath);
|
||||||
|
}
|
||||||
|
if (calcTags.Count == 0) return;
|
||||||
|
|
||||||
|
// Rule 1 — scriptId existence.
|
||||||
|
foreach (var (t, _, scriptId) in calcTags)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(scriptId))
|
||||||
|
errors.Add(new("CalculationScriptMissing",
|
||||||
|
$"Calculation tag '{t.TagId}' has no 'scriptId' in its TagConfig", t.TagId));
|
||||||
|
else if (!scriptExists.Contains(scriptId!))
|
||||||
|
errors.Add(new("CalculationScriptNotFound",
|
||||||
|
$"Calculation tag '{t.TagId}' references script '{scriptId}', which does not exist in the draft",
|
||||||
|
t.TagId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule 2 — cycle gate. Edges run from a calc tag to each dep that is ITSELF a calc tag.
|
||||||
|
var graph = new DependencyGraph();
|
||||||
|
foreach (var (_, rawPath, scriptId) in calcTags)
|
||||||
|
{
|
||||||
|
if (rawPath is null) continue; // broken chain — flagged by the charset rule; not a graph node
|
||||||
|
var source = scriptId is not null && scriptSourceById.TryGetValue(scriptId, out var src) ? src : null;
|
||||||
|
var calcDeps = EquipmentScriptPaths.ExtractDependencyRefs(source)
|
||||||
|
.Where(calcRawPaths.Contains)
|
||||||
|
.ToHashSet(StringComparer.Ordinal);
|
||||||
|
graph.Add(rawPath, calcDeps);
|
||||||
|
}
|
||||||
|
foreach (var cycle in graph.DetectCycles())
|
||||||
|
errors.Add(new("CalculationDependencyCycle",
|
||||||
|
"Calculation tags form a dependency cycle (members: " + string.Join(", ", cycle) +
|
||||||
|
"); a calc→calc cycle is a live oscillation loop and must be broken before deploy",
|
||||||
|
cycle.Count > 0 ? cycle[0] : null));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Extract the <c>scriptId</c> string from a calc tag's schemaless <c>TagConfig</c> JSON. Never
|
||||||
|
/// throws — malformed/blank/non-object JSON or an absent/non-string <c>scriptId</c> yields null.</summary>
|
||||||
|
private static string? ParseScriptId(string? tagConfig)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(tagConfig)) return null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = System.Text.Json.JsonDocument.Parse(tagConfig);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
if (root.ValueKind != System.Text.Json.JsonValueKind.Object) return null;
|
||||||
|
if (root.TryGetProperty("scriptId", out var el)
|
||||||
|
&& el.ValueKind == System.Text.Json.JsonValueKind.String)
|
||||||
|
{
|
||||||
|
var v = el.GetString();
|
||||||
|
return string.IsNullOrWhiteSpace(v) ? null : v;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch (System.Text.Json.JsonException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Builds the shared <see cref="RawPathResolver"/> from the draft's raw topology so the
|
/// <summary>Builds the shared <see cref="RawPathResolver"/> from the draft's raw topology so the
|
||||||
/// tagname-length rule computes the SAME RawPath the deploy artifact injects into drivers.</summary>
|
/// tagname-length rule computes the SAME RawPath the deploy artifact injects into drivers.</summary>
|
||||||
private static RawPathResolver BuildRawPathResolver(DraftSnapshot draft)
|
private static RawPathResolver BuildRawPathResolver(DraftSnapshot draft)
|
||||||
@@ -139,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) =>
|
private static bool IsValidSegment(string? s) =>
|
||||||
s is not null && (UnsSegment.IsMatch(s) || s == UnsDefaultSegment);
|
s is not null && (UnsSegment.IsMatch(s) || s == UnsDefaultSegment);
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,12 @@
|
|||||||
<LangVersion>latest</LangVersion>
|
<LangVersion>latest</LangVersion>
|
||||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
<!-- WP7: the calc deploy-gate reuses the pure DependencyGraph (Tarjan SCC) from Core.VirtualTags,
|
||||||
|
whose closure includes Microsoft.CodeAnalysis.CSharp.Scripting 4.12.0 (== CodeAnalysis.Common
|
||||||
|
4.12.0), while EF's transitive CodeAnalysis.Common 5.0.0 wins resolution. Suppress NU1608 — the
|
||||||
|
only surface Configuration touches (DependencyGraph.DetectCycles) has ZERO Roslyn dependency, so
|
||||||
|
the version skew never runs. Mirrors the identical suppression + rationale in the Host csproj. -->
|
||||||
|
<NoWarn>$(NoWarn);CS1591;NU1608</NoWarn>
|
||||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Configuration</RootNamespace>
|
<RootNamespace>ZB.MOM.WW.OtOpcUa.Configuration</RootNamespace>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
@@ -30,6 +35,9 @@
|
|||||||
<!-- R2-11 (01/C-1): consume the shared TagConfigIntent.Parse byte-parity authority in Commons.
|
<!-- R2-11 (01/C-1): consume the shared TagConfigIntent.Parse byte-parity authority in Commons.
|
||||||
Cycle-safe — Commons has zero in-repo ProjectReferences. -->
|
Cycle-safe — Commons has zero in-repo ProjectReferences. -->
|
||||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj"/>
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj"/>
|
||||||
|
<!-- WP7: the deploy-gate cycle check reuses DependencyGraph (Tarjan SCC) for the calc→calc
|
||||||
|
dependency-cycle rule. Cycle-safe — Core.VirtualTags does not reference Configuration. -->
|
||||||
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.VirtualTags\ZB.MOM.WW.OtOpcUa.Core.VirtualTags.csproj"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Single source of truth for the driver-type identifier strings the runtime
|
||||||
|
/// dispatches on. Each constant's <b>value</b> is the exact <c>DriverTypeName</c>
|
||||||
|
/// that the corresponding driver factory registers under in the process
|
||||||
|
/// <c>DriverFactoryRegistry</c> — the string the deploy pipeline stores in
|
||||||
|
/// <c>DriverInstance.DriverType</c> and every dispatch map keys by.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Historically several dispatch maps hand-authored these strings and drifted from
|
||||||
|
/// the authoritative factory names (e.g. <c>"TwinCat"</c> vs the real
|
||||||
|
/// <c>"TwinCAT"</c>, <c>"Focas"</c> vs <c>"FOCAS"</c>). Consumers should reference
|
||||||
|
/// these constants instead of literals so the drift can never recur.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The reflection guard test
|
||||||
|
/// (<c>DriverTypeNamesGuardTests</c>) asserts bidirectional parity between these
|
||||||
|
/// constants and the set the driver factories actually register, so a rename on
|
||||||
|
/// either side breaks the build's test gate. Only constants for
|
||||||
|
/// <b>currently-registered</b> factories belong here — a not-yet-registered driver
|
||||||
|
/// (e.g. the Calculation driver landing in a later Batch 2 package) adds its own
|
||||||
|
/// constant when its factory is wired in.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class DriverTypeNames
|
||||||
|
{
|
||||||
|
/// <summary>Modbus TCP / RTU-over-TCP driver.</summary>
|
||||||
|
public const string Modbus = "Modbus";
|
||||||
|
|
||||||
|
/// <summary>Siemens S7 driver.</summary>
|
||||||
|
public const string S7 = "S7";
|
||||||
|
|
||||||
|
/// <summary>Allen-Bradley CIP (ControlLogix / CompactLogix) driver.</summary>
|
||||||
|
public const string AbCip = "AbCip";
|
||||||
|
|
||||||
|
/// <summary>Allen-Bradley legacy (PCCC / DF1-over-TCP) driver.</summary>
|
||||||
|
public const string AbLegacy = "AbLegacy";
|
||||||
|
|
||||||
|
/// <summary>Beckhoff TwinCAT ADS driver.</summary>
|
||||||
|
public const string TwinCAT = "TwinCAT";
|
||||||
|
|
||||||
|
/// <summary>FANUC FOCAS CNC driver.</summary>
|
||||||
|
public const string FOCAS = "FOCAS";
|
||||||
|
|
||||||
|
/// <summary>OPC UA client (upstream-server) driver.</summary>
|
||||||
|
public const string OpcUaClient = "OpcUaClient";
|
||||||
|
|
||||||
|
/// <summary>AVEVA System Platform (Wonderware) Galaxy driver, via the mxaccessgw gateway.</summary>
|
||||||
|
public const string Galaxy = "GalaxyMxGateway";
|
||||||
|
|
||||||
|
/// <summary>Calculation pseudo-driver — tags computed by C# scripts over other tags' live values.</summary>
|
||||||
|
public const string Calculation = "Calculation";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Every driver-type string declared above, for callers that need to enumerate
|
||||||
|
/// the full set (e.g. validation of an authored <c>DriverType</c>).
|
||||||
|
/// </summary>
|
||||||
|
public static IReadOnlyCollection<string> All { get; } =
|
||||||
|
[
|
||||||
|
Modbus,
|
||||||
|
S7,
|
||||||
|
AbCip,
|
||||||
|
AbLegacy,
|
||||||
|
TwinCAT,
|
||||||
|
FOCAS,
|
||||||
|
OpcUaClient,
|
||||||
|
Galaxy,
|
||||||
|
Calculation,
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional composable driver capability: the driver consumes <b>other</b> tags' live values.
|
||||||
|
/// The host registers the declared <see cref="DependencyRefs"/> with its per-node dependency mux
|
||||||
|
/// and forwards every matching value change into <see cref="OnDependencyValue"/> — the seam that
|
||||||
|
/// lets a host-blind driver (e.g. the <c>Calculation</c> pseudo-driver) read the address space's
|
||||||
|
/// live values without any cross-driver plumbing of its own.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The mechanism mirrors how a <c>VirtualTagActor</c> registers interest on the
|
||||||
|
/// <c>DependencyMuxActor</c>: the mux already receives every driver's
|
||||||
|
/// <c>AttributeValuePublished</c> keyed by wire-ref (under v3, RawPath), so a driver that
|
||||||
|
/// implements this interface simply piggy-backs on that fan-out. A calc tag's computed output
|
||||||
|
/// re-enters the mux via the ordinary driver publish path, so calc-of-calc chains work with no
|
||||||
|
/// extra machinery — which is exactly why the deploy-time cycle gate is mandatory.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Implementations of <see cref="OnDependencyValue"/> are invoked from an actor context and
|
||||||
|
/// <b>must be non-blocking</b> — do the work inline and cheaply (the compile cache makes
|
||||||
|
/// steady-state calc evaluation a bounded method invocation), never block on I/O.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public interface IDependencyConsumer
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Wire-refs (RawPaths) this driver needs fed to it, derived from its authored tags. The host
|
||||||
|
/// re-reads this after <see cref="IDriver.InitializeAsync"/> / <see cref="IDriver.ReinitializeAsync"/>
|
||||||
|
/// and (re)registers the set with its dependency mux on every apply, so a tag/script edit that
|
||||||
|
/// changes the set converges without a bespoke notification.
|
||||||
|
/// </summary>
|
||||||
|
IReadOnlyCollection<string> DependencyRefs { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Host push of a single dependency value change. Called from an actor context, so the
|
||||||
|
/// implementation must be non-blocking.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="rawPath">The changed dependency's wire-ref (RawPath).</param>
|
||||||
|
/// <param name="value">The new value (may be null).</param>
|
||||||
|
/// <param name="statusCode">The OPC UA status code carried with the value (0 = Good).</param>
|
||||||
|
/// <param name="timestampUtc">The source timestamp of the change.</param>
|
||||||
|
void OnDependencyValue(string rawPath, object? value, uint statusCode, DateTime timestampUtc);
|
||||||
|
}
|
||||||
@@ -0,0 +1,473 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Serilog;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The <c>Calculation</c> pseudo-driver. Its tags are ordinary raw tags whose value is <b>computed</b>
|
||||||
|
/// by a C# script over other tags' live values — there is no backend, no endpoint, no discovery, no
|
||||||
|
/// writes. It implements:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><see cref="IDriver"/> — lifecycle + always-Connected health.</item>
|
||||||
|
/// <item><see cref="IDependencyConsumer"/> — the host feeds it the other tags' live values.</item>
|
||||||
|
/// <item><see cref="ISubscribable"/> — computed values flow out via <see cref="OnDataChange"/>
|
||||||
|
/// exactly like any driver, so the ordinary node fan-out + the mux re-entry (calc-of-calc) apply.</item>
|
||||||
|
/// <item><see cref="IReadable"/> — on-demand read returns the last computed snapshot
|
||||||
|
/// (<c>BadWaitingForInitialData</c> before the first evaluation).</item>
|
||||||
|
/// </list>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>Triggers.</b> A tag re-evaluates on any of its declared dependencies changing (change-trigger,
|
||||||
|
/// gated exactly like <c>VirtualTagActor</c>: nothing publishes until every declared dep has arrived
|
||||||
|
/// at least once, and equal results are deduped) and/or on a timer (one timer per interval group).
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Error semantics (mini-design §6).</b> An evaluator <c>Failure</c> publishes <b>Bad</b> quality
|
||||||
|
/// carrying the last-known value, once per Good→Bad transition and only after all deps have arrived,
|
||||||
|
/// and emits a <c>ScriptLogEntry</c> on the <c>script-logs</c> topic (via the injected
|
||||||
|
/// <see cref="ScriptRootLogger"/>'s <c>ScriptLogTopicSink</c>). Recovery to Good is force-published.
|
||||||
|
/// A historized Bad records <c>BadInternalError</c>.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscribable, IReadable, IDisposable
|
||||||
|
{
|
||||||
|
// OPC UA status codes surfaced by the calc driver.
|
||||||
|
private const uint StatusGood = 0u;
|
||||||
|
private const uint StatusBadWaitingForInitialData = 0x80320000u;
|
||||||
|
private const uint StatusBadInternalError = 0x80020000u;
|
||||||
|
|
||||||
|
private readonly string _driverInstanceId;
|
||||||
|
private readonly CalculationDriverOptions _options;
|
||||||
|
private readonly ILogger<CalculationDriver> _logger;
|
||||||
|
private readonly ScriptRootLogger _scriptRoot;
|
||||||
|
private readonly CalculationEvaluator _evaluator;
|
||||||
|
|
||||||
|
// Authored calc tags, keyed by RawPath (identity + evaluation id). Built at construction so
|
||||||
|
// DependencyRefs is available BEFORE InitializeAsync (the host reads it when spawning the mux-adapter),
|
||||||
|
// and REBUILT on every ReinitializeAsync (an in-place ApplyDelta re-binds an edited calc-tag set).
|
||||||
|
private readonly Dictionary<string, CalculationTagDefinition> _tagsByRawPath = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
// rawPath (a dependency) → the calc tags that read it AND are change-triggered. Drives OnDependencyValue.
|
||||||
|
private readonly Dictionary<string, List<string>> _changeDependents = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
// Union of every tag's declared dependency refs — the interest set the host registers on the mux.
|
||||||
|
// Reassigned by RebuildTagTable so a redeploy that adds/removes dependencies re-registers on the mux.
|
||||||
|
private IReadOnlyCollection<string> _dependencyRefs = Array.Empty<string>();
|
||||||
|
|
||||||
|
// Live evaluation state. Guarded by _lock: OnDependencyValue (actor thread) and timer ticks
|
||||||
|
// (pool threads) can both drive an evaluation, so all state mutation is serialized.
|
||||||
|
private readonly object _lock = new();
|
||||||
|
private readonly Dictionary<string, object?> _depValues = new(StringComparer.Ordinal);
|
||||||
|
private readonly HashSet<string> _arrivedDeps = new(StringComparer.Ordinal);
|
||||||
|
private readonly Dictionary<string, TagState> _stateByRawPath = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
private CalculationTimerScheduler? _timers;
|
||||||
|
private volatile bool _running;
|
||||||
|
private DateTime? _lastComputeUtc;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
/// <summary>Occurs when a calc tag's computed value changes (or transitions Good↔Bad).</summary>
|
||||||
|
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||||
|
|
||||||
|
/// <summary>Initializes a new <see cref="CalculationDriver"/>.</summary>
|
||||||
|
/// <param name="options">Bound options carrying the authored raw calc tags.</param>
|
||||||
|
/// <param name="driverInstanceId">Stable logical id of this driver instance.</param>
|
||||||
|
/// <param name="scriptRoot">Root script logger — user <c>ctx.Logger</c> output + failure entries fan out
|
||||||
|
/// to the <c>script-logs</c> topic through its <c>ScriptLogTopicSink</c>. When null a no-op logger is used.</param>
|
||||||
|
/// <param name="logger">Host-side diagnostics logger; defaults to the null logger.</param>
|
||||||
|
public CalculationDriver(
|
||||||
|
CalculationDriverOptions options,
|
||||||
|
string driverInstanceId,
|
||||||
|
ScriptRootLogger? scriptRoot = null,
|
||||||
|
ILogger<CalculationDriver>? logger = null)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||||
|
_options = options;
|
||||||
|
_driverInstanceId = driverInstanceId;
|
||||||
|
_logger = logger ?? NullLogger<CalculationDriver>.Instance;
|
||||||
|
_scriptRoot = scriptRoot ?? new ScriptRootLogger(new LoggerConfiguration().CreateLogger());
|
||||||
|
_evaluator = new CalculationEvaluator(
|
||||||
|
_logger is ILogger<CalculationEvaluator> el ? el : NullLogger<CalculationEvaluator>.Instance,
|
||||||
|
_scriptRoot,
|
||||||
|
_options.RunTimeout);
|
||||||
|
|
||||||
|
RebuildTagTable(_options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// (Re)build the authored tag table (<see cref="_tagsByRawPath"/>), the change-trigger fan-out
|
||||||
|
/// (<see cref="_changeDependents"/>), the dependency-ref interest set (<see cref="_dependencyRefs"/>),
|
||||||
|
/// and the per-tag state map (<see cref="_stateByRawPath"/>) from <paramref name="config"/>. Called
|
||||||
|
/// once at construction AND on every <see cref="ReinitializeAsync"/> so an in-place ApplyDelta — a
|
||||||
|
/// calc-tag add/remove/edit, or a script-source edit — actually takes effect. Extracted so the ctor
|
||||||
|
/// and reinit paths share ONE construction routine and cannot drift.
|
||||||
|
/// <para>Per-tag <see cref="TagState"/> is preserved for a tag that survives the edit (cheap last-known
|
||||||
|
/// value carry-over); a brand-new tag starts fresh; a removed tag's state is dropped. MUST be called
|
||||||
|
/// under <see cref="_lock"/> when invoked post-construction (the ctor runs single-threaded).</para>
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="config">The (new) bound options carrying the authored raw calc tags.</param>
|
||||||
|
private void RebuildTagTable(CalculationDriverOptions config)
|
||||||
|
{
|
||||||
|
_tagsByRawPath.Clear();
|
||||||
|
_changeDependents.Clear();
|
||||||
|
// Retain surviving tags' state; anything not re-authored is dropped when we swap this in below.
|
||||||
|
var retainedState = new Dictionary<string, TagState>(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
foreach (var entry in config.RawTags)
|
||||||
|
{
|
||||||
|
if (!CalculationTagDefinition.TryFromTagConfig(entry.TagConfig, entry.RawPath, out var def))
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Calculation {Driver}: raw tag {RawPath} has no scriptId; skipping (not a calc tag)",
|
||||||
|
_driverInstanceId, entry.RawPath);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (string.IsNullOrWhiteSpace(def.ScriptSource))
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Calculation {Driver}: calc tag {RawPath} (script {Script}) has no resolved scriptSource; " +
|
||||||
|
"it will not compute until the deploy artifact injects the script source",
|
||||||
|
_driverInstanceId, entry.RawPath, def.ScriptId);
|
||||||
|
}
|
||||||
|
_tagsByRawPath[entry.RawPath] = def;
|
||||||
|
// Carry the last-known state forward for a surviving tag; a brand-new tag starts fresh.
|
||||||
|
retainedState[entry.RawPath] =
|
||||||
|
_stateByRawPath.TryGetValue(entry.RawPath, out var prior) ? prior : new TagState();
|
||||||
|
if (def.ChangeTriggered)
|
||||||
|
{
|
||||||
|
foreach (var dep in def.DependencyRefs)
|
||||||
|
{
|
||||||
|
if (!_changeDependents.TryGetValue(dep, out var list))
|
||||||
|
_changeDependents[dep] = list = new List<string>();
|
||||||
|
if (!list.Contains(entry.RawPath, StringComparer.Ordinal)) list.Add(entry.RawPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swap in the retained/new state map — a removed tag's state is dropped so it stops publishing.
|
||||||
|
_stateByRawPath.Clear();
|
||||||
|
foreach (var (rawPath, state) in retainedState) _stateByRawPath[rawPath] = state;
|
||||||
|
|
||||||
|
_dependencyRefs = _tagsByRawPath.Values
|
||||||
|
.SelectMany(t => t.DependencyRefs)
|
||||||
|
.Distinct(StringComparer.Ordinal)
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reinit config-bind options — mirrors the factory's bind (case-insensitive, enum-string, trailing-comma
|
||||||
|
// tolerant) so an ApplyDelta payload re-binds identically to the spawn payload.
|
||||||
|
private static readonly JsonSerializerOptions ReinitJsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||||
|
AllowTrailingCommas = true,
|
||||||
|
Converters = { new JsonStringEnumConverter() },
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Re-bind the authored raw calc tags from a new merged DriverConfig JSON (the ApplyDelta
|
||||||
|
/// payload). The ctor-fixed <see cref="CalculationEvaluator"/> timeout is kept — <c>RunTimeout</c> is bound
|
||||||
|
/// at spawn (a change to it is out of this in-place path's scope). A parse failure logs and keeps the
|
||||||
|
/// current tags so a malformed delta can never blank the driver.</summary>
|
||||||
|
private CalculationDriverOptions ParseOptions(string driverConfigJson)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(driverConfigJson))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var dto = JsonSerializer.Deserialize<ReinitConfigDto>(driverConfigJson, ReinitJsonOptions);
|
||||||
|
if (dto is not null)
|
||||||
|
{
|
||||||
|
return new CalculationDriverOptions
|
||||||
|
{
|
||||||
|
RawTags = dto.RawTags is { Count: > 0 } ? [.. dto.RawTags] : [],
|
||||||
|
RunTimeout = _options.RunTimeout,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex,
|
||||||
|
"Calculation {Driver}: reinit config parse failed; keeping the current tag table",
|
||||||
|
_driverInstanceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new CalculationDriverOptions { RawTags = _options.RawTags, RunTimeout = _options.RunTimeout };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Minimal reinit-bind DTO — the calc driver has no backend config, only the injected raw tags
|
||||||
|
/// (+ an optional eval-timeout, ignored on reinit since the evaluator is ctor-fixed). Mirrors the factory's
|
||||||
|
/// <c>CalculationDriverConfigDto</c>.</summary>
|
||||||
|
private sealed class ReinitConfigDto
|
||||||
|
{
|
||||||
|
public List<RawTagEntry>? RawTags { get; init; }
|
||||||
|
public int? RunTimeoutMs { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- IDriver ----
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string DriverInstanceId => _driverInstanceId;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string DriverType => DriverTypeNames.Calculation;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_running = true;
|
||||||
|
StartTimers();
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Calculation {Driver}: initialized with {TagCount} calc tag(s), {DepCount} distinct dependency ref(s)",
|
||||||
|
_driverInstanceId, _tagsByRawPath.Count, _dependencyRefs.Count);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// A calc-tag add/remove/edit — or a script edit that changes the injected scriptSource — changes the
|
||||||
|
// merged DriverConfig, which DriverSpawnPlanner routes to an in-place ApplyDelta → THIS call (a
|
||||||
|
// respawn fires only on a DriverType/ResilienceConfig change). So the authored tag table is NOT
|
||||||
|
// fixed at construction: we re-bind the new config and REBUILD the tag table + dependency-ref set
|
||||||
|
// here. Without this, added tags never compute, removed tags keep publishing, and edited scripts
|
||||||
|
// serve stale results (deploy reports success but nothing changed). After the rebuild _dependencyRefs
|
||||||
|
// reflects the new authored tags, and the host re-registers the mux interest post-reinit (the
|
||||||
|
// DriverInstanceActor.DeltaApplied → DriverHostActor path, sequenced AFTER this completes).
|
||||||
|
StopTimers();
|
||||||
|
var newOptions = ParseOptions(driverConfigJson);
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
RebuildTagTable(newOptions);
|
||||||
|
}
|
||||||
|
// Drop compiled scripts (IScriptCacheOwner contract — stops collectible ALCs accreting AND forces a
|
||||||
|
// recompile from each tag's NEW ScriptSource) + re-arm timers off the rebuilt tag set.
|
||||||
|
_evaluator.ClearCompiledScripts();
|
||||||
|
_running = true;
|
||||||
|
StartTimers();
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task ShutdownAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_running = false;
|
||||||
|
StopTimers();
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public DriverHealth GetHealth() => new(DriverState.Healthy, _lastComputeUtc, null);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public long GetMemoryFootprint() => 0;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// The compile cache is the only optional cache; dropping it is safe (recompiled on next eval).
|
||||||
|
_evaluator.ClearCompiledScripts();
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- IDependencyConsumer ----
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IReadOnlyCollection<string> DependencyRefs => _dependencyRefs;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void OnDependencyValue(string rawPath, object? value, uint statusCode, DateTime timestampUtc)
|
||||||
|
{
|
||||||
|
if (!_running) return;
|
||||||
|
List<string>? dependents;
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
_depValues[rawPath] = value;
|
||||||
|
_arrivedDeps.Add(rawPath);
|
||||||
|
if (!_changeDependents.TryGetValue(rawPath, out dependents) || dependents.Count == 0) return;
|
||||||
|
// Evaluate each change-triggered dependent inline under the lock (the compile cache makes this
|
||||||
|
// a bounded method invocation — the same "fast enough to run inline" contract as the VT evaluator).
|
||||||
|
foreach (var calcRawPath in dependents)
|
||||||
|
EvaluateTag(calcRawPath, timestampUtc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- ISubscribable ----
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<ISubscriptionHandle> SubscribeAsync(
|
||||||
|
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(fullReferences);
|
||||||
|
var handle = new CalculationSubscriptionHandle($"calc-{Guid.NewGuid():N}");
|
||||||
|
// Initial-data callback (OPC UA convention): surface the current snapshot for each subscribed calc
|
||||||
|
// tag so a freshly-materialised node shows BadWaitingForInitialData until its first evaluation lands.
|
||||||
|
foreach (var reference in fullReferences)
|
||||||
|
{
|
||||||
|
var snapshot = ReadOne(reference);
|
||||||
|
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, reference, snapshot));
|
||||||
|
}
|
||||||
|
return Task.FromResult<ISubscriptionHandle>(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
|
||||||
|
=> Task.CompletedTask;
|
||||||
|
|
||||||
|
// ---- IReadable ----
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||||
|
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(fullReferences);
|
||||||
|
var results = new DataValueSnapshot[fullReferences.Count];
|
||||||
|
for (var i = 0; i < fullReferences.Count; i++)
|
||||||
|
results[i] = ReadOne(fullReferences[i]);
|
||||||
|
return Task.FromResult<IReadOnlyList<DataValueSnapshot>>(results);
|
||||||
|
}
|
||||||
|
|
||||||
|
private DataValueSnapshot ReadOne(string reference)
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
if (!_stateByRawPath.TryGetValue(reference, out var st) || !st.HasValue)
|
||||||
|
return new DataValueSnapshot(null, StatusBadWaitingForInitialData, null, now);
|
||||||
|
var status = st.LastPublishedBad ? StatusBadInternalError : StatusGood;
|
||||||
|
return new DataValueSnapshot(st.LastValue, status, st.LastTimestampUtc, st.LastTimestampUtc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- evaluation ----
|
||||||
|
|
||||||
|
/// <summary>Timer-trigger entry point — evaluates a single calc tag. Guards the lock itself (called
|
||||||
|
/// from a pool thread, unlike the change path which is already under the lock).</summary>
|
||||||
|
private void EvaluateTagLocked(string rawPath)
|
||||||
|
{
|
||||||
|
if (!_running) return;
|
||||||
|
lock (_lock) EvaluateTag(rawPath, DateTime.UtcNow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Evaluate one calc tag and publish the outcome. MUST be called under <see cref="_lock"/>.</summary>
|
||||||
|
private void EvaluateTag(string rawPath, DateTime timestampUtc)
|
||||||
|
{
|
||||||
|
if (!_tagsByRawPath.TryGetValue(rawPath, out var def)) return;
|
||||||
|
if (string.IsNullOrWhiteSpace(def.ScriptSource)) return; // unresolved script — never computes (logged at init)
|
||||||
|
|
||||||
|
var st = _stateByRawPath[rawPath];
|
||||||
|
|
||||||
|
// Change-gate (mini-design §4): publish nothing until every declared dep has arrived at least once.
|
||||||
|
var allArrived = def.DependencyRefs.All(_arrivedDeps.Contains);
|
||||||
|
|
||||||
|
// Build the per-tag dependency snapshot (only its declared reads, current values).
|
||||||
|
var deps = new Dictionary<string, object?>(StringComparer.Ordinal);
|
||||||
|
foreach (var dep in def.DependencyRefs)
|
||||||
|
if (_depValues.TryGetValue(dep, out var v)) deps[dep] = v;
|
||||||
|
|
||||||
|
var result = _evaluator.Evaluate(rawPath, def.ScriptSource, deps);
|
||||||
|
|
||||||
|
if (!result.Success)
|
||||||
|
{
|
||||||
|
OnEvaluationFailed(def, st, result.Reason ?? "evaluator failure", allArrived, timestampUtc);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allArrived) return; // change-trigger warm-up: hold until inputs are ready
|
||||||
|
|
||||||
|
// Value dedup — but bypass it while recovering from Bad so a recovery whose value equals the
|
||||||
|
// pre-failure value still republishes Good (otherwise the node would stay Bad forever).
|
||||||
|
if (!st.LastPublishedBad && st.HasValue && Equals(st.LastValue, result.Value))
|
||||||
|
return;
|
||||||
|
|
||||||
|
st.HasValue = true;
|
||||||
|
st.LastValue = result.Value;
|
||||||
|
st.LastPublishedBad = false;
|
||||||
|
st.LastTimestampUtc = timestampUtc;
|
||||||
|
_lastComputeUtc = DateTime.UtcNow;
|
||||||
|
Publish(rawPath, result.Value, StatusGood, timestampUtc);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Failure tail (mirrors <c>VirtualTagActor</c> 02/S13): always emits the per-failure
|
||||||
|
/// script-log; additionally publishes a Bad snapshot — carrying the last-known value — once per
|
||||||
|
/// Good→Bad transition and only after every declared dependency has arrived.</summary>
|
||||||
|
private void OnEvaluationFailed(
|
||||||
|
CalculationTagDefinition def, TagState st, string reason, bool allArrived, DateTime timestampUtc)
|
||||||
|
{
|
||||||
|
PublishScriptLog(def, reason);
|
||||||
|
if (st.LastPublishedBad || !allArrived) return;
|
||||||
|
|
||||||
|
st.LastPublishedBad = true;
|
||||||
|
st.LastTimestampUtc = timestampUtc;
|
||||||
|
// Carry the last-known value (null if none) with Bad quality.
|
||||||
|
Publish(def.RawPath, st.HasValue ? st.LastValue : null, StatusBadInternalError, timestampUtc);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Publish(string rawPath, object? value, uint statusCode, DateTime timestampUtc)
|
||||||
|
{
|
||||||
|
var snapshot = new DataValueSnapshot(value, statusCode, timestampUtc, timestampUtc);
|
||||||
|
// Fired under _lock — the subscriber (DriverInstanceActor) marshals the event to its own thread,
|
||||||
|
// so there is no re-entrant call back into this driver.
|
||||||
|
OnDataChange?.Invoke(this, new DataChangeEventArgs(SharedHandle, rawPath, snapshot));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PublishScriptLog(CalculationTagDefinition def, string reason)
|
||||||
|
{
|
||||||
|
// Route through the script logger bound with ScriptId + the calc tag's RawPath (in the VirtualTagId
|
||||||
|
// slot the Script-log page attributes on). The ScriptRootLogger's ScriptLogTopicSink converts this
|
||||||
|
// Warning event into a ScriptLogEntry on the script-logs DPS topic — the driver needs no Akka access.
|
||||||
|
_scriptRoot.Logger
|
||||||
|
.ForContext(ScriptLoggerFactory.ScriptIdProperty, def.ScriptId)
|
||||||
|
.ForContext(ScriptLoggerFactory.VirtualTagIdProperty, def.RawPath)
|
||||||
|
.Warning("{Reason}", reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StartTimers()
|
||||||
|
{
|
||||||
|
var timed = _tagsByRawPath.Values.Where(t => t.TimerInterval is not null).ToArray();
|
||||||
|
if (timed.Length == 0) return;
|
||||||
|
_timers = new CalculationTimerScheduler(EvaluateTagLocked, _logger);
|
||||||
|
_timers.Start(timed);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StopTimers()
|
||||||
|
{
|
||||||
|
_timers?.Dispose();
|
||||||
|
_timers = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Test/diagnostic accessor: number of timer ticks skipped for in-flight groups.</summary>
|
||||||
|
internal long TimerSkippedTickCount => _timers?.SkippedTickCount ?? 0;
|
||||||
|
|
||||||
|
// A single shared handle for host-pushed publishes (change + timer). SubscribeAsync mints its own
|
||||||
|
// per-call handle for the initial-data callback; steady-state publishes reuse this so OnDataChange
|
||||||
|
// always carries a non-null handle.
|
||||||
|
private static readonly CalculationSubscriptionHandle SharedHandle = new("calc-shared");
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
_disposed = true;
|
||||||
|
_running = false;
|
||||||
|
StopTimers();
|
||||||
|
_evaluator.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Per-tag published state (guarded by <see cref="_lock"/>).</summary>
|
||||||
|
private sealed class TagState
|
||||||
|
{
|
||||||
|
public bool HasValue;
|
||||||
|
public object? LastValue;
|
||||||
|
public bool LastPublishedBad;
|
||||||
|
public DateTime LastTimestampUtc;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record CalculationSubscriptionHandle(string DiagnosticId) : ISubscriptionHandle;
|
||||||
|
}
|
||||||
+96
@@ -0,0 +1,96 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Static factory registration helper for <see cref="CalculationDriver"/>. The Server's
|
||||||
|
/// <c>DriverFactoryBootstrap</c> calls <see cref="Register"/> once at startup; the bootstrapper then
|
||||||
|
/// materialises <c>Calculation</c> DriverInstance rows into live driver instances. Mirrors
|
||||||
|
/// <c>ModbusDriverFactoryExtensions</c>, with the addition of a <see cref="ScriptRootLogger"/> so a
|
||||||
|
/// script failure fans out onto the <c>script-logs</c> topic (via the root logger's
|
||||||
|
/// <c>ScriptLogTopicSink</c>) — the calc driver has no Akka access of its own.
|
||||||
|
/// </summary>
|
||||||
|
public static class CalculationDriverFactoryExtensions
|
||||||
|
{
|
||||||
|
/// <summary>The <c>DriverInstance.DriverType</c> string this factory registers under.</summary>
|
||||||
|
public const string DriverTypeName = "Calculation";
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||||
|
AllowTrailingCommas = true,
|
||||||
|
Converters = { new JsonStringEnumConverter() },
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Register the Calculation factory with the driver registry. The optional
|
||||||
|
/// <paramref name="loggerFactory"/> + <paramref name="scriptRoot"/> are captured at registration
|
||||||
|
/// time; both may be null (the guard test invokes <c>Register(registry)</c> reflectively with the
|
||||||
|
/// trailing parameters defaulted), in which case the driver runs with the null logger + a no-op
|
||||||
|
/// script-logger.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="registry">The driver factory registry to register with.</param>
|
||||||
|
/// <param name="loggerFactory">Optional logger factory for per-instance diagnostics loggers.</param>
|
||||||
|
/// <param name="scriptRoot">Optional root script logger (fans failure entries onto the <c>script-logs</c> topic).</param>
|
||||||
|
public static void Register(
|
||||||
|
DriverFactoryRegistry registry,
|
||||||
|
ILoggerFactory? loggerFactory = null,
|
||||||
|
ScriptRootLogger? scriptRoot = null)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(registry);
|
||||||
|
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory, scriptRoot));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Public overload for the Server-side bootstrapper + test consumers.</summary>
|
||||||
|
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
|
||||||
|
/// <param name="driverConfigJson">The merged driver config JSON (carries the <c>RawTags</c> array).</param>
|
||||||
|
/// <returns>The constructed <see cref="CalculationDriver"/>.</returns>
|
||||||
|
public static CalculationDriver CreateInstance(string driverInstanceId, string driverConfigJson)
|
||||||
|
=> CreateInstance(driverInstanceId, driverConfigJson, loggerFactory: null, scriptRoot: null);
|
||||||
|
|
||||||
|
/// <summary>Logger/script-logger-aware overload — used by <see cref="Register"/>'s closure via DI.</summary>
|
||||||
|
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
|
||||||
|
/// <param name="driverConfigJson">The merged driver config JSON (carries the <c>RawTags</c> array).</param>
|
||||||
|
/// <param name="loggerFactory">Optional logger factory for per-instance diagnostics loggers.</param>
|
||||||
|
/// <param name="scriptRoot">Optional root script logger for failure fan-out.</param>
|
||||||
|
/// <returns>The constructed <see cref="CalculationDriver"/>.</returns>
|
||||||
|
public static CalculationDriver CreateInstance(
|
||||||
|
string driverInstanceId, string driverConfigJson,
|
||||||
|
ILoggerFactory? loggerFactory, ScriptRootLogger? scriptRoot)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
||||||
|
|
||||||
|
var dto = JsonSerializer.Deserialize<CalculationDriverConfigDto>(driverConfigJson, JsonOptions)
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
$"Calculation driver config for '{driverInstanceId}' deserialised to null");
|
||||||
|
|
||||||
|
var options = new CalculationDriverOptions
|
||||||
|
{
|
||||||
|
RawTags = dto.RawTags is { Count: > 0 } ? [.. dto.RawTags] : [],
|
||||||
|
RunTimeout = dto.RunTimeoutMs is { } ms && ms > 0
|
||||||
|
? TimeSpan.FromMilliseconds(ms) : TimeSpan.FromSeconds(2),
|
||||||
|
};
|
||||||
|
|
||||||
|
return new CalculationDriver(
|
||||||
|
options, driverInstanceId, scriptRoot,
|
||||||
|
logger: loggerFactory?.CreateLogger<CalculationDriver>());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The merged-config DTO the calc factory binds from. The Calculation driver has no
|
||||||
|
/// backend/endpoint config — only the injected raw tags (+ an optional evaluation-timeout override).</summary>
|
||||||
|
internal sealed class CalculationDriverConfigDto
|
||||||
|
{
|
||||||
|
/// <summary>The driver's authored raw calc tags (RawPath + TagConfig blob + WriteIdempotent + DeviceName).</summary>
|
||||||
|
public List<RawTagEntry>? RawTags { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Optional per-script evaluation wall-clock budget override, in milliseconds (default 2000).</summary>
|
||||||
|
public int? RunTimeoutMs { get; init; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bound options for a <see cref="CalculationDriver"/> instance. The Calculation pseudo-driver has
|
||||||
|
/// no backend/endpoint config — its only input is the authored raw calc tags the deploy artifact
|
||||||
|
/// injects as the <see cref="RawTags"/> list (each carrying the tag's <c>scriptId</c> + resolved
|
||||||
|
/// <c>scriptSource</c> + trigger config inside its <see cref="RawTagEntry.TagConfig"/> blob).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CalculationDriverOptions
|
||||||
|
{
|
||||||
|
/// <summary>The driver's authored raw calc tags (RawPath + TagConfig blob + WriteIdempotent), from
|
||||||
|
/// the deploy artifact. Each is mapped to a <see cref="CalculationTagDefinition"/> at construction.</summary>
|
||||||
|
public IReadOnlyList<RawTagEntry> RawTags { get; init; } = Array.Empty<RawTagEntry>();
|
||||||
|
|
||||||
|
/// <summary>Per-script wall-clock evaluation budget handed to the <see cref="CalculationEvaluator"/>.
|
||||||
|
/// Defaults to 2 seconds (parity with the VirtualTag evaluator).</summary>
|
||||||
|
public TimeSpan RunTimeout { get; init; } = TimeSpan.FromSeconds(2);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Test-connect probe for the <c>Calculation</c> pseudo-driver. There is no backend to connect to —
|
||||||
|
/// the driver computes tags from other tags' values — so the probe just confirms the driver config
|
||||||
|
/// parses (mini-design §7). Per-script compile verification belongs to the editor diagnostics + the
|
||||||
|
/// tag editor's inline panel, not the probe (which receives only the driver config). Never throws;
|
||||||
|
/// returns Ok with negligible latency.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CalculationDriverProbe : IDriverProbe
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions Opts = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
||||||
|
Converters = { new JsonStringEnumConverter() },
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string DriverType => CalculationDriverFactoryExtensions.DriverTypeName;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(configJson))
|
||||||
|
return Task.FromResult(new DriverProbeResult(false, "Config JSON is empty.", null));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Parse-only: a Calculation driver's config is well-formed as long as it deserialises to an
|
||||||
|
// object (the RawTags array is optional — a driver with no calc tags is still valid).
|
||||||
|
_ = JsonSerializer.Deserialize<CalculationDriverFactoryExtensions.CalculationDriverConfigDto>(configJson, Opts);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Task.FromResult(new DriverProbeResult(false, $"Config JSON is invalid: {ex.Message}", null));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult(new DriverProbeResult(true, null, TimeSpan.Zero));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
|
||||||
|
using SerilogLogger = Serilog.ILogger;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// T0-4 — the Calculation driver's script evaluator. A calc tag is a raw tag whose value is
|
||||||
|
/// produced by a C# script over other tags' live values. This evaluator is a deliberate mirror of
|
||||||
|
/// the VirtualTag <c>RoslynVirtualTagEvaluator</c>: it compiles each unique source once via
|
||||||
|
/// <see cref="ScriptEvaluator{TContext, TResult}"/> (the Roslyn-backed sandbox), caches the
|
||||||
|
/// resulting evaluator keyed by source in a <see cref="CompiledScriptCache{TContext, TResult}"/>,
|
||||||
|
/// and runs steady-state evaluations as in-process method invocations against the dependency
|
||||||
|
/// dictionary — fast enough to run inline on the driver's dispatch. It reuses
|
||||||
|
/// <see cref="VirtualTagContext"/> verbatim so the Monaco editor, sandbox, and diagnostics pipeline
|
||||||
|
/// apply to calc scripts with no divergence.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>Single-tag mode.</b> A calc tag produces exactly one output value — its own — so cross-tag
|
||||||
|
/// <c>ctx.SetVirtualTag</c> writes are dropped (and logged at Debug). Fan-out between tags is owned
|
||||||
|
/// by the host's <c>DependencyMuxActor</c> (calc-of-calc chains re-enter the mux), never by the
|
||||||
|
/// eval engine, exactly as in the VirtualTag adapter.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Error surfacing (for WP7).</b> This evaluator never throws for script faults: compile error,
|
||||||
|
/// sandbox violation, runtime throw, and timeout all return <see cref="VirtualTagEvalResult.Failure"/>
|
||||||
|
/// with a descriptive <c>Reason</c>. A successful run returns <see cref="VirtualTagEvalResult.Ok"/>
|
||||||
|
/// carrying the computed value (which may be null). WP7's <c>CalculationDriver</c> maps a
|
||||||
|
/// <c>Failure</c> to Bad quality + a <c>ScriptLogEntry</c> and an <c>Ok</c> to the published value.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Cache management.</b> Implements <see cref="IScriptCacheOwner"/> so WP7 can drop compiled
|
||||||
|
/// scripts at each deploy generation whose source set changed — mirroring the VirtualTag adapter's
|
||||||
|
/// apply-boundary clear, which is what stops collectible <c>AssemblyLoadContext</c>s accreting across
|
||||||
|
/// script edits.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CalculationEvaluator : IScriptCacheOwner, IDisposable
|
||||||
|
{
|
||||||
|
// Source-hash-keyed compile cache: Lazy single-compile (no double-compile race) and Clear()/Dispose()
|
||||||
|
// that dispose each evaluator's collectible AssemblyLoadContext. ClearCompiledScripts() — driven by the
|
||||||
|
// WP7 driver per deploy generation — is what stops ALCs accreting across script edits.
|
||||||
|
private readonly CompiledScriptCache<VirtualTagContext, object?> _cache = new();
|
||||||
|
private readonly ILogger<CalculationEvaluator> _logger;
|
||||||
|
private readonly SerilogLogger _scriptRoot;
|
||||||
|
private readonly TimeSpan _runTimeout;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
/// <summary>Initializes a new <see cref="CalculationEvaluator"/>.</summary>
|
||||||
|
/// <param name="logger">Logger for host-side compilation/execution diagnostics.</param>
|
||||||
|
/// <param name="scriptRoot">Root script logger; user <c>ctx.Logger.*</c> output flows through this to the Script-log page.</param>
|
||||||
|
/// <param name="runTimeout">Wall-clock budget per script run; defaults to 2 seconds (parity with the VT evaluator).</param>
|
||||||
|
public CalculationEvaluator(
|
||||||
|
ILogger<CalculationEvaluator> logger,
|
||||||
|
ScriptRootLogger scriptRoot,
|
||||||
|
TimeSpan? runTimeout = null)
|
||||||
|
{
|
||||||
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
_scriptRoot = (scriptRoot ?? throw new ArgumentNullException(nameof(scriptRoot))).Logger;
|
||||||
|
_runTimeout = runTimeout ?? TimeSpan.FromSeconds(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Evaluate <paramref name="scriptSource"/> for the calc tag <paramref name="calcTagId"/> against
|
||||||
|
/// the snapshot in <paramref name="dependencies"/> (RawPath → last value). Never throws — script
|
||||||
|
/// faults are reported via <see cref="VirtualTagEvalResult.Failure"/>; a successful run returns
|
||||||
|
/// <see cref="VirtualTagEvalResult.Ok"/> carrying the single computed value.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="calcTagId">Identity of the calc tag (bound to the script log; in the live path equals the script id).</param>
|
||||||
|
/// <param name="scriptSource">The C# script source producing the tag's value.</param>
|
||||||
|
/// <param name="dependencies">Read-only snapshot of dependency values keyed by RawPath.</param>
|
||||||
|
public VirtualTagEvalResult Evaluate(string calcTagId, string scriptSource, IReadOnlyDictionary<string, object?> dependencies)
|
||||||
|
{
|
||||||
|
if (_disposed) return VirtualTagEvalResult.Failure("evaluator disposed");
|
||||||
|
if (string.IsNullOrWhiteSpace(scriptSource)) return VirtualTagEvalResult.Failure("empty expression");
|
||||||
|
|
||||||
|
// Passthrough fast-path: the mirror shape `return ctx.GetTag("X").Value;` needs no Roslyn.
|
||||||
|
// Semantics are byte-identical to the compiled mirror: a present dependency returns its value
|
||||||
|
// (Ok), an absent one makes GetTag yield a Bad snapshot whose .Value is null, so the script
|
||||||
|
// returns null — Ok(null) here too. Near-misses fall through to the compiled path.
|
||||||
|
if (PassthroughScript.TryMatch(scriptSource, out var passthroughRef))
|
||||||
|
{
|
||||||
|
return dependencies.TryGetValue(passthroughRef, out var ptValue)
|
||||||
|
? VirtualTagEvalResult.Ok(ptValue)
|
||||||
|
: VirtualTagEvalResult.Ok(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
var readCache = BuildReadCache(dependencies);
|
||||||
|
// Per-evaluation script logger: bind both ScriptId and CalcTagId (via the VirtualTagId slot, which
|
||||||
|
// the Script-log page attributes on) from the calc-tag id so each line is attributable.
|
||||||
|
var scriptLog = _scriptRoot
|
||||||
|
.ForContext(ScriptLoggerFactory.ScriptIdProperty, calcTagId)
|
||||||
|
.ForContext(ScriptLoggerFactory.VirtualTagIdProperty, calcTagId);
|
||||||
|
var context = new VirtualTagContext(
|
||||||
|
readCache,
|
||||||
|
// Single-tag mode: a calc tag has exactly one output (its own), so cross-tag writes are dropped.
|
||||||
|
setVirtualTag: (path, _) =>
|
||||||
|
_logger.LogDebug("Calculation {Id}: cross-tag write to {Path} dropped (single-tag evaluator)",
|
||||||
|
calcTagId, path),
|
||||||
|
logger: scriptLog);
|
||||||
|
|
||||||
|
// Fetch + run inside a bounded loop so an ObjectDisposedException caused by a concurrent
|
||||||
|
// ClearCompiledScripts (which disposed the evaluator between GetOrCompile and the run's
|
||||||
|
// disposed guard) re-fetches and retries exactly once. The loop runs at most twice.
|
||||||
|
for (var attempt = 0; ; attempt++)
|
||||||
|
{
|
||||||
|
ScriptEvaluator<VirtualTagContext, object?> evaluator;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
evaluator = _cache.GetOrCompile(scriptSource);
|
||||||
|
}
|
||||||
|
catch (CompilationErrorException ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Calculation {Id}: Roslyn compile failed", calcTagId);
|
||||||
|
return VirtualTagEvalResult.Failure($"compile error: {ex.Message}");
|
||||||
|
}
|
||||||
|
catch (ScriptSandboxViolationException ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Calculation {Id}: sandbox violation", calcTagId);
|
||||||
|
return VirtualTagEvalResult.Failure($"sandbox violation: {ex.Message}");
|
||||||
|
}
|
||||||
|
catch (ObjectDisposedException)
|
||||||
|
{
|
||||||
|
// Fetch-time disposal is a shutdown signal (the cache is tearing down), not the
|
||||||
|
// apply-boundary race — never retry.
|
||||||
|
return VirtualTagEvalResult.Failure("evaluator disposed");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Calculation {Id}: compile threw", calcTagId);
|
||||||
|
return VirtualTagEvalResult.Failure($"compile failure: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Route through TimedScriptEvaluator (Task.Run + WaitAsync): a raw CancellationToken can't
|
||||||
|
// interrupt a CPU-bound/infinite-loop Roslyn script, so the timed wrapper is what bounds a
|
||||||
|
// runaway script to the wall-clock budget instead of wedging the driver's dispatch.
|
||||||
|
var timed = new TimedScriptEvaluator<VirtualTagContext, object?>(evaluator, _runTimeout);
|
||||||
|
var raw = timed.RunAsync(context).GetAwaiter().GetResult();
|
||||||
|
return VirtualTagEvalResult.Ok(raw);
|
||||||
|
}
|
||||||
|
catch (ScriptTimeoutException)
|
||||||
|
{
|
||||||
|
return VirtualTagEvalResult.Failure($"script timed out after {_runTimeout.TotalSeconds:F1}s");
|
||||||
|
}
|
||||||
|
catch (ObjectDisposedException) when (!_disposed && attempt == 0)
|
||||||
|
{
|
||||||
|
// A concurrent ClearCompiledScripts disposed the evaluator between our GetOrCompile and the
|
||||||
|
// run's disposed-guard. Re-fetch (recompiling the same source into the CURRENT generation)
|
||||||
|
// and retry once. A second disposal mid-retry falls through to the failure path below.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
catch (ObjectDisposedException)
|
||||||
|
{
|
||||||
|
return VirtualTagEvalResult.Failure("evaluator disposed during evaluation");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Calculation {Id}: script execution threw", calcTagId);
|
||||||
|
return VirtualTagEvalResult.Failure($"script threw: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void ClearCompiledScripts() => _cache.Clear();
|
||||||
|
|
||||||
|
private static IReadOnlyDictionary<string, DataValueSnapshot> BuildReadCache(
|
||||||
|
IReadOnlyDictionary<string, object?> deps)
|
||||||
|
{
|
||||||
|
// VirtualTagContext.GetTag returns a DataValueSnapshot — wrap each raw dep value as Good-quality
|
||||||
|
// so the script's `(int)ctx.GetTag("a").Value` pattern works. Null values stay null.
|
||||||
|
var nowUtc = DateTime.UtcNow;
|
||||||
|
var cache = new Dictionary<string, DataValueSnapshot>(StringComparer.Ordinal);
|
||||||
|
foreach (var kv in deps)
|
||||||
|
{
|
||||||
|
cache[kv.Key] = new DataValueSnapshot(kv.Value, StatusCode: 0u, SourceTimestampUtc: nowUtc, ServerTimestampUtc: nowUtc);
|
||||||
|
}
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Disposes the evaluator, draining + disposing every cached compile's collectible ALC.</summary>
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
_disposed = true;
|
||||||
|
_cache.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation;
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A single authored calc tag as the driver runs it: its <see cref="RawPath"/> identity, the script
|
||||||
|
/// that computes it (<see cref="ScriptId"/> for attribution, <see cref="ScriptSource"/> for
|
||||||
|
/// evaluation), its trigger config (<see cref="ChangeTriggered"/> / <see cref="TimerInterval"/>), and
|
||||||
|
/// the static <see cref="DependencyRefs"/> extracted from the script's literal
|
||||||
|
/// <c>ctx.GetTag("…")</c> reads.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="RawPath">The tag's cluster-scoped RawPath — its identity + the calc-tag id passed to the evaluator.</param>
|
||||||
|
/// <param name="ScriptId">Logical FK to the shared <c>Script</c> entity — bound to the failure script-log for attribution.</param>
|
||||||
|
/// <param name="ScriptSource">The resolved C# script source that produces the tag's value; empty when unresolved.</param>
|
||||||
|
/// <param name="ChangeTriggered">Re-evaluate whenever a declared dependency changes.</param>
|
||||||
|
/// <param name="TimerInterval">Optional periodic re-evaluation cadence; null ⇒ change-trigger only.</param>
|
||||||
|
/// <param name="DependencyRefs">Distinct literal <c>ctx.GetTag</c> RawPaths this tag reads (first-seen order).</param>
|
||||||
|
public sealed record CalculationTagDefinition(
|
||||||
|
string RawPath,
|
||||||
|
string ScriptId,
|
||||||
|
string ScriptSource,
|
||||||
|
bool ChangeTriggered,
|
||||||
|
TimeSpan? TimerInterval,
|
||||||
|
IReadOnlyList<string> DependencyRefs)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Map a calc tag's <c>TagConfig</c> blob (<c>{ "scriptId", "scriptSource", "changeTriggered",
|
||||||
|
/// "timerIntervalMs" }</c>) + its RawPath into a <see cref="CalculationTagDefinition"/>. Returns
|
||||||
|
/// <see langword="false"/> (and leaves <paramref name="def"/> null) only when the blob has no
|
||||||
|
/// <c>scriptId</c> — a tag with no identifiable script cannot be a calc tag. A present
|
||||||
|
/// <c>scriptId</c> with an empty/absent <c>scriptSource</c> still maps (the driver logs it and the
|
||||||
|
/// tag never computes). Never throws — malformed JSON yields a miss.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="tagConfig">The calc tag's schemaless <c>TagConfig</c> JSON.</param>
|
||||||
|
/// <param name="rawPath">The tag's RawPath identity.</param>
|
||||||
|
/// <param name="def">The mapped definition when this returns <see langword="true"/>.</param>
|
||||||
|
/// <returns><see langword="true"/> when a definition (with a scriptId) was mapped.</returns>
|
||||||
|
public static bool TryFromTagConfig(string tagConfig, string rawPath, out CalculationTagDefinition def)
|
||||||
|
{
|
||||||
|
def = null!;
|
||||||
|
if (string.IsNullOrWhiteSpace(tagConfig)) return false;
|
||||||
|
|
||||||
|
string? scriptId;
|
||||||
|
string scriptSource;
|
||||||
|
bool changeTriggered;
|
||||||
|
int? timerMs;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(tagConfig);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
if (root.ValueKind != JsonValueKind.Object) return false;
|
||||||
|
|
||||||
|
scriptId = root.TryGetProperty("scriptId", out var sidEl) && sidEl.ValueKind == JsonValueKind.String
|
||||||
|
? sidEl.GetString() : null;
|
||||||
|
if (string.IsNullOrWhiteSpace(scriptId)) return false;
|
||||||
|
|
||||||
|
scriptSource = root.TryGetProperty("scriptSource", out var srcEl) && srcEl.ValueKind == JsonValueKind.String
|
||||||
|
? srcEl.GetString() ?? string.Empty : string.Empty;
|
||||||
|
|
||||||
|
// changeTriggered defaults to true (mini-design §2) — only an explicit false disables it.
|
||||||
|
changeTriggered = !(root.TryGetProperty("changeTriggered", out var ctEl)
|
||||||
|
&& ctEl.ValueKind == JsonValueKind.False);
|
||||||
|
|
||||||
|
timerMs = root.TryGetProperty("timerIntervalMs", out var tEl)
|
||||||
|
&& tEl.ValueKind == JsonValueKind.Number && tEl.TryGetInt32(out var ms) && ms > 0
|
||||||
|
? ms : null;
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var deps = EquipmentScriptPaths.ExtractDependencyRefs(scriptSource);
|
||||||
|
def = new CalculationTagDefinition(
|
||||||
|
RawPath: rawPath,
|
||||||
|
ScriptId: scriptId!,
|
||||||
|
ScriptSource: scriptSource,
|
||||||
|
ChangeTriggered: changeTriggered,
|
||||||
|
TimerInterval: timerMs is { } m ? TimeSpan.FromMilliseconds(m) : null,
|
||||||
|
DependencyRefs: deps);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Periodic re-evaluation scheduler for calc tags carrying a <c>timerIntervalMs</c>. A deliberate
|
||||||
|
/// mirror of the VirtualTag <c>TimerTriggerScheduler</c> (<c>Core.VirtualTags</c>): one
|
||||||
|
/// <see cref="System.Threading.Timer"/> per distinct interval group (so the wire count stays low
|
||||||
|
/// regardless of tag count) with a per-group in-flight flag that skips a tick when the prior tick for
|
||||||
|
/// the same group is still running. Revived <b>inside the driver</b> because the VT scheduler is welded
|
||||||
|
/// to <c>VirtualTagEngine.EvaluateOneAsync</c>; here each tick invokes the driver's own evaluate
|
||||||
|
/// callback per tag in the group.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class CalculationTimerScheduler : IDisposable
|
||||||
|
{
|
||||||
|
private readonly Action<string> _evaluate;
|
||||||
|
private readonly ILogger _logger;
|
||||||
|
private readonly List<Timer> _timers = [];
|
||||||
|
private readonly List<TickGroup> _groups = [];
|
||||||
|
private readonly CancellationTokenSource _cts = new();
|
||||||
|
private long _skippedTickCount;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
/// <summary>Initializes a new instance of the <see cref="CalculationTimerScheduler"/> class.</summary>
|
||||||
|
/// <param name="evaluate">Callback invoked with a calc tag's RawPath when its timer group ticks.</param>
|
||||||
|
/// <param name="logger">Logger for diagnostics.</param>
|
||||||
|
public CalculationTimerScheduler(Action<string> evaluate, ILogger logger)
|
||||||
|
{
|
||||||
|
_evaluate = evaluate ?? throw new ArgumentNullException(nameof(evaluate));
|
||||||
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Number of timer callbacks that skipped their work because the prior tick for the same
|
||||||
|
/// group was still running. Monotonic; exposed for tests + operational metrics.</summary>
|
||||||
|
public long SkippedTickCount => Interlocked.Read(ref _skippedTickCount);
|
||||||
|
|
||||||
|
/// <summary>Stand up one <see cref="Timer"/> per distinct interval. All tags sharing an interval share
|
||||||
|
/// a timer; each tick re-evaluates every tag in the group.</summary>
|
||||||
|
/// <param name="tags">The calc tags to schedule (only those with a positive <see cref="CalculationTagDefinition.TimerInterval"/> are scheduled).</param>
|
||||||
|
public void Start(IEnumerable<CalculationTagDefinition> tags)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(tags);
|
||||||
|
if (_disposed) throw new ObjectDisposedException(nameof(CalculationTimerScheduler));
|
||||||
|
|
||||||
|
var byInterval = tags
|
||||||
|
.Where(t => t.TimerInterval is { } iv && iv > TimeSpan.Zero)
|
||||||
|
.GroupBy(t => t.TimerInterval!.Value);
|
||||||
|
|
||||||
|
foreach (var group in byInterval)
|
||||||
|
{
|
||||||
|
var paths = group.Select(t => t.RawPath).ToArray();
|
||||||
|
var interval = group.Key;
|
||||||
|
var ctx = new TickGroup(paths);
|
||||||
|
_groups.Add(ctx);
|
||||||
|
var timer = new Timer(_ => OnTimer(ctx), null, interval, interval);
|
||||||
|
_timers.Add(timer);
|
||||||
|
_logger.LogInformation("CalculationTimerScheduler: {TagCount} tag(s) on {Interval} cadence",
|
||||||
|
paths.Length, interval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnTimer(TickGroup ctx)
|
||||||
|
{
|
||||||
|
if (_cts.IsCancellationRequested) return;
|
||||||
|
|
||||||
|
// Skip the tick when the prior one for this group is still running — bounds the outstanding
|
||||||
|
// work to one tick per group regardless of how long an evaluation takes.
|
||||||
|
if (Interlocked.CompareExchange(ref ctx.InFlight, 1, 0) != 0)
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref _skippedTickCount);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var path in ctx.Paths)
|
||||||
|
{
|
||||||
|
if (_cts.IsCancellationRequested) return;
|
||||||
|
try { _evaluate(path); }
|
||||||
|
catch (Exception ex) { _logger.LogError(ex, "CalculationTimerScheduler evaluate failed for {Path}", path); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Interlocked.Exchange(ref ctx.InFlight, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Releases all timers and disposes the scheduler's resources.</summary>
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
_disposed = true;
|
||||||
|
_cts.Cancel();
|
||||||
|
foreach (var t in _timers)
|
||||||
|
{
|
||||||
|
try { t.Dispose(); } catch { /* best-effort teardown */ }
|
||||||
|
}
|
||||||
|
_timers.Clear();
|
||||||
|
_groups.Clear();
|
||||||
|
_cts.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TickGroup(IReadOnlyList<string> paths)
|
||||||
|
{
|
||||||
|
// 0 = idle, 1 = a tick is currently running for this group.
|
||||||
|
public int InFlight;
|
||||||
|
public IReadOnlyList<string> Paths { get; } = paths;
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
|
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||||
|
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Calculation</RootNamespace>
|
||||||
|
<AssemblyName>ZB.MOM.WW.OtOpcUa.Driver.Calculation</AssemblyName>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- Scripting infra shared with the VirtualTag Roslyn evaluator. Core.Scripting brings
|
||||||
|
CompiledScriptCache / TimedScriptEvaluator / ScriptEvaluator / the sandbox exceptions /
|
||||||
|
ScriptRootLogger + ScriptLoggerFactory (and, transitively, Microsoft.CodeAnalysis
|
||||||
|
for CompilationErrorException). Core.Scripting.Abstractions owns VirtualTagContext +
|
||||||
|
PassthroughScript; Commons owns VirtualTagEvalResult + IScriptCacheOwner; Core.Abstractions
|
||||||
|
owns DataValueSnapshot. -->
|
||||||
|
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||||
|
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj"/>
|
||||||
|
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
||||||
|
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions.csproj"/>
|
||||||
|
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Scripting\ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging.Abstractions;
|
|||||||
using ZB.MOM.WW.MxGateway.Client;
|
using ZB.MOM.WW.MxGateway.Client;
|
||||||
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser;
|
||||||
|
|
||||||
@@ -34,11 +35,14 @@ public sealed class GalaxyDriverBrowser : IDriverBrowser
|
|||||||
};
|
};
|
||||||
|
|
||||||
private readonly ILogger<GalaxyDriverBrowser> _logger;
|
private readonly ILogger<GalaxyDriverBrowser> _logger;
|
||||||
|
private readonly ISecretResolver _secretResolver;
|
||||||
|
|
||||||
/// <summary>Creates a new browser. Logger defaults to <see cref="NullLogger{T}"/>.</summary>
|
/// <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>
|
/// <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;
|
_logger = logger ?? NullLogger<GalaxyDriverBrowser>.Instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +72,7 @@ public sealed class GalaxyDriverBrowser : IDriverBrowser
|
|||||||
if (string.IsNullOrWhiteSpace(opts.MxAccess.ClientName))
|
if (string.IsNullOrWhiteSpace(opts.MxAccess.ClientName))
|
||||||
throw new InvalidOperationException("Galaxy browser requires 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
|
// 30s wall-clock budget for the connect phase, linked to the caller's token so
|
||||||
// an AdminUI cancel still wins early.
|
// 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
|
/// 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
|
/// 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
|
/// 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 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>
|
/// </summary>
|
||||||
private MxGatewayClientOptions BuildClientOptions(GalaxyGatewayOptions gw) => new()
|
private async Task<MxGatewayClientOptions> BuildClientOptionsAsync(
|
||||||
|
GalaxyGatewayOptions gw, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
Endpoint = new Uri(gw.Endpoint, UriKind.Absolute),
|
var apiKey = await GalaxySecretRef
|
||||||
ApiKey = GalaxySecretRef.ResolveApiKey(gw.ApiKeySecretRef, _logger),
|
.ResolveApiKeyAsync(gw.ApiKeySecretRef, _secretResolver, _logger, cancellationToken)
|
||||||
UseTls = gw.UseTls,
|
.ConfigureAwait(false);
|
||||||
CaCertificatePath = gw.CaCertificatePath,
|
return new MxGatewayClientOptions
|
||||||
ConnectTimeout = TimeSpan.FromSeconds(gw.ConnectTimeoutSeconds),
|
{
|
||||||
DefaultCallTimeout = TimeSpan.FromSeconds(gw.DefaultCallTimeoutSeconds),
|
Endpoint = new Uri(gw.Endpoint, UriKind.Absolute),
|
||||||
StreamTimeout = gw.StreamTimeoutSeconds > 0
|
ApiKey = apiKey,
|
||||||
? TimeSpan.FromSeconds(gw.StreamTimeoutSeconds)
|
UseTls = gw.UseTls,
|
||||||
: null,
|
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>
|
/// <summary>
|
||||||
/// Connection details for the MxAccess gateway. <see cref="ApiKeySecretRef"/> is
|
/// 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:
|
/// supported, in priority order:
|
||||||
/// <list type="bullet">
|
/// <list type="bullet">
|
||||||
/// <item><c>env:NAME</c> — read from an environment variable (recommended for
|
/// <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>
|
/// 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>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;
|
/// <item><c>dev:KEY</c> — explicit cleartext opt-in for dev rigs / parity tests;
|
||||||
/// no startup warning.</item>
|
/// no startup warning.</item>
|
||||||
/// <item>Anything else — treated as a literal cleartext API key for back-compat.
|
/// <item>Anything else — treated as a literal cleartext API key for back-compat.
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||||
|
|
||||||
/// <summary>
|
/// <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:
|
/// forms supported, evaluated in order:
|
||||||
/// <list type="number">
|
/// <list type="number">
|
||||||
/// <item><c>env:NAME</c> — reads <c>Environment.GetEnvironmentVariable(NAME)</c>.
|
/// <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
|
/// <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
|
/// 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>
|
/// 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
|
/// <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
|
/// configs that pre-date this resolver. When a logger is supplied the
|
||||||
/// resolver emits a startup warning so an operator who accidentally
|
/// resolver emits a startup warning so an operator who accidentally
|
||||||
/// committed a cleartext key sees it.</item>
|
/// committed a cleartext key sees it.</item>
|
||||||
/// </list>
|
/// </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.
|
/// changing the call site.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -31,18 +37,26 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
|||||||
public static class GalaxySecretRef
|
public static class GalaxySecretRef
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Resolves the supplied secret reference. When the ref falls through to the
|
/// Resolves the supplied secret reference. The <c>secret:NAME</c> arm resolves
|
||||||
/// back-compat literal arm (an unprefixed cleartext API key in
|
/// through <paramref name="resolver"/> and is fail-closed (throws when the secret
|
||||||
/// <c>DriverConfig</c> JSON) and a <paramref name="logger"/> is supplied, emits
|
/// is absent). When the ref falls through to the back-compat literal arm (an
|
||||||
/// a <see cref="LogLevel.Warning"/>. The <c>dev:</c> prefix is the explicit
|
/// unprefixed cleartext API key in <c>DriverConfig</c> JSON) and a
|
||||||
/// opt-in path that doesn't warn.
|
/// <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>
|
/// </summary>
|
||||||
/// <param name="secretRef">The secret reference string to resolve.</param>
|
/// <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="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>
|
/// <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);
|
ArgumentException.ThrowIfNullOrEmpty(secretRef);
|
||||||
|
ArgumentNullException.ThrowIfNull(resolver);
|
||||||
|
|
||||||
if (secretRef.StartsWith("env:", StringComparison.OrdinalIgnoreCase))
|
if (secretRef.StartsWith("env:", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
@@ -76,6 +90,19 @@ public static class GalaxySecretRef
|
|||||||
return secretRef[4..];
|
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
|
// 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
|
// 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
|
// 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" />
|
<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. -->
|
<!-- Logging abstraction needed by GalaxySecretRef.ResolveApiKey's optional warning logger. -->
|
||||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||||
|
<PackageReference Include="ZB.MOM.WW.Secrets.Abstractions" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</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.Config;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Health;
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Health;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy;
|
||||||
|
|
||||||
@@ -43,6 +44,12 @@ public sealed class GalaxyDriver
|
|||||||
private readonly GalaxyDriverOptions _options;
|
private readonly GalaxyDriverOptions _options;
|
||||||
private readonly ILogger<GalaxyDriver> _logger;
|
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
|
// PR 4.1 — IGalaxyHierarchySource is the test seam for browse. When null, the driver
|
||||||
// lazily builds a GatewayGalaxyHierarchySource around a GalaxyRepositoryClient on
|
// lazily builds a GatewayGalaxyHierarchySource around a GalaxyRepositoryClient on
|
||||||
// first DiscoverAsync. Tests inject a fake source via the internal ctor to exercise
|
// 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>
|
/// <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="driverInstanceId">The unique identifier for this driver instance.</param>
|
||||||
/// <param name="options">The Galaxy driver configuration options.</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>
|
/// <param name="logger">Optional logger instance for diagnostics.</param>
|
||||||
public GalaxyDriver(
|
public GalaxyDriver(
|
||||||
string driverInstanceId,
|
string driverInstanceId,
|
||||||
GalaxyDriverOptions options,
|
GalaxyDriverOptions options,
|
||||||
|
ISecretResolver secretResolver,
|
||||||
ILogger<GalaxyDriver>? logger = null)
|
ILogger<GalaxyDriver>? logger = null)
|
||||||
: this(driverInstanceId, options,
|
: this(driverInstanceId, options,
|
||||||
hierarchySource: null, dataReader: null, dataWriter: null, subscriber: null,
|
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="alarmAcknowledger">Optional custom alarm acknowledger for testing.</param>
|
||||||
/// <param name="alarmFeed">Optional custom alarm feed for testing.</param>
|
/// <param name="alarmFeed">Optional custom alarm feed for testing.</param>
|
||||||
/// <param name="logger">Optional logger instance for diagnostics.</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(
|
internal GalaxyDriver(
|
||||||
string driverInstanceId,
|
string driverInstanceId,
|
||||||
GalaxyDriverOptions options,
|
GalaxyDriverOptions options,
|
||||||
@@ -182,13 +197,15 @@ public sealed class GalaxyDriver
|
|||||||
IGalaxySubscriber? subscriber = null,
|
IGalaxySubscriber? subscriber = null,
|
||||||
IGalaxyAlarmAcknowledger? alarmAcknowledger = null,
|
IGalaxyAlarmAcknowledger? alarmAcknowledger = null,
|
||||||
IGalaxyAlarmFeed? alarmFeed = null,
|
IGalaxyAlarmFeed? alarmFeed = null,
|
||||||
ILogger<GalaxyDriver>? logger = null)
|
ILogger<GalaxyDriver>? logger = null,
|
||||||
|
ISecretResolver? secretResolver = null)
|
||||||
{
|
{
|
||||||
_driverInstanceId = !string.IsNullOrWhiteSpace(driverInstanceId)
|
_driverInstanceId = !string.IsNullOrWhiteSpace(driverInstanceId)
|
||||||
? driverInstanceId
|
? driverInstanceId
|
||||||
: throw new ArgumentException("Driver instance id required.", nameof(driverInstanceId));
|
: throw new ArgumentException("Driver instance id required.", nameof(driverInstanceId));
|
||||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||||
_logger = logger ?? NullLogger<GalaxyDriver>.Instance;
|
_logger = logger ?? NullLogger<GalaxyDriver>.Instance;
|
||||||
|
_secretResolver = secretResolver ?? NullSecretResolver.Instance;
|
||||||
_hierarchySource = hierarchySource;
|
_hierarchySource = hierarchySource;
|
||||||
_dataReader = dataReader;
|
_dataReader = dataReader;
|
||||||
_dataWriter = dataWriter;
|
_dataWriter = dataWriter;
|
||||||
@@ -316,7 +333,7 @@ public sealed class GalaxyDriver
|
|||||||
_driverInstanceId);
|
_driverInstanceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
StartDeployWatcher();
|
await StartDeployWatcherAsync(cancellationToken).ConfigureAwait(false);
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"GalaxyDriver {InstanceId} initialized — endpoint={Endpoint} clientName={ClientName}",
|
"GalaxyDriver {InstanceId} initialized — endpoint={Endpoint} clientName={ClientName}",
|
||||||
_driverInstanceId, _options.Gateway.Endpoint, _options.MxAccess.ClientName);
|
_driverInstanceId, _options.Gateway.Endpoint, _options.MxAccess.ClientName);
|
||||||
@@ -331,7 +348,7 @@ public sealed class GalaxyDriver
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task BuildProductionRuntimeAsync(CancellationToken cancellationToken)
|
private async Task BuildProductionRuntimeAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var clientOptions = BuildClientOptions(_options.Gateway);
|
var clientOptions = await BuildClientOptionsAsync(_options.Gateway, cancellationToken).ConfigureAwait(false);
|
||||||
_ownedMxClient = MxGatewayClient.Create(clientOptions);
|
_ownedMxClient = MxGatewayClient.Create(clientOptions);
|
||||||
_ownedMxSession = new GalaxyMxSession(_options.MxAccess, _logger);
|
_ownedMxSession = new GalaxyMxSession(_options.MxAccess, _logger);
|
||||||
await _ownedMxSession.ConnectAsync(clientOptions, cancellationToken).ConfigureAwait(false);
|
await _ownedMxSession.ConnectAsync(clientOptions, cancellationToken).ConfigureAwait(false);
|
||||||
@@ -386,7 +403,7 @@ public sealed class GalaxyDriver
|
|||||||
private async Task ReopenAsync(CancellationToken cancellationToken)
|
private async Task ReopenAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (_ownedMxSession is null) return;
|
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);
|
await _ownedMxSession.RecreateAsync(clientOptions, cancellationToken).ConfigureAwait(false);
|
||||||
// The recreated session invalidates every prior gw item handle; drop the writer's handle/advise
|
// 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.
|
// 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),
|
// Resolve the API-key ref BEFORE the object initializer — the secret: arm is
|
||||||
// Pass the logger so the literal-arm cleartext fallback surfaces a startup
|
// async and you can't await inside an initializer. Pass the logger so the
|
||||||
// warning rather than silently shipping the key. The resolver lives in
|
// literal-arm cleartext fallback surfaces a startup warning rather than
|
||||||
// Driver.Galaxy.Contracts (GalaxySecretRef) so the runtime driver and the
|
// silently shipping the key. The resolver lives in Driver.Galaxy.Contracts
|
||||||
// AdminUI browser share one implementation.
|
// (GalaxySecretRef) so the runtime driver and the AdminUI browser share one
|
||||||
ApiKey = GalaxySecretRef.ResolveApiKey(gw.ApiKeySecretRef, _logger),
|
// implementation; the secret: arm resolves through the shared ISecretResolver.
|
||||||
UseTls = gw.UseTls,
|
var apiKey = await GalaxySecretRef
|
||||||
CaCertificatePath = gw.CaCertificatePath,
|
.ResolveApiKeyAsync(gw.ApiKeySecretRef, _secretResolver, _logger, cancellationToken)
|
||||||
ConnectTimeout = TimeSpan.FromSeconds(gw.ConnectTimeoutSeconds),
|
.ConfigureAwait(false);
|
||||||
DefaultCallTimeout = TimeSpan.FromSeconds(gw.DefaultCallTimeoutSeconds),
|
return new MxGatewayClientOptions
|
||||||
StreamTimeout = gw.StreamTimeoutSeconds > 0 ? TimeSpan.FromSeconds(gw.StreamTimeoutSeconds) : null,
|
{
|
||||||
};
|
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 (!_options.Repository.WatchDeployEvents) return;
|
||||||
if (_ownedRepositoryClient is null && _hierarchySource is null) return;
|
if (_ownedRepositoryClient is null && _hierarchySource is null) return;
|
||||||
|
|
||||||
// Reuse the lazily-built repository client (DiscoverAsync constructs it on demand).
|
// 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.
|
// 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
|
// Build under a null-check (not ??=) so if BuildDefaultHierarchySourceAsync later
|
||||||
// rather than overwriting the field and leaking the first instance.
|
// 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(
|
_ownedRepositoryClient ??= ZB.MOM.WW.MxGateway.Client.GalaxyRepositoryClient.Create(
|
||||||
BuildClientOptions(_options.Gateway));
|
await BuildClientOptionsAsync(_options.Gateway, cancellationToken).ConfigureAwait(false));
|
||||||
|
|
||||||
var source = new GatewayGalaxyDeployWatchSource(_ownedRepositoryClient);
|
var source = new GatewayGalaxyDeployWatchSource(_ownedRepositoryClient);
|
||||||
_deployWatcher = new DeployWatcher(source, _logger);
|
_deployWatcher = new DeployWatcher(source, _logger);
|
||||||
@@ -691,7 +718,8 @@ public sealed class GalaxyDriver
|
|||||||
// After discovery, SyncPlatformsAsync refreshes the probe watcher's membership so
|
// After discovery, SyncPlatformsAsync refreshes the probe watcher's membership so
|
||||||
// newly added $WinPlatform / $AppEngine objects start advising their ScanState attribute.
|
// newly added $WinPlatform / $AppEngine objects start advising their ScanState attribute.
|
||||||
var capturingBuilder = new SecurityCapturingBuilder(builder, _securityByFullRef);
|
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
|
// 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
|
// its node reference (FullName) — the security map SecurityCapturingBuilder captures is then
|
||||||
// keyed by RawPath, matching what WriteAsync resolves against.
|
// 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
|
/// <see cref="Dispose"/>. Tests bypass this by injecting their own source via the
|
||||||
/// internal ctor.
|
/// internal ctor.
|
||||||
/// </summary>
|
/// </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
|
// than always overwriting the field and leaking the first instance. Both paths
|
||||||
// produce equivalent clients from the same options.
|
// produce equivalent clients from the same options. The client-options build is
|
||||||
_ownedRepositoryClient ??= GalaxyRepositoryClient.Create(BuildClientOptions(_options.Gateway));
|
// async now (secret: arm resolves through the shared ISecretResolver).
|
||||||
|
_ownedRepositoryClient ??= GalaxyRepositoryClient.Create(
|
||||||
|
await BuildClientOptionsAsync(_options.Gateway, cancellationToken).ConfigureAwait(false));
|
||||||
return new TracedGalaxyHierarchySource(
|
return new TracedGalaxyHierarchySource(
|
||||||
new GatewayGalaxyHierarchySource(_ownedRepositoryClient), _options.MxAccess.ClientName);
|
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.Abstractions;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy;
|
||||||
|
|
||||||
@@ -23,30 +24,45 @@ public static class GalaxyDriverFactoryExtensions
|
|||||||
{
|
{
|
||||||
public const string DriverTypeName = "GalaxyMxGateway";
|
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="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>
|
/// <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);
|
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="driverInstanceId">The unique identifier for the driver instance.</param>
|
||||||
/// <param name="driverConfigJson">The driver configuration in JSON format.</param>
|
/// <param name="driverConfigJson">The driver configuration in JSON format.</param>
|
||||||
/// <returns>The constructed Galaxy driver instance.</returns>
|
/// <returns>The constructed Galaxy driver instance.</returns>
|
||||||
public static GalaxyDriver CreateInstance(string driverInstanceId, string driverConfigJson)
|
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="driverInstanceId">The unique identifier for the driver instance.</param>
|
||||||
/// <param name="driverConfigJson">The driver configuration in JSON format.</param>
|
/// <param name="driverConfigJson">The driver configuration in JSON format.</param>
|
||||||
/// <param name="loggerFactory">The optional logger factory for creating drivers.</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>
|
/// <returns>The constructed Galaxy driver instance.</returns>
|
||||||
public static GalaxyDriver CreateInstance(
|
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(driverInstanceId);
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
||||||
|
|
||||||
@@ -88,7 +104,7 @@ public static class GalaxyDriverFactoryExtensions
|
|||||||
RawTags = dto.RawTags ?? [],
|
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()
|
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
|
/// exposes PLC data" flow. Tier A (pure managed, OPC Foundation reference SDK); universal
|
||||||
/// protections cover it.
|
/// protections cover it.
|
||||||
/// </remarks>
|
/// </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>
|
/// <summary>
|
||||||
/// Remote OPC UA endpoint URL, e.g. <c>opc.tcp://plc.internal:4840</c>. Convenience
|
/// 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.Client;
|
||||||
using Opc.Ua.Configuration;
|
using Opc.Ua.Configuration;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
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="options">Driver configuration.</param>
|
||||||
/// <param name="driverInstanceId">Stable logical ID from the config DB.</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="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,
|
public OpcUaClientDriver(OpcUaClientDriverOptions options, string driverInstanceId,
|
||||||
ILogger<OpcUaClientDriver>? logger = null)
|
ILogger<OpcUaClientDriver>? logger = null, ISecretResolver? secretResolver = null)
|
||||||
{
|
{
|
||||||
_options = options;
|
_options = options;
|
||||||
_driverInstanceId = driverInstanceId;
|
_driverInstanceId = driverInstanceId;
|
||||||
_logger = logger ?? NullLogger<OpcUaClientDriver>.Instance;
|
_logger = logger ?? NullLogger<OpcUaClientDriver>.Instance;
|
||||||
|
_secretResolver = secretResolver ?? NullSecretResolver.Instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly OpcUaClientDriverOptions _options;
|
private readonly OpcUaClientDriverOptions _options;
|
||||||
|
private readonly ISecretResolver _secretResolver;
|
||||||
private readonly string _driverInstanceId;
|
private readonly string _driverInstanceId;
|
||||||
// ---- IAlarmSource state ----
|
// ---- IAlarmSource state ----
|
||||||
|
|
||||||
@@ -159,7 +170,18 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
|||||||
var appConfig = await BuildApplicationConfigurationAsync(cancellationToken).ConfigureAwait(false);
|
var appConfig = await BuildApplicationConfigurationAsync(cancellationToken).ConfigureAwait(false);
|
||||||
var candidates = ResolveEndpointCandidates(_options);
|
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
|
// 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
|
// 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 System.Text.Json.Serialization;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||||
|
|
||||||
@@ -31,22 +32,37 @@ public static class OpcUaClientDriverFactoryExtensions
|
|||||||
Converters = { new JsonStringEnumConverter() },
|
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="registry">The driver factory registry to register with.</param>
|
||||||
/// <param name="loggerFactory">Optional logger factory used to create per-instance loggers.</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);
|
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>
|
/// <summary>Public for the Server-side bootstrapper + test consumers.</summary>
|
||||||
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
|
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
|
||||||
/// <param name="driverConfigJson">The JSON configuration string for the driver.</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="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>
|
/// <returns>A configured <see cref="OpcUaClientDriver"/>.</returns>
|
||||||
public static OpcUaClientDriver CreateInstance(
|
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(driverInstanceId);
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
||||||
@@ -55,6 +71,8 @@ public static class OpcUaClientDriverFactoryExtensions
|
|||||||
?? throw new InvalidOperationException(
|
?? throw new InvalidOperationException(
|
||||||
$"OpcUaClient driver config for '{driverInstanceId}' deserialised to null");
|
$"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 />
|
/// <inheritdoc />
|
||||||
public async Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
|
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;
|
OpcUaClientDriverOptions? opts;
|
||||||
try { opts = JsonSerializer.Deserialize<OpcUaClientDriverOptions>(configJson, _opts); }
|
try { opts = JsonSerializer.Deserialize<OpcUaClientDriverOptions>(configJson, _opts); }
|
||||||
catch (Exception ex) { return new(false, $"Config JSON is invalid: {ex.Message}", null); }
|
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>
|
<ItemGroup>
|
||||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client"/>
|
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client"/>
|
||||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Configuration"/>
|
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Configuration"/>
|
||||||
|
<PackageReference Include="ZB.MOM.WW.Secrets.Abstractions"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Browsing;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Payload for a leaf toggle in <c>DriverBrowseTree</c>'s multi-select mode (WP6 "Browse device…"
|
||||||
|
/// re-target). Carries the toggled <see cref="Leaf"/> plus the captured browse-folder nesting above it
|
||||||
|
/// (<see cref="FolderPath"/>, root→parent display names) so the browse modal can optionally mirror the
|
||||||
|
/// nesting onto TagGroups on commit. Fired only for <see cref="BrowseNodeKind.Leaf"/> nodes.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Leaf">The toggled leaf browse node (its <c>NodeId</c> is the driver full reference).</param>
|
||||||
|
/// <param name="FolderPath">The ancestor folder display names, root→parent (empty for a top-level leaf).</param>
|
||||||
|
public sealed record BrowseLeafSelection(BrowseNode Leaf, IReadOnlyList<string> FolderPath);
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
<HeadOutlet/>
|
<HeadOutlet/>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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>
|
<script src="_content/ZB.MOM.WW.OtOpcUa.AdminUI/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||||
<ThemeScripts />
|
<ThemeScripts />
|
||||||
<script src="_content/ZB.MOM.WW.OtOpcUa.AdminUI/js/monaco-init.js"></script>
|
<script src="_content/ZB.MOM.WW.OtOpcUa.AdminUI/js/monaco-init.js"></script>
|
||||||
|
|||||||
@@ -16,9 +16,11 @@
|
|||||||
<NavRailItem Href="/hosts" Text="Host status" />
|
<NavRailItem Href="/hosts" Text="Host status" />
|
||||||
<NavRailItem Href="/clusters" Text="Clusters" />
|
<NavRailItem Href="/clusters" Text="Clusters" />
|
||||||
<NavRailItem Href="/uns" Text="UNS" />
|
<NavRailItem Href="/uns" Text="UNS" />
|
||||||
|
<NavRailItem Href="/raw" Text="Raw" />
|
||||||
<NavRailItem Href="/reservations" Text="Reservations" />
|
<NavRailItem Href="/reservations" Text="Reservations" />
|
||||||
<NavRailItem Href="/certificates" Text="Certificates" />
|
<NavRailItem Href="/certificates" Text="Certificates" />
|
||||||
<NavRailItem Href="/role-grants" Text="Role grants" />
|
<NavRailItem Href="/role-grants" Text="Role grants" />
|
||||||
|
<NavRailItem Href="/admin/secrets" Text="Secrets" />
|
||||||
</NavRailSection>
|
</NavRailSection>
|
||||||
<NavRailSection Title="Scripting" Key="scripting">
|
<NavRailSection Title="Scripting" Key="scripting">
|
||||||
<NavRailItem Href="/scripts" Text="Scripts" />
|
<NavRailItem Href="/scripts" Text="Scripts" />
|
||||||
|
|||||||
@@ -64,7 +64,22 @@ else
|
|||||||
<tr>
|
<tr>
|
||||||
<td><span class="mono small">@e.TimestampUtc.ToString("HH:mm:ss.fff")</span></td>
|
<td><span class="mono small">@e.TimestampUtc.ToString("HH:mm:ss.fff")</span></td>
|
||||||
<td><span class="mono">@e.AlarmId</span><div class="text-muted small">@e.AlarmName</div></td>
|
<td><span class="mono">@e.AlarmId</span><div class="text-muted small">@e.AlarmName</div></td>
|
||||||
<td><span class="mono small">@e.EquipmentPath</span></td>
|
<td>
|
||||||
|
<span class="mono small">@e.EquipmentPath</span>
|
||||||
|
@* v3 Batch 4 (multi-notifier native alarms): one condition row carries the list
|
||||||
|
of referencing equipment (Area/Line/Equipment) as display metadata. Shown only
|
||||||
|
when the producer populated it (native alarms whose raw tag ≥1 equipment
|
||||||
|
references); scripted alarms + unreferenced native alarms leave it empty. *@
|
||||||
|
@if (e.ReferencingEquipmentPaths is { Count: > 0 } refs)
|
||||||
|
{
|
||||||
|
<div class="text-muted small" title="Referencing equipment">
|
||||||
|
@foreach (var p in refs)
|
||||||
|
{
|
||||||
|
<span class="chip chip-idle" style="font-size:0.72rem; margin:1px">@p</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
<td><span class="chip @KindChipClass(e.TransitionKind)">@e.TransitionKind</span></td>
|
<td><span class="chip @KindChipClass(e.TransitionKind)">@e.TransitionKind</span></td>
|
||||||
<td class="num">@e.Severity</td>
|
<td class="num">@e.Severity</td>
|
||||||
<td>@e.User</td>
|
<td>@e.User</td>
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
@page "/clusters/{ClusterId}/drivers"
|
|
||||||
@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
|
|
||||||
@rendermode RenderMode.InteractiveServer
|
|
||||||
@using Microsoft.EntityFrameworkCore
|
|
||||||
@using ZB.MOM.WW.OtOpcUa.Configuration
|
|
||||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
|
||||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
|
||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
||||||
<h4 class="mb-0">Drivers · <span class="mono">@ClusterId</span></h4>
|
|
||||||
<a href="/clusters/@ClusterId/drivers/new" class="btn btn-primary btn-sm">New driver</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ClusterNav ClusterId="@ClusterId" ActiveTab="drivers" />
|
|
||||||
|
|
||||||
@if (_rows is null)
|
|
||||||
{
|
|
||||||
<p>Loading…</p>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<section class="panel rise mt-3" style="animation-delay:.08s">
|
|
||||||
<div class="panel-head">@_rows.Count driver instance@(_rows.Count == 1 ? "" : "s")</div>
|
|
||||||
@if (_rows.Count == 0)
|
|
||||||
{
|
|
||||||
<div style="padding:1rem" class="text-muted">No driver instances for this cluster.</div>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
@foreach (var d in _rows)
|
|
||||||
{
|
|
||||||
<details style="border-top:1px solid var(--rule)">
|
|
||||||
<summary style="padding:.75rem 1rem;cursor:pointer">
|
|
||||||
<span class="mono">@d.DriverInstanceId</span>
|
|
||||||
· <span>@d.Name</span>
|
|
||||||
· <span class="chip chip-idle ms-1">@d.DriverType</span>
|
|
||||||
@if (!d.Enabled) { <span class="chip chip-idle ms-1">Disabled</span> }
|
|
||||||
</summary>
|
|
||||||
<div style="padding:0 1rem 1rem">
|
|
||||||
<div class="d-flex mb-2">
|
|
||||||
<a href="/clusters/@ClusterId/drivers/@d.DriverInstanceId" class="btn btn-sm btn-outline-primary">Edit</a>
|
|
||||||
</div>
|
|
||||||
<pre class="mono small" style="background:var(--surface-2);padding:1rem;border-radius:4px;overflow:auto">@FormatJson(d.DriverConfig)</pre>
|
|
||||||
@if (!string.IsNullOrWhiteSpace(d.ResilienceConfig))
|
|
||||||
{
|
|
||||||
<div class="text-muted small mt-2">Resilience overrides:</div>
|
|
||||||
<pre class="mono small" style="background:var(--surface-2);padding:1rem;border-radius:4px;overflow:auto">@FormatJson(d.ResilienceConfig)</pre>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
|
|
||||||
@code {
|
|
||||||
[Parameter] public string ClusterId { get; set; } = "";
|
|
||||||
private List<DriverInstance>? _rows;
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
|
||||||
{
|
|
||||||
await using var db = await DbFactory.CreateDbContextAsync();
|
|
||||||
_rows = await db.DriverInstances.AsNoTracking()
|
|
||||||
.Where(d => d.ClusterId == ClusterId)
|
|
||||||
.OrderBy(d => d.DriverInstanceId)
|
|
||||||
.ToListAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string FormatJson(string raw)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(raw)) return "";
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var doc = System.Text.Json.JsonDocument.Parse(raw);
|
|
||||||
return System.Text.Json.JsonSerializer.Serialize(doc.RootElement,
|
|
||||||
new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return raw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-448
@@ -1,448 +0,0 @@
|
|||||||
@page "/clusters/{ClusterId}/drivers/new/abcip"
|
|
||||||
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
|
|
||||||
@rendermode RenderMode.InteractiveServer
|
|
||||||
@using Microsoft.AspNetCore.Components.Forms
|
|
||||||
@using Microsoft.EntityFrameworkCore
|
|
||||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients
|
|
||||||
@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
|
|
||||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
|
||||||
@using ZB.MOM.WW.OtOpcUa.Driver.AbCip
|
|
||||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
|
||||||
@inject NavigationManager Nav
|
|
||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
||||||
<h4 class="mb-0">@(IsNew ? "New AB CIP driver" : "Edit AB CIP driver") · <span class="mono">@ClusterId</span></h4>
|
|
||||||
<a href="/clusters/@ClusterId/drivers" class="btn btn-outline-secondary btn-sm">Cancel</a>
|
|
||||||
</div>
|
|
||||||
<ClusterNav ClusterId="@ClusterId" ActiveTab="drivers" />
|
|
||||||
|
|
||||||
@if (!_loaded)
|
|
||||||
{
|
|
||||||
<p>Loading…</p>
|
|
||||||
}
|
|
||||||
else if (!IsNew && _existing is null)
|
|
||||||
{
|
|
||||||
<section class="panel notice rise" style="animation-delay:.02s">
|
|
||||||
Driver instance <span class="mono">@DriverInstanceId</span> was not found in cluster <span class="mono">@ClusterId</span>.
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="abcipDriverEdit">
|
|
||||||
<DataAnnotationsValidator />
|
|
||||||
<DriverFormShell IsNew="IsNew" Busy="_busy" Error="@_error"
|
|
||||||
CancelHref="@($"/clusters/{ClusterId}/drivers")"
|
|
||||||
OnDelete="@(IsNew ? null : (EventCallback?)EventCallback.Factory.Create(this, DeleteAsync))">
|
|
||||||
|
|
||||||
<DriverIdentitySection Model="_identityModel" IsNew="IsNew" ShowDriverType="false" />
|
|
||||||
|
|
||||||
@if (!IsNew && !string.IsNullOrEmpty(DriverInstanceId))
|
|
||||||
{
|
|
||||||
<DriverStatusPanel DriverInstanceId="@DriverInstanceId" Enabled="@_identityModel.Enabled" />
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="mt-2 mb-3">
|
|
||||||
<DriverTestConnectButton DriverType="@DriverTypeKey"
|
|
||||||
GetConfigJson="@SerializeCurrentConfig"
|
|
||||||
TimeoutSeconds="@_form.AdminProbeTimeoutSeconds" />
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
|
|
||||||
@onclick="@(() => _showPicker = true)">
|
|
||||||
Pick address
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DriverTagPicker @bind-Visible="_showPicker"
|
|
||||||
Title="AB CIP address"
|
|
||||||
CurrentAddress="@_pickedAddress"
|
|
||||||
OnPickAddress="@OnAddressPicked">
|
|
||||||
<AbCipAddressPickerBody CurrentAddress="@_pickedAddress"
|
|
||||||
CurrentAddressChanged="@((s) => _pickedAddress = s)"
|
|
||||||
GetConfigJson="@SerializeCurrentConfig" />
|
|
||||||
</DriverTagPicker>
|
|
||||||
|
|
||||||
@* Operation timeout *@
|
|
||||||
<section class="panel rise mt-3" style="animation-delay:.06s">
|
|
||||||
<div class="panel-head">Operation settings</div>
|
|
||||||
<div style="padding:1rem">
|
|
||||||
<div class="row g-3">
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label">Timeout (seconds)</label>
|
|
||||||
<InputNumber @bind-Value="_form.TimeoutSeconds" class="form-control form-control-sm" />
|
|
||||||
<div class="form-text">Default libplctag call timeout. Default 2 s.</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="form-check form-switch mt-4">
|
|
||||||
<InputCheckbox @bind-Value="_form.EnableControllerBrowse" class="form-check-input" id="enableBrowse" />
|
|
||||||
<label class="form-check-label" for="enableBrowse">Enable controller browse</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-text">Walk the Logix symbol table and surface globals under Discovered/.</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="form-check form-switch mt-4">
|
|
||||||
<InputCheckbox @bind-Value="_form.EnableDeclarationOnlyUdtGrouping" class="form-check-input" id="enableUdtGroup" />
|
|
||||||
<label class="form-check-label" for="enableUdtGroup">Enable declaration-only UDT grouping</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-text">Only enable when UDT member declaration order matches controller compiled layout.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
@* Alarm projection *@
|
|
||||||
<section class="panel rise mt-3" style="animation-delay:.08s">
|
|
||||||
<div class="panel-head">Alarm projection</div>
|
|
||||||
<div style="padding:1rem">
|
|
||||||
<div class="row g-3">
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="form-check form-switch mt-4">
|
|
||||||
<InputCheckbox @bind-Value="_form.EnableAlarmProjection" class="form-check-input" id="enableAlarm" />
|
|
||||||
<label class="form-check-label" for="enableAlarm">Enable ALMD alarm projection</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-text">Surfaces ALMD tags as alarm conditions via IAlarmSource. Default off.</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label">Alarm poll interval (seconds)</label>
|
|
||||||
<InputNumber @bind-Value="_form.AlarmPollIntervalSeconds" class="form-control form-control-sm" />
|
|
||||||
<div class="form-text">Default 1 s.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
@* Connectivity probe *@
|
|
||||||
<section class="panel rise mt-3" style="animation-delay:.10s">
|
|
||||||
<div class="panel-head">Connectivity probe</div>
|
|
||||||
<div style="padding:1rem">
|
|
||||||
<div class="row g-3">
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="form-check form-switch mt-4">
|
|
||||||
<InputCheckbox @bind-Value="_form.ProbeEnabled" class="form-check-input" id="probeEnabled" />
|
|
||||||
<label class="form-check-label" for="probeEnabled">Enabled</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label">Interval (seconds)</label>
|
|
||||||
<InputNumber @bind-Value="_form.ProbeIntervalSeconds" class="form-control form-control-sm" />
|
|
||||||
<div class="form-text">Default 5 s.</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label">Timeout (seconds)</label>
|
|
||||||
<InputNumber @bind-Value="_form.ProbeTimeoutSeconds" class="form-control form-control-sm" />
|
|
||||||
<div class="form-text">Default 2 s.</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label">Probe tag path</label>
|
|
||||||
<InputText @bind-Value="_form.ProbeTagPath" class="form-control form-control-sm mono"
|
|
||||||
placeholder="e.g. Program:Main.SomeTag" />
|
|
||||||
<div class="form-text">Required when probe is enabled. Leave blank and an operator warning is logged.</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label">Admin UI probe timeout (seconds)</label>
|
|
||||||
<InputNumber @bind-Value="_form.AdminProbeTimeoutSeconds" class="form-control form-control-sm" />
|
|
||||||
<div class="form-text">Max 60. Used by Test Connect. Default 5.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
@* Devices *@
|
|
||||||
<CollectionEditor TRow="AbCipDeviceRow" Items="_devices" Title="Devices" ItemNoun="device"
|
|
||||||
AnimationDelay=".12s"
|
|
||||||
NewRow="@(() => new AbCipDeviceRow())" Clone="@(r => r.Clone())"
|
|
||||||
Validate="AbCipDeviceRow.ValidateRow">
|
|
||||||
<HeaderTemplate>
|
|
||||||
<tr><th>Host address</th><th>PLC family</th><th>Device name</th><th></th></tr>
|
|
||||||
</HeaderTemplate>
|
|
||||||
<RowTemplate Context="d">
|
|
||||||
<td class="mono">@d.HostAddress</td><td>@d.PlcFamily</td>
|
|
||||||
<td>@(string.IsNullOrWhiteSpace(d.DeviceName) ? "—" : d.DeviceName)</td>
|
|
||||||
</RowTemplate>
|
|
||||||
<EditTemplate Context="d">
|
|
||||||
<div class="row g-3">
|
|
||||||
<div class="col-md-6"><label class="form-label">Host address</label>
|
|
||||||
<input class="form-control form-control-sm mono" @bind="d.HostAddress"
|
|
||||||
placeholder="ab://gateway/1,0" /></div>
|
|
||||||
<div class="col-md-3"><label class="form-label">PLC family</label>
|
|
||||||
<select class="form-select form-select-sm" @bind="d.PlcFamily">
|
|
||||||
@foreach (var e in Enum.GetValues<AbCipPlcFamily>()) { <option value="@e">@e</option> }
|
|
||||||
</select></div>
|
|
||||||
<div class="col-md-3"><label class="form-label">Device name</label>
|
|
||||||
<input class="form-control form-control-sm" @bind="d.DeviceName" /></div>
|
|
||||||
</div>
|
|
||||||
</EditTemplate>
|
|
||||||
</CollectionEditor>
|
|
||||||
|
|
||||||
@* Tags *@
|
|
||||||
<section class="panel notice rise mt-3" style="animation-delay:.18s">
|
|
||||||
<div class="panel-head">Tags</div>
|
|
||||||
<div style="padding:1rem" class="text-muted">
|
|
||||||
Tag authoring moves to the Raw tree (<strong>/raw</strong>) in v3 Batch 2.
|
|
||||||
Pre-declared driver tags have been retired.
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
|
|
||||||
</DriverFormShell>
|
|
||||||
</EditForm>
|
|
||||||
}
|
|
||||||
|
|
||||||
@code {
|
|
||||||
[Parameter] public string ClusterId { get; set; } = "";
|
|
||||||
[Parameter] public string? DriverInstanceId { get; set; }
|
|
||||||
|
|
||||||
private const string DriverTypeKey = "AbCip";
|
|
||||||
|
|
||||||
private bool IsNew => string.IsNullOrEmpty(DriverInstanceId);
|
|
||||||
|
|
||||||
private static readonly System.Text.Json.JsonSerializerOptions _jsonOpts = new()
|
|
||||||
{
|
|
||||||
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
|
|
||||||
UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip,
|
|
||||||
WriteIndented = false,
|
|
||||||
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
|
|
||||||
};
|
|
||||||
|
|
||||||
private FormModel _form = new();
|
|
||||||
private DriverIdentitySection.DriverIdentityModel _identityModel = new() { DriverType = DriverTypeKey };
|
|
||||||
private DriverInstance? _existing;
|
|
||||||
private bool _loaded;
|
|
||||||
private bool _busy;
|
|
||||||
private string? _error;
|
|
||||||
|
|
||||||
// Address picker state
|
|
||||||
private bool _showPicker;
|
|
||||||
private string _pickedAddress = "";
|
|
||||||
|
|
||||||
private void OnAddressPicked(string address) => _pickedAddress = address;
|
|
||||||
|
|
||||||
// Held separately because Devices is a collection — edited via the CollectionEditor modal.
|
|
||||||
private List<AbCipDeviceRow> _devices = [];
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
|
||||||
{
|
|
||||||
await using var db = await DbFactory.CreateDbContextAsync();
|
|
||||||
|
|
||||||
if (IsNew)
|
|
||||||
{
|
|
||||||
_identityModel = new()
|
|
||||||
{
|
|
||||||
DriverInstanceId = "",
|
|
||||||
Name = "",
|
|
||||||
DriverType = DriverTypeKey,
|
|
||||||
Enabled = true,
|
|
||||||
};
|
|
||||||
_form = new FormModel();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_existing = await db.DriverInstances.AsNoTracking()
|
|
||||||
.FirstOrDefaultAsync(d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
|
|
||||||
if (_existing is not null)
|
|
||||||
{
|
|
||||||
_identityModel = new()
|
|
||||||
{
|
|
||||||
DriverInstanceId = _existing.DriverInstanceId,
|
|
||||||
Name = _existing.Name,
|
|
||||||
DriverType = _existing.DriverType,
|
|
||||||
Enabled = _existing.Enabled,
|
|
||||||
};
|
|
||||||
var opts = TryDeserialize(_existing.DriverConfig) ?? new AbCipDriverOptions();
|
|
||||||
_form = FormModel.FromOptions(opts);
|
|
||||||
_form.ResilienceConfig = _existing.ResilienceConfig;
|
|
||||||
_form.RowVersion = _existing.RowVersion;
|
|
||||||
_devices = opts.Devices.Select(AbCipDeviceRow.FromDefinition).ToList();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_loaded = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task SubmitAsync()
|
|
||||||
{
|
|
||||||
_busy = true; _error = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var configJson = System.Text.Json.JsonSerializer.Serialize(
|
|
||||||
_form.ToOptions(
|
|
||||||
_devices.Select(r => r.ToDefinition()).ToList()),
|
|
||||||
_jsonOpts);
|
|
||||||
await using var db = await DbFactory.CreateDbContextAsync();
|
|
||||||
if (IsNew)
|
|
||||||
{
|
|
||||||
if (await db.DriverInstances.AnyAsync(d => d.DriverInstanceId == _identityModel.DriverInstanceId))
|
|
||||||
{
|
|
||||||
_error = $"Driver instance '{_identityModel.DriverInstanceId}' already exists.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
db.DriverInstances.Add(new DriverInstance
|
|
||||||
{
|
|
||||||
DriverInstanceId = _identityModel.DriverInstanceId,
|
|
||||||
ClusterId = ClusterId,
|
|
||||||
Name = _identityModel.Name,
|
|
||||||
DriverType = DriverTypeKey,
|
|
||||||
Enabled = _identityModel.Enabled,
|
|
||||||
DriverConfig = configJson,
|
|
||||||
ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var entity = await db.DriverInstances.FirstOrDefaultAsync(
|
|
||||||
d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
|
|
||||||
if (entity is null) { _error = "Row no longer exists."; return; }
|
|
||||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
|
|
||||||
entity.Name = _identityModel.Name;
|
|
||||||
entity.Enabled = _identityModel.Enabled;
|
|
||||||
entity.DriverConfig = configJson;
|
|
||||||
entity.ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig;
|
|
||||||
}
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
|
|
||||||
}
|
|
||||||
catch (DbUpdateConcurrencyException)
|
|
||||||
{
|
|
||||||
_error = "Another user changed this driver instance while you were editing. Reload to see the latest values, then re-apply your changes.";
|
|
||||||
}
|
|
||||||
catch (Exception ex) { _error = ex.Message; }
|
|
||||||
finally { _busy = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task DeleteAsync()
|
|
||||||
{
|
|
||||||
if (IsNew) return;
|
|
||||||
_busy = true; _error = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await using var db = await DbFactory.CreateDbContextAsync();
|
|
||||||
var entity = await db.DriverInstances.FirstOrDefaultAsync(
|
|
||||||
d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
|
|
||||||
if (entity is null) { Nav.NavigateTo($"/clusters/{ClusterId}/drivers"); return; }
|
|
||||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
|
|
||||||
db.DriverInstances.Remove(entity);
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
|
|
||||||
}
|
|
||||||
catch (DbUpdateConcurrencyException)
|
|
||||||
{
|
|
||||||
_error = "Another user changed this driver instance while you were viewing it. Reload before deleting.";
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_error = $"Delete failed: {ex.Message}. (Likely because equipment/tags still reference this driver — remove them first.)";
|
|
||||||
}
|
|
||||||
finally { _busy = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
private string SerializeCurrentConfig()
|
|
||||||
=> System.Text.Json.JsonSerializer.Serialize(
|
|
||||||
_form.ToOptions(
|
|
||||||
_devices.Select(r => r.ToDefinition()).ToList()),
|
|
||||||
_jsonOpts);
|
|
||||||
|
|
||||||
private static AbCipDriverOptions? TryDeserialize(string json)
|
|
||||||
{
|
|
||||||
try { return System.Text.Json.JsonSerializer.Deserialize<AbCipDriverOptions>(json, _jsonOpts); }
|
|
||||||
catch { return null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mutable VM for the modal editor — AbCipDeviceOptions is an immutable record.
|
|
||||||
public sealed class AbCipDeviceRow
|
|
||||||
{
|
|
||||||
public string HostAddress { get; set; } = "";
|
|
||||||
public AbCipPlcFamily PlcFamily { get; set; } = AbCipPlcFamily.ControlLogix;
|
|
||||||
public string? DeviceName { get; set; }
|
|
||||||
|
|
||||||
// Original record (null for newly-added rows). Preserves fields the editor doesn't expose
|
|
||||||
// (AllowPacking, ConnectionSize) across a load→save.
|
|
||||||
private AbCipDeviceOptions? _source;
|
|
||||||
|
|
||||||
public AbCipDeviceRow Clone() => (AbCipDeviceRow)MemberwiseClone(); // _source is an immutable record ref — safe to share
|
|
||||||
|
|
||||||
public static AbCipDeviceRow FromDefinition(AbCipDeviceOptions d) => new()
|
|
||||||
{
|
|
||||||
HostAddress = d.HostAddress, PlcFamily = d.PlcFamily, DeviceName = d.DeviceName,
|
|
||||||
_source = d,
|
|
||||||
};
|
|
||||||
|
|
||||||
public AbCipDeviceOptions ToDefinition()
|
|
||||||
{
|
|
||||||
var baseDef = _source ?? new AbCipDeviceOptions(HostAddress.Trim(), PlcFamily);
|
|
||||||
return baseDef with
|
|
||||||
{
|
|
||||||
HostAddress = HostAddress.Trim(),
|
|
||||||
PlcFamily = PlcFamily,
|
|
||||||
DeviceName = string.IsNullOrWhiteSpace(DeviceName) ? null : DeviceName.Trim(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string? ValidateRow(AbCipDeviceRow row, IReadOnlyList<AbCipDeviceRow> all, int? editIndex)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(row.HostAddress)) return "Host address is required.";
|
|
||||||
for (var i = 0; i < all.Count; i++)
|
|
||||||
if (i != editIndex && string.Equals(all[i].HostAddress, row.HostAddress, StringComparison.OrdinalIgnoreCase))
|
|
||||||
return $"Duplicate device host address '{row.HostAddress}'.";
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Flat mutable model — all scalar properties settable for Blazor @bind-Value.
|
|
||||||
// Collections (Devices) are kept on the component and passed in on ToOptions().
|
|
||||||
public sealed class FormModel
|
|
||||||
{
|
|
||||||
// Operation
|
|
||||||
public int TimeoutSeconds { get; set; } = 2;
|
|
||||||
public bool EnableControllerBrowse { get; set; } = false;
|
|
||||||
public bool EnableDeclarationOnlyUdtGrouping { get; set; } = false;
|
|
||||||
|
|
||||||
// Alarm projection
|
|
||||||
public bool EnableAlarmProjection { get; set; } = false;
|
|
||||||
public int AlarmPollIntervalSeconds { get; set; } = 1;
|
|
||||||
|
|
||||||
// Probe
|
|
||||||
public bool ProbeEnabled { get; set; } = true;
|
|
||||||
public int ProbeIntervalSeconds { get; set; } = 5;
|
|
||||||
public int ProbeTimeoutSeconds { get; set; } = 2;
|
|
||||||
public string? ProbeTagPath { get; set; }
|
|
||||||
|
|
||||||
// Admin UI probe timeout
|
|
||||||
public int AdminProbeTimeoutSeconds { get; set; } = 5;
|
|
||||||
|
|
||||||
// Persistence
|
|
||||||
public string? ResilienceConfig { get; set; }
|
|
||||||
public byte[] RowVersion { get; set; } = [];
|
|
||||||
|
|
||||||
public static FormModel FromOptions(AbCipDriverOptions o) => new()
|
|
||||||
{
|
|
||||||
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
|
|
||||||
EnableControllerBrowse = o.EnableControllerBrowse,
|
|
||||||
EnableDeclarationOnlyUdtGrouping = o.EnableDeclarationOnlyUdtGrouping,
|
|
||||||
EnableAlarmProjection = o.EnableAlarmProjection,
|
|
||||||
AlarmPollIntervalSeconds = (int)o.AlarmPollInterval.TotalSeconds,
|
|
||||||
ProbeEnabled = o.Probe.Enabled,
|
|
||||||
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
|
|
||||||
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
|
|
||||||
ProbeTagPath = o.Probe.ProbeTagPath,
|
|
||||||
AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
|
|
||||||
};
|
|
||||||
|
|
||||||
public AbCipDriverOptions ToOptions(
|
|
||||||
IReadOnlyList<AbCipDeviceOptions> devices) => new()
|
|
||||||
{
|
|
||||||
Devices = devices,
|
|
||||||
Probe = new AbCipProbeOptions
|
|
||||||
{
|
|
||||||
Enabled = ProbeEnabled,
|
|
||||||
Interval = TimeSpan.FromSeconds(ProbeIntervalSeconds),
|
|
||||||
Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds),
|
|
||||||
ProbeTagPath = string.IsNullOrWhiteSpace(ProbeTagPath) ? null : ProbeTagPath,
|
|
||||||
},
|
|
||||||
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
|
|
||||||
EnableControllerBrowse = EnableControllerBrowse,
|
|
||||||
EnableAlarmProjection = EnableAlarmProjection,
|
|
||||||
AlarmPollInterval = TimeSpan.FromSeconds(AlarmPollIntervalSeconds),
|
|
||||||
EnableDeclarationOnlyUdtGrouping = EnableDeclarationOnlyUdtGrouping,
|
|
||||||
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-399
@@ -1,399 +0,0 @@
|
|||||||
@page "/clusters/{ClusterId}/drivers/new/ablegacy"
|
|
||||||
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
|
|
||||||
@rendermode RenderMode.InteractiveServer
|
|
||||||
@using Microsoft.AspNetCore.Components.Forms
|
|
||||||
@using Microsoft.EntityFrameworkCore
|
|
||||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients
|
|
||||||
@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
|
|
||||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
|
||||||
@using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy
|
|
||||||
@using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies
|
|
||||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
|
||||||
@inject NavigationManager Nav
|
|
||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
||||||
<h4 class="mb-0">@(IsNew ? "New AB Legacy driver" : "Edit AB Legacy driver") · <span class="mono">@ClusterId</span></h4>
|
|
||||||
<a href="/clusters/@ClusterId/drivers" class="btn btn-outline-secondary btn-sm">Cancel</a>
|
|
||||||
</div>
|
|
||||||
<ClusterNav ClusterId="@ClusterId" ActiveTab="drivers" />
|
|
||||||
|
|
||||||
@if (!_loaded)
|
|
||||||
{
|
|
||||||
<p>Loading…</p>
|
|
||||||
}
|
|
||||||
else if (!IsNew && _existing is null)
|
|
||||||
{
|
|
||||||
<section class="panel notice rise" style="animation-delay:.02s">
|
|
||||||
Driver instance <span class="mono">@DriverInstanceId</span> was not found in cluster <span class="mono">@ClusterId</span>.
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="ablegacyDriverEdit">
|
|
||||||
<DataAnnotationsValidator />
|
|
||||||
<DriverFormShell IsNew="IsNew" Busy="_busy" Error="@_error"
|
|
||||||
CancelHref="@($"/clusters/{ClusterId}/drivers")"
|
|
||||||
OnDelete="@(IsNew ? null : (EventCallback?)EventCallback.Factory.Create(this, DeleteAsync))">
|
|
||||||
|
|
||||||
<DriverIdentitySection Model="_identityModel" IsNew="IsNew" ShowDriverType="false" />
|
|
||||||
|
|
||||||
@if (!IsNew && !string.IsNullOrEmpty(DriverInstanceId))
|
|
||||||
{
|
|
||||||
<DriverStatusPanel DriverInstanceId="@DriverInstanceId" Enabled="@_identityModel.Enabled" />
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="mt-2 mb-3">
|
|
||||||
<DriverTestConnectButton DriverType="@DriverTypeKey"
|
|
||||||
GetConfigJson="@SerializeCurrentConfig"
|
|
||||||
TimeoutSeconds="@_form.AdminProbeTimeoutSeconds" />
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
|
|
||||||
@onclick="@(() => _showPicker = true)">
|
|
||||||
Pick address
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DriverTagPicker @bind-Visible="_showPicker"
|
|
||||||
Title="AB Legacy address"
|
|
||||||
CurrentAddress="@_pickedAddress"
|
|
||||||
OnPickAddress="@OnAddressPicked">
|
|
||||||
<AbLegacyAddressPickerBody CurrentAddress="@_pickedAddress"
|
|
||||||
CurrentAddressChanged="@((s) => _pickedAddress = s)" />
|
|
||||||
</DriverTagPicker>
|
|
||||||
|
|
||||||
@* Operation settings *@
|
|
||||||
<section class="panel rise mt-3" style="animation-delay:.06s">
|
|
||||||
<div class="panel-head">Operation settings</div>
|
|
||||||
<div style="padding:1rem">
|
|
||||||
<div class="row g-3">
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label">Timeout (seconds)</label>
|
|
||||||
<InputNumber @bind-Value="_form.TimeoutSeconds" class="form-control form-control-sm" />
|
|
||||||
<div class="form-text">Default read/write timeout. Default 2 s.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
@* Connectivity probe *@
|
|
||||||
<section class="panel rise mt-3" style="animation-delay:.08s">
|
|
||||||
<div class="panel-head">Connectivity probe</div>
|
|
||||||
<div style="padding:1rem">
|
|
||||||
<div class="row g-3">
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="form-check form-switch mt-4">
|
|
||||||
<InputCheckbox @bind-Value="_form.ProbeEnabled" class="form-check-input" id="probeEnabled" />
|
|
||||||
<label class="form-check-label" for="probeEnabled">Enabled</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label">Interval (seconds)</label>
|
|
||||||
<InputNumber @bind-Value="_form.ProbeIntervalSeconds" class="form-control form-control-sm" />
|
|
||||||
<div class="form-text">Default 5 s.</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label">Timeout (seconds)</label>
|
|
||||||
<InputNumber @bind-Value="_form.ProbeTimeoutSeconds" class="form-control form-control-sm" />
|
|
||||||
<div class="form-text">Default 2 s.</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label">Probe address</label>
|
|
||||||
<InputText @bind-Value="_form.ProbeAddress" class="form-control form-control-sm mono"
|
|
||||||
placeholder="S:0" />
|
|
||||||
<div class="form-text">PCCC file address to read for probe. Default S:0 (status file word 0).</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label">Admin UI probe timeout (seconds)</label>
|
|
||||||
<InputNumber @bind-Value="_form.AdminProbeTimeoutSeconds" class="form-control form-control-sm" />
|
|
||||||
<div class="form-text">Max 60. Used by Test Connect. Default 5.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
@* Devices *@
|
|
||||||
<CollectionEditor TRow="AbLegacyDeviceRow" Items="_devices" Title="Devices" ItemNoun="device"
|
|
||||||
AnimationDelay=".10s"
|
|
||||||
NewRow="@(() => new AbLegacyDeviceRow())" Clone="@(r => r.Clone())"
|
|
||||||
Validate="AbLegacyDeviceRow.ValidateRow">
|
|
||||||
<HeaderTemplate>
|
|
||||||
<tr><th>Host address</th><th>PLC family</th><th>Device name</th><th></th></tr>
|
|
||||||
</HeaderTemplate>
|
|
||||||
<RowTemplate Context="d">
|
|
||||||
<td class="mono">@d.HostAddress</td><td>@d.PlcFamily</td>
|
|
||||||
<td>@(string.IsNullOrWhiteSpace(d.DeviceName) ? "—" : d.DeviceName)</td>
|
|
||||||
</RowTemplate>
|
|
||||||
<EditTemplate Context="d">
|
|
||||||
<div class="row g-3">
|
|
||||||
<div class="col-md-6"><label class="form-label">Host address</label>
|
|
||||||
<input class="form-control form-control-sm mono" @bind="d.HostAddress"
|
|
||||||
placeholder="10.0.0.10" /></div>
|
|
||||||
<div class="col-md-3"><label class="form-label">PLC family</label>
|
|
||||||
<select class="form-select form-select-sm" @bind="d.PlcFamily">
|
|
||||||
@foreach (var e in Enum.GetValues<AbLegacyPlcFamily>()) { <option value="@e">@e</option> }
|
|
||||||
</select></div>
|
|
||||||
<div class="col-md-3"><label class="form-label">Device name</label>
|
|
||||||
<input class="form-control form-control-sm" @bind="d.DeviceName" /></div>
|
|
||||||
</div>
|
|
||||||
</EditTemplate>
|
|
||||||
</CollectionEditor>
|
|
||||||
|
|
||||||
@* Tags *@
|
|
||||||
<section class="panel notice rise mt-3" style="animation-delay:.18s">
|
|
||||||
<div class="panel-head">Tags</div>
|
|
||||||
<div style="padding:1rem" class="text-muted">
|
|
||||||
Tag authoring moves to the Raw tree (<strong>/raw</strong>) in v3 Batch 2.
|
|
||||||
Pre-declared driver tags have been retired.
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
|
|
||||||
</DriverFormShell>
|
|
||||||
</EditForm>
|
|
||||||
}
|
|
||||||
|
|
||||||
@code {
|
|
||||||
[Parameter] public string ClusterId { get; set; } = "";
|
|
||||||
[Parameter] public string? DriverInstanceId { get; set; }
|
|
||||||
|
|
||||||
private const string DriverTypeKey = "AbLegacy";
|
|
||||||
|
|
||||||
private bool IsNew => string.IsNullOrEmpty(DriverInstanceId);
|
|
||||||
|
|
||||||
private static readonly System.Text.Json.JsonSerializerOptions _jsonOpts = new()
|
|
||||||
{
|
|
||||||
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
|
|
||||||
UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip,
|
|
||||||
WriteIndented = false,
|
|
||||||
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
|
|
||||||
};
|
|
||||||
|
|
||||||
private FormModel _form = new();
|
|
||||||
private DriverIdentitySection.DriverIdentityModel _identityModel = new() { DriverType = DriverTypeKey };
|
|
||||||
private DriverInstance? _existing;
|
|
||||||
private bool _loaded;
|
|
||||||
private bool _busy;
|
|
||||||
private string? _error;
|
|
||||||
|
|
||||||
// Address picker state
|
|
||||||
private bool _showPicker;
|
|
||||||
private string _pickedAddress = "";
|
|
||||||
|
|
||||||
private void OnAddressPicked(string address) => _pickedAddress = address;
|
|
||||||
|
|
||||||
// Held separately because Devices/Tags are collections — edited via the CollectionEditor modal.
|
|
||||||
private List<AbLegacyDeviceRow> _devices = [];
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
|
||||||
{
|
|
||||||
await using var db = await DbFactory.CreateDbContextAsync();
|
|
||||||
|
|
||||||
if (IsNew)
|
|
||||||
{
|
|
||||||
_identityModel = new()
|
|
||||||
{
|
|
||||||
DriverInstanceId = "",
|
|
||||||
Name = "",
|
|
||||||
DriverType = DriverTypeKey,
|
|
||||||
Enabled = true,
|
|
||||||
};
|
|
||||||
_form = new FormModel();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_existing = await db.DriverInstances.AsNoTracking()
|
|
||||||
.FirstOrDefaultAsync(d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
|
|
||||||
if (_existing is not null)
|
|
||||||
{
|
|
||||||
_identityModel = new()
|
|
||||||
{
|
|
||||||
DriverInstanceId = _existing.DriverInstanceId,
|
|
||||||
Name = _existing.Name,
|
|
||||||
DriverType = _existing.DriverType,
|
|
||||||
Enabled = _existing.Enabled,
|
|
||||||
};
|
|
||||||
var opts = TryDeserialize(_existing.DriverConfig) ?? new AbLegacyDriverOptions();
|
|
||||||
_form = FormModel.FromOptions(opts);
|
|
||||||
_form.ResilienceConfig = _existing.ResilienceConfig;
|
|
||||||
_form.RowVersion = _existing.RowVersion;
|
|
||||||
_devices = opts.Devices.Select(AbLegacyDeviceRow.FromDefinition).ToList();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_loaded = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task SubmitAsync()
|
|
||||||
{
|
|
||||||
_busy = true; _error = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var configJson = System.Text.Json.JsonSerializer.Serialize(
|
|
||||||
_form.ToOptions(
|
|
||||||
_devices.Select(r => r.ToDefinition()).ToList()),
|
|
||||||
_jsonOpts);
|
|
||||||
await using var db = await DbFactory.CreateDbContextAsync();
|
|
||||||
if (IsNew)
|
|
||||||
{
|
|
||||||
if (await db.DriverInstances.AnyAsync(d => d.DriverInstanceId == _identityModel.DriverInstanceId))
|
|
||||||
{
|
|
||||||
_error = $"Driver instance '{_identityModel.DriverInstanceId}' already exists.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
db.DriverInstances.Add(new DriverInstance
|
|
||||||
{
|
|
||||||
DriverInstanceId = _identityModel.DriverInstanceId,
|
|
||||||
ClusterId = ClusterId,
|
|
||||||
Name = _identityModel.Name,
|
|
||||||
DriverType = DriverTypeKey,
|
|
||||||
Enabled = _identityModel.Enabled,
|
|
||||||
DriverConfig = configJson,
|
|
||||||
ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var entity = await db.DriverInstances.FirstOrDefaultAsync(
|
|
||||||
d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
|
|
||||||
if (entity is null) { _error = "Row no longer exists."; return; }
|
|
||||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
|
|
||||||
entity.Name = _identityModel.Name;
|
|
||||||
entity.Enabled = _identityModel.Enabled;
|
|
||||||
entity.DriverConfig = configJson;
|
|
||||||
entity.ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig;
|
|
||||||
}
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
|
|
||||||
}
|
|
||||||
catch (DbUpdateConcurrencyException)
|
|
||||||
{
|
|
||||||
_error = "Another user changed this driver instance while you were editing. Reload to see the latest values, then re-apply your changes.";
|
|
||||||
}
|
|
||||||
catch (Exception ex) { _error = ex.Message; }
|
|
||||||
finally { _busy = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task DeleteAsync()
|
|
||||||
{
|
|
||||||
if (IsNew) return;
|
|
||||||
_busy = true; _error = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await using var db = await DbFactory.CreateDbContextAsync();
|
|
||||||
var entity = await db.DriverInstances.FirstOrDefaultAsync(
|
|
||||||
d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
|
|
||||||
if (entity is null) { Nav.NavigateTo($"/clusters/{ClusterId}/drivers"); return; }
|
|
||||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
|
|
||||||
db.DriverInstances.Remove(entity);
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
|
|
||||||
}
|
|
||||||
catch (DbUpdateConcurrencyException)
|
|
||||||
{
|
|
||||||
_error = "Another user changed this driver instance while you were viewing it. Reload before deleting.";
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_error = $"Delete failed: {ex.Message}. (Likely because equipment/tags still reference this driver — remove them first.)";
|
|
||||||
}
|
|
||||||
finally { _busy = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
private string SerializeCurrentConfig()
|
|
||||||
=> System.Text.Json.JsonSerializer.Serialize(
|
|
||||||
_form.ToOptions(
|
|
||||||
_devices.Select(r => r.ToDefinition()).ToList()),
|
|
||||||
_jsonOpts);
|
|
||||||
|
|
||||||
private static AbLegacyDriverOptions? TryDeserialize(string json)
|
|
||||||
{
|
|
||||||
try { return System.Text.Json.JsonSerializer.Deserialize<AbLegacyDriverOptions>(json, _jsonOpts); }
|
|
||||||
catch { return null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mutable VM for the modal editor — AbLegacyDeviceOptions is an immutable record.
|
|
||||||
public sealed class AbLegacyDeviceRow
|
|
||||||
{
|
|
||||||
public string HostAddress { get; set; } = "";
|
|
||||||
public AbLegacyPlcFamily PlcFamily { get; set; } = AbLegacyPlcFamily.Slc500;
|
|
||||||
public string? DeviceName { get; set; }
|
|
||||||
|
|
||||||
// Original record (null for newly-added rows). Preserves fields the editor doesn't expose
|
|
||||||
// across a load→save.
|
|
||||||
private AbLegacyDeviceOptions? _source;
|
|
||||||
|
|
||||||
public AbLegacyDeviceRow Clone() => (AbLegacyDeviceRow)MemberwiseClone(); // _source is an immutable record ref — safe to share
|
|
||||||
|
|
||||||
public static AbLegacyDeviceRow FromDefinition(AbLegacyDeviceOptions d) => new()
|
|
||||||
{
|
|
||||||
HostAddress = d.HostAddress, PlcFamily = d.PlcFamily, DeviceName = d.DeviceName,
|
|
||||||
_source = d,
|
|
||||||
};
|
|
||||||
|
|
||||||
public AbLegacyDeviceOptions ToDefinition()
|
|
||||||
{
|
|
||||||
var baseDef = _source ?? new AbLegacyDeviceOptions(HostAddress.Trim(), PlcFamily);
|
|
||||||
return baseDef with
|
|
||||||
{
|
|
||||||
HostAddress = HostAddress.Trim(),
|
|
||||||
PlcFamily = PlcFamily,
|
|
||||||
DeviceName = string.IsNullOrWhiteSpace(DeviceName) ? null : DeviceName.Trim(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string? ValidateRow(AbLegacyDeviceRow row, IReadOnlyList<AbLegacyDeviceRow> all, int? editIndex)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(row.HostAddress)) return "Host address is required.";
|
|
||||||
for (var i = 0; i < all.Count; i++)
|
|
||||||
if (i != editIndex && string.Equals(all[i].HostAddress, row.HostAddress, StringComparison.OrdinalIgnoreCase))
|
|
||||||
return $"Duplicate device host address '{row.HostAddress}'.";
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Flat mutable model — all scalar properties settable for Blazor @bind-Value.
|
|
||||||
// Collections (Devices, Tags) are kept on the component and passed in on ToOptions().
|
|
||||||
public sealed class FormModel
|
|
||||||
{
|
|
||||||
// Operation
|
|
||||||
public int TimeoutSeconds { get; set; } = 2;
|
|
||||||
|
|
||||||
// Probe
|
|
||||||
public bool ProbeEnabled { get; set; } = true;
|
|
||||||
public int ProbeIntervalSeconds { get; set; } = 5;
|
|
||||||
public int ProbeTimeoutSeconds { get; set; } = 2;
|
|
||||||
public string? ProbeAddress { get; set; } = "S:0";
|
|
||||||
|
|
||||||
// Admin UI probe timeout
|
|
||||||
public int AdminProbeTimeoutSeconds { get; set; } = 5;
|
|
||||||
|
|
||||||
// Persistence
|
|
||||||
public string? ResilienceConfig { get; set; }
|
|
||||||
public byte[] RowVersion { get; set; } = [];
|
|
||||||
|
|
||||||
public static FormModel FromOptions(AbLegacyDriverOptions o) => new()
|
|
||||||
{
|
|
||||||
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
|
|
||||||
ProbeEnabled = o.Probe.Enabled,
|
|
||||||
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
|
|
||||||
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
|
|
||||||
ProbeAddress = o.Probe.ProbeAddress,
|
|
||||||
AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
|
|
||||||
};
|
|
||||||
|
|
||||||
public AbLegacyDriverOptions ToOptions(
|
|
||||||
IReadOnlyList<AbLegacyDeviceOptions> devices) => new()
|
|
||||||
{
|
|
||||||
Devices = devices,
|
|
||||||
Probe = new AbLegacyProbeOptions
|
|
||||||
{
|
|
||||||
Enabled = ProbeEnabled,
|
|
||||||
Interval = TimeSpan.FromSeconds(ProbeIntervalSeconds),
|
|
||||||
Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds),
|
|
||||||
ProbeAddress = string.IsNullOrWhiteSpace(ProbeAddress) ? null : ProbeAddress,
|
|
||||||
},
|
|
||||||
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
|
|
||||||
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-82
@@ -1,82 +0,0 @@
|
|||||||
@* Dispatch page: reads DriverInstance.DriverType and dispatches to the matching typed editor
|
|
||||||
via <DynamicComponent> using _componentMap. Shows an error panel when the driver type has
|
|
||||||
no registered typed page. *@
|
|
||||||
@page "/clusters/{ClusterId}/drivers/{DriverInstanceId}"
|
|
||||||
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
|
|
||||||
@rendermode RenderMode.InteractiveServer
|
|
||||||
@using Microsoft.EntityFrameworkCore
|
|
||||||
@using ZB.MOM.WW.OtOpcUa.Configuration
|
|
||||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
|
||||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
|
||||||
|
|
||||||
@if (!_loaded)
|
|
||||||
{
|
|
||||||
<p>Loading…</p>
|
|
||||||
}
|
|
||||||
else if (_existing is null)
|
|
||||||
{
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
||||||
<h4 class="mb-0">Edit driver instance · <span class="mono">@ClusterId</span></h4>
|
|
||||||
<a href="/clusters/@ClusterId/drivers" class="btn btn-outline-secondary btn-sm">Cancel</a>
|
|
||||||
</div>
|
|
||||||
<ClusterNav ClusterId="@ClusterId" ActiveTab="drivers" />
|
|
||||||
<section class="panel notice rise" style="animation-delay:.02s">
|
|
||||||
Driver instance <span class="mono">@DriverInstanceId</span> was not found in cluster <span class="mono">@ClusterId</span>.
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
else if (ResolveComponentType() is { } pageType)
|
|
||||||
{
|
|
||||||
<DynamicComponent Type="pageType" Parameters="BuildParameters()" />
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
||||||
<h4 class="mb-0">Edit driver instance · <span class="mono">@ClusterId</span></h4>
|
|
||||||
<a href="/clusters/@ClusterId/drivers" class="btn btn-outline-secondary btn-sm">Cancel</a>
|
|
||||||
</div>
|
|
||||||
<ClusterNav ClusterId="@ClusterId" ActiveTab="drivers" />
|
|
||||||
<section class="panel notice rise" style="animation-delay:.02s; border-color:var(--alert)">
|
|
||||||
Driver instance <span class="mono">@DriverInstanceId</span> has an unknown <code>DriverType</code> value of
|
|
||||||
<strong><span class="mono">@_existing.DriverType</span></strong>. No editor is registered for this type.
|
|
||||||
Likely causes: the row was written by a newer build, or the type-string was corrupted in the database.
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
|
|
||||||
@code {
|
|
||||||
[Parameter] public string ClusterId { get; set; } = "";
|
|
||||||
[Parameter] public string DriverInstanceId { get; set; } = "";
|
|
||||||
|
|
||||||
private DriverInstance? _existing;
|
|
||||||
private bool _loaded;
|
|
||||||
|
|
||||||
private static readonly IReadOnlyDictionary<string, Type> _componentMap =
|
|
||||||
new Dictionary<string, Type>(StringComparer.OrdinalIgnoreCase)
|
|
||||||
{
|
|
||||||
["Modbus"] = typeof(ModbusDriverPage),
|
|
||||||
["AbCip"] = typeof(AbCipDriverPage),
|
|
||||||
["AbLegacy"] = typeof(AbLegacyDriverPage),
|
|
||||||
["S7"] = typeof(S7DriverPage),
|
|
||||||
["TwinCat"] = typeof(TwinCATDriverPage),
|
|
||||||
["Focas"] = typeof(FocasDriverPage),
|
|
||||||
["OpcUaClient"] = typeof(OpcUaClientDriverPage),
|
|
||||||
["GalaxyMxGateway"] = typeof(GalaxyDriverPage),
|
|
||||||
};
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
|
||||||
{
|
|
||||||
await using var db = await DbFactory.CreateDbContextAsync();
|
|
||||||
_existing = await db.DriverInstances.AsNoTracking()
|
|
||||||
.FirstOrDefaultAsync(d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
|
|
||||||
_loaded = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Type? ResolveComponentType()
|
|
||||||
=> _componentMap.TryGetValue(_existing!.DriverType, out var t) ? t : null;
|
|
||||||
|
|
||||||
private IDictionary<string, object> BuildParameters()
|
|
||||||
=> new Dictionary<string, object>
|
|
||||||
{
|
|
||||||
["ClusterId"] = ClusterId,
|
|
||||||
["DriverInstanceId"] = DriverInstanceId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
-49
@@ -1,49 +0,0 @@
|
|||||||
@* Driver type picker — presents a card grid of registered driver types and links to the
|
|
||||||
per-type new-driver creation page (/clusters/{ClusterId}/drivers/new/{slug}). *@
|
|
||||||
@page "/clusters/{ClusterId}/drivers/new"
|
|
||||||
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
|
|
||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
||||||
<h4 class="mb-0">New driver · <span class="mono">@ClusterId</span></h4>
|
|
||||||
<a href="/clusters/@ClusterId/drivers" class="btn btn-outline-secondary btn-sm">Cancel</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ClusterNav ClusterId="@ClusterId" ActiveTab="drivers" />
|
|
||||||
|
|
||||||
<section class="panel rise mt-3" style="animation-delay:.08s">
|
|
||||||
<div class="panel-head">Pick a driver type</div>
|
|
||||||
<div style="padding:1rem">
|
|
||||||
<div class="row g-3">
|
|
||||||
@foreach (var t in _types)
|
|
||||||
{
|
|
||||||
<div class="col-md-4 col-lg-3">
|
|
||||||
<a href="/clusters/@ClusterId/drivers/new/@t.Slug" class="card h-100 text-decoration-none">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="text-muted small mono">@t.Icon</div>
|
|
||||||
<div class="card-title mt-1"><strong>@t.DisplayName</strong></div>
|
|
||||||
<div class="card-text small text-muted">@t.Description</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
@code {
|
|
||||||
[Parameter] public string ClusterId { get; set; } = "";
|
|
||||||
|
|
||||||
private sealed record DriverTypeEntry(string DisplayName, string Slug, string Icon, string Description);
|
|
||||||
|
|
||||||
private static readonly IReadOnlyList<DriverTypeEntry> _types = new[]
|
|
||||||
{
|
|
||||||
new DriverTypeEntry("Modbus TCP", "modbustcp", "[M]", "Modbus/TCP — generic registers/coils via port 502."),
|
|
||||||
new DriverTypeEntry("AbCip", "abcip", "[CIP]", "Allen-Bradley CompactLogix/ControlLogix via CIP."),
|
|
||||||
new DriverTypeEntry("AbLegacy", "ablegacy", "[AB]", "Allen-Bradley PLC-5/SLC-500/MicroLogix via DF1."),
|
|
||||||
new DriverTypeEntry("S7", "s7", "[S7]", "Siemens S7-300/400/1200/1500 via ISO-on-TCP."),
|
|
||||||
new DriverTypeEntry("TwinCat", "twincat", "[TC]", "Beckhoff TwinCAT via ADS."),
|
|
||||||
new DriverTypeEntry("Focas", "focas", "[FOC]", "Fanuc CNC via FOCAS library."),
|
|
||||||
new DriverTypeEntry("OpcUaClient", "opcuaclient", "[OPC]", "Upstream OPC UA server pull."),
|
|
||||||
new DriverTypeEntry("Galaxy", "galaxy", "[Gx]", "AVEVA System Platform (Wonderware) via mxaccessgw."),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
-502
@@ -1,502 +0,0 @@
|
|||||||
@page "/clusters/{ClusterId}/drivers/new/focas"
|
|
||||||
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
|
|
||||||
@rendermode RenderMode.InteractiveServer
|
|
||||||
@using Microsoft.AspNetCore.Components.Forms
|
|
||||||
@using Microsoft.EntityFrameworkCore
|
|
||||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients
|
|
||||||
@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
|
|
||||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
|
||||||
@using ZB.MOM.WW.OtOpcUa.Driver.FOCAS
|
|
||||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
|
||||||
@inject NavigationManager Nav
|
|
||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
||||||
<h4 class="mb-0">@(IsNew ? "New Fanuc FOCAS driver" : "Edit Fanuc FOCAS driver") · <span class="mono">@ClusterId</span></h4>
|
|
||||||
<a href="/clusters/@ClusterId/drivers" class="btn btn-outline-secondary btn-sm">Cancel</a>
|
|
||||||
</div>
|
|
||||||
<ClusterNav ClusterId="@ClusterId" ActiveTab="drivers" />
|
|
||||||
|
|
||||||
@if (!_loaded)
|
|
||||||
{
|
|
||||||
<p>Loading…</p>
|
|
||||||
}
|
|
||||||
else if (!IsNew && _existing is null)
|
|
||||||
{
|
|
||||||
<section class="panel notice rise" style="animation-delay:.02s">
|
|
||||||
Driver instance <span class="mono">@DriverInstanceId</span> was not found in cluster <span class="mono">@ClusterId</span>.
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="focasDriverEdit">
|
|
||||||
<DataAnnotationsValidator />
|
|
||||||
<DriverFormShell IsNew="IsNew" Busy="_busy" Error="@_error"
|
|
||||||
CancelHref="@($"/clusters/{ClusterId}/drivers")"
|
|
||||||
OnDelete="@(IsNew ? null : (EventCallback?)EventCallback.Factory.Create(this, DeleteAsync))">
|
|
||||||
|
|
||||||
<DriverIdentitySection Model="_identityModel" IsNew="IsNew" ShowDriverType="false" />
|
|
||||||
|
|
||||||
@if (!IsNew && !string.IsNullOrEmpty(DriverInstanceId))
|
|
||||||
{
|
|
||||||
<DriverStatusPanel DriverInstanceId="@DriverInstanceId" Enabled="@_identityModel.Enabled" />
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="mt-2 mb-3">
|
|
||||||
<DriverTestConnectButton DriverType="@DriverTypeKey"
|
|
||||||
GetConfigJson="@SerializeCurrentConfig"
|
|
||||||
TimeoutSeconds="@_form.AdminProbeTimeoutSeconds" />
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
|
|
||||||
@onclick="@(() => _showPicker = true)">
|
|
||||||
Pick address
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DriverTagPicker @bind-Visible="_showPicker"
|
|
||||||
Title="FOCAS address"
|
|
||||||
CurrentAddress="@_pickedAddress"
|
|
||||||
OnPickAddress="@OnAddressPicked">
|
|
||||||
<FOCASAddressPickerBody CurrentAddress="@_pickedAddress"
|
|
||||||
CurrentAddressChanged="@((s) => _pickedAddress = s)"
|
|
||||||
GetConfigJson="@SerializeCurrentConfig" />
|
|
||||||
</DriverTagPicker>
|
|
||||||
|
|
||||||
@* Connection *@
|
|
||||||
<section class="panel rise mt-3" style="animation-delay:.05s">
|
|
||||||
<div class="panel-head">Connection</div>
|
|
||||||
<div style="padding:1rem">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-3 mb-3">
|
|
||||||
<label class="form-label" for="focasTimeoutSec">Timeout (seconds)</label>
|
|
||||||
<InputNumber id="focasTimeoutSec" @bind-Value="_form.TimeoutSeconds"
|
|
||||||
class="form-control form-control-sm" />
|
|
||||||
<div class="form-text">Per-operation timeout. Default 2 s.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
@* Probe *@
|
|
||||||
<section class="panel rise mt-3" style="animation-delay:.08s">
|
|
||||||
<div class="panel-head">Connectivity probe</div>
|
|
||||||
<div style="padding:1rem">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-3 mb-3">
|
|
||||||
<div class="form-check form-switch mt-2">
|
|
||||||
<InputCheckbox id="focasProbeEnabled" @bind-Value="_form.ProbeEnabled"
|
|
||||||
class="form-check-input" />
|
|
||||||
<label class="form-check-label" for="focasProbeEnabled">Probe enabled</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3 mb-3">
|
|
||||||
<label class="form-label" for="focasProbeInterval">Probe interval (s)</label>
|
|
||||||
<InputNumber id="focasProbeInterval" @bind-Value="_form.ProbeIntervalSeconds"
|
|
||||||
class="form-control form-control-sm" />
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3 mb-3">
|
|
||||||
<label class="form-label" for="focasProbeTimeout">Probe timeout (s)</label>
|
|
||||||
<InputNumber id="focasProbeTimeout" @bind-Value="_form.ProbeTimeoutSeconds"
|
|
||||||
class="form-control form-control-sm" />
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3 mb-3">
|
|
||||||
<label class="form-label" for="focasAdminProbe">Admin probe timeout (s)</label>
|
|
||||||
<InputNumber id="focasAdminProbe" @bind-Value="_form.AdminProbeTimeoutSeconds"
|
|
||||||
class="form-control form-control-sm" />
|
|
||||||
<div class="form-text">Test Connect timeout (1–60 s).</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
@* Alarm projection *@
|
|
||||||
<section class="panel rise mt-3" style="animation-delay:.11s">
|
|
||||||
<div class="panel-head">Alarm projection</div>
|
|
||||||
<div style="padding:1rem">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-3 mb-3">
|
|
||||||
<div class="form-check form-switch mt-2">
|
|
||||||
<InputCheckbox id="focasAlarmEnabled" @bind-Value="_form.AlarmProjectionEnabled"
|
|
||||||
class="form-check-input" />
|
|
||||||
<label class="form-check-label" for="focasAlarmEnabled">Alarm projection enabled</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-text">Surfaces FOCAS alarms via IAlarmSource.</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3 mb-3">
|
|
||||||
<label class="form-label" for="focasAlarmPoll">Alarm poll interval (s)</label>
|
|
||||||
<InputNumber id="focasAlarmPoll" @bind-Value="_form.AlarmProjectionPollIntervalSeconds"
|
|
||||||
class="form-control form-control-sm" />
|
|
||||||
<div class="form-text">One cnc_rdalmmsg2 call per device per tick. Default 2 s.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
@* Handle recycle *@
|
|
||||||
<section class="panel rise mt-3" style="animation-delay:.14s">
|
|
||||||
<div class="panel-head">Handle recycle</div>
|
|
||||||
<div style="padding:1rem">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-3 mb-3">
|
|
||||||
<div class="form-check form-switch mt-2">
|
|
||||||
<InputCheckbox id="focasRecycleEnabled" @bind-Value="_form.HandleRecycleEnabled"
|
|
||||||
class="form-check-input" />
|
|
||||||
<label class="form-check-label" for="focasRecycleEnabled">Handle recycle enabled</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-text">Proactive FWLIB session recycle to prevent handle pool exhaustion. Default off.</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3 mb-3">
|
|
||||||
<label class="form-label" for="focasRecycleInterval">Recycle interval (minutes)</label>
|
|
||||||
<InputNumber id="focasRecycleInterval" @bind-Value="_form.HandleRecycleIntervalMinutes"
|
|
||||||
class="form-control form-control-sm" />
|
|
||||||
<div class="form-text">Typical: 30 min (shared pool) or 360 min (single client).</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
@* Fixed tree *@
|
|
||||||
<section class="panel rise mt-3" style="animation-delay:.17s">
|
|
||||||
<div class="panel-head">Fixed-node tree</div>
|
|
||||||
<div style="padding:1rem">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-3 mb-3">
|
|
||||||
<div class="form-check form-switch mt-2">
|
|
||||||
<InputCheckbox id="focasFixedTree" @bind-Value="_form.FixedTreeEnabled"
|
|
||||||
class="form-check-input" />
|
|
||||||
<label class="form-check-label" for="focasFixedTree">Fixed tree enabled</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-text">Exposes Identity/, Axes/, etc. from cnc_sysinfo/cnc_rdaxisname/cnc_rddynamic2. Default off.</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3 mb-3">
|
|
||||||
<label class="form-label" for="focasAxisPoll">Axis poll interval (ms)</label>
|
|
||||||
<InputNumber id="focasAxisPoll" @bind-Value="_form.FixedTreePollIntervalMs"
|
|
||||||
class="form-control form-control-sm" />
|
|
||||||
<div class="form-text">cnc_rddynamic2 cadence per axis. Default 250 ms.</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3 mb-3">
|
|
||||||
<label class="form-label" for="focasProgramPoll">Program poll interval (s)</label>
|
|
||||||
<InputNumber id="focasProgramPoll" @bind-Value="_form.FixedTreeProgramPollIntervalSeconds"
|
|
||||||
class="form-control form-control-sm" />
|
|
||||||
<div class="form-text">Program/mode info cadence. 0 = disabled. Default 1 s.</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3 mb-3">
|
|
||||||
<label class="form-label" for="focasTimerPoll">Timer poll interval (s)</label>
|
|
||||||
<InputNumber id="focasTimerPoll" @bind-Value="_form.FixedTreeTimerPollIntervalSeconds"
|
|
||||||
class="form-control form-control-sm" />
|
|
||||||
<div class="form-text">Power-on/cutting/cycle timer cadence. 0 = disabled. Default 30 s.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
@* Devices *@
|
|
||||||
<CollectionEditor TRow="FocasDeviceRow" Items="_devices" Title="Devices" ItemNoun="device"
|
|
||||||
AnimationDelay=".20s"
|
|
||||||
NewRow="@(() => new FocasDeviceRow())" Clone="@(r => r.Clone())"
|
|
||||||
Validate="FocasDeviceRow.ValidateRow">
|
|
||||||
<HeaderTemplate>
|
|
||||||
<tr><th>Host address</th><th>CNC series</th><th>Device name</th><th></th></tr>
|
|
||||||
</HeaderTemplate>
|
|
||||||
<RowTemplate Context="d">
|
|
||||||
<td class="mono">@d.HostAddress</td><td>@d.Series</td>
|
|
||||||
<td>@(string.IsNullOrWhiteSpace(d.DeviceName) ? "—" : d.DeviceName)</td>
|
|
||||||
</RowTemplate>
|
|
||||||
<EditTemplate Context="d">
|
|
||||||
<div class="row g-3">
|
|
||||||
<div class="col-md-6"><label class="form-label">Host address</label>
|
|
||||||
<input class="form-control form-control-sm mono" @bind="d.HostAddress"
|
|
||||||
placeholder="192.168.0.10:8193" /></div>
|
|
||||||
<div class="col-md-3"><label class="form-label">CNC series</label>
|
|
||||||
<select class="form-select form-select-sm" @bind="d.Series">
|
|
||||||
@foreach (var e in Enum.GetValues<FocasCncSeries>()) { <option value="@e">@e</option> }
|
|
||||||
</select></div>
|
|
||||||
<div class="col-md-3"><label class="form-label">Device name</label>
|
|
||||||
<input class="form-control form-control-sm" @bind="d.DeviceName" /></div>
|
|
||||||
</div>
|
|
||||||
</EditTemplate>
|
|
||||||
</CollectionEditor>
|
|
||||||
|
|
||||||
@* Tags *@
|
|
||||||
<section class="panel notice rise mt-3" style="animation-delay:.18s">
|
|
||||||
<div class="panel-head">Tags</div>
|
|
||||||
<div style="padding:1rem" class="text-muted">
|
|
||||||
Tag authoring moves to the Raw tree (<strong>/raw</strong>) in v3 Batch 2.
|
|
||||||
Pre-declared driver tags have been retired.
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
|
|
||||||
</DriverFormShell>
|
|
||||||
</EditForm>
|
|
||||||
}
|
|
||||||
|
|
||||||
@code {
|
|
||||||
[Parameter] public string ClusterId { get; set; } = "";
|
|
||||||
[Parameter] public string? DriverInstanceId { get; set; }
|
|
||||||
|
|
||||||
private const string DriverTypeKey = "Focas";
|
|
||||||
|
|
||||||
private bool IsNew => string.IsNullOrEmpty(DriverInstanceId);
|
|
||||||
|
|
||||||
private static readonly System.Text.Json.JsonSerializerOptions _jsonOpts = new()
|
|
||||||
{
|
|
||||||
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
|
|
||||||
UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip,
|
|
||||||
WriteIndented = false,
|
|
||||||
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
|
|
||||||
};
|
|
||||||
|
|
||||||
private FormModel _form = new();
|
|
||||||
private DriverIdentitySection.DriverIdentityModel _identityModel = new() { DriverType = DriverTypeKey };
|
|
||||||
private DriverInstance? _existing;
|
|
||||||
private bool _loaded, _busy;
|
|
||||||
private string? _error;
|
|
||||||
|
|
||||||
// Address picker state
|
|
||||||
private bool _showPicker;
|
|
||||||
private string _pickedAddress = "";
|
|
||||||
|
|
||||||
private void OnAddressPicked(string address) => _pickedAddress = address;
|
|
||||||
|
|
||||||
// Held separately because Devices/Tags are collections — edited via the CollectionEditor modal.
|
|
||||||
private List<FocasDeviceRow> _devices = [];
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
|
||||||
{
|
|
||||||
await using var db = await DbFactory.CreateDbContextAsync();
|
|
||||||
|
|
||||||
if (IsNew)
|
|
||||||
{
|
|
||||||
_identityModel = new() { DriverType = DriverTypeKey, Enabled = true };
|
|
||||||
_form = FormModel.FromOptions(new FocasDriverOptions());
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_existing = await db.DriverInstances.AsNoTracking()
|
|
||||||
.FirstOrDefaultAsync(d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
|
|
||||||
if (_existing is not null)
|
|
||||||
{
|
|
||||||
_identityModel = new()
|
|
||||||
{
|
|
||||||
DriverInstanceId = _existing.DriverInstanceId,
|
|
||||||
Name = _existing.Name,
|
|
||||||
DriverType = _existing.DriverType,
|
|
||||||
Enabled = _existing.Enabled,
|
|
||||||
};
|
|
||||||
var opts = TryDeserialize(_existing.DriverConfig) ?? new FocasDriverOptions();
|
|
||||||
_form = FormModel.FromOptions(opts);
|
|
||||||
_form.ResilienceConfig = _existing.ResilienceConfig;
|
|
||||||
_form.RowVersion = _existing.RowVersion;
|
|
||||||
_devices = opts.Devices.Select(FocasDeviceRow.FromDefinition).ToList();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_loaded = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task SubmitAsync()
|
|
||||||
{
|
|
||||||
_busy = true; _error = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var opts = _form.ToOptions(
|
|
||||||
_devices.Select(r => r.ToDefinition()).ToList());
|
|
||||||
var configJson = System.Text.Json.JsonSerializer.Serialize(opts, _jsonOpts);
|
|
||||||
await using var db = await DbFactory.CreateDbContextAsync();
|
|
||||||
if (IsNew)
|
|
||||||
{
|
|
||||||
if (await db.DriverInstances.AnyAsync(d => d.DriverInstanceId == _identityModel.DriverInstanceId))
|
|
||||||
{
|
|
||||||
_error = $"Driver instance '{_identityModel.DriverInstanceId}' already exists."; return;
|
|
||||||
}
|
|
||||||
db.DriverInstances.Add(new DriverInstance
|
|
||||||
{
|
|
||||||
DriverInstanceId = _identityModel.DriverInstanceId,
|
|
||||||
ClusterId = ClusterId,
|
|
||||||
Name = _identityModel.Name,
|
|
||||||
DriverType = DriverTypeKey,
|
|
||||||
Enabled = _identityModel.Enabled,
|
|
||||||
DriverConfig = configJson,
|
|
||||||
ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var entity = await db.DriverInstances.FirstOrDefaultAsync(
|
|
||||||
d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
|
|
||||||
if (entity is null) { _error = "Row no longer exists."; return; }
|
|
||||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
|
|
||||||
entity.Name = _identityModel.Name;
|
|
||||||
entity.Enabled = _identityModel.Enabled;
|
|
||||||
entity.DriverConfig = configJson;
|
|
||||||
entity.ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig;
|
|
||||||
}
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
|
|
||||||
}
|
|
||||||
catch (DbUpdateConcurrencyException)
|
|
||||||
{
|
|
||||||
_error = "Another user changed this driver instance while you were editing. Reload to see the latest values, then re-apply your changes.";
|
|
||||||
}
|
|
||||||
catch (Exception ex) { _error = ex.Message; }
|
|
||||||
finally { _busy = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task DeleteAsync()
|
|
||||||
{
|
|
||||||
if (IsNew) return;
|
|
||||||
_busy = true; _error = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await using var db = await DbFactory.CreateDbContextAsync();
|
|
||||||
var entity = await db.DriverInstances.FirstOrDefaultAsync(
|
|
||||||
d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
|
|
||||||
if (entity is null) { Nav.NavigateTo($"/clusters/{ClusterId}/drivers"); return; }
|
|
||||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
|
|
||||||
db.DriverInstances.Remove(entity);
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
|
|
||||||
}
|
|
||||||
catch (DbUpdateConcurrencyException)
|
|
||||||
{
|
|
||||||
_error = "Another user changed this driver instance while you were viewing it. Reload before deleting.";
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_error = $"Delete failed: {ex.Message}. (Likely because equipment/tags still reference this driver — remove them first.)";
|
|
||||||
}
|
|
||||||
finally { _busy = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
private string SerializeCurrentConfig()
|
|
||||||
=> System.Text.Json.JsonSerializer.Serialize(
|
|
||||||
_form.ToOptions(
|
|
||||||
_devices.Select(r => r.ToDefinition()).ToList()),
|
|
||||||
_jsonOpts);
|
|
||||||
|
|
||||||
private static FocasDriverOptions? TryDeserialize(string json)
|
|
||||||
{
|
|
||||||
try { return System.Text.Json.JsonSerializer.Deserialize<FocasDriverOptions>(json, _jsonOpts); }
|
|
||||||
catch { return null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mutable VM for the modal editor — FocasDeviceOptions is an immutable record.
|
|
||||||
public sealed class FocasDeviceRow
|
|
||||||
{
|
|
||||||
public string HostAddress { get; set; } = "";
|
|
||||||
public FocasCncSeries Series { get; set; } = FocasCncSeries.Unknown;
|
|
||||||
public string? DeviceName { get; set; }
|
|
||||||
|
|
||||||
// Original record (null for newly-added rows). Preserves any fields the editor doesn't
|
|
||||||
// expose across a load→save.
|
|
||||||
private FocasDeviceOptions? _source;
|
|
||||||
|
|
||||||
public FocasDeviceRow Clone() => (FocasDeviceRow)MemberwiseClone(); // _source is an immutable record ref — safe to share
|
|
||||||
|
|
||||||
public static FocasDeviceRow FromDefinition(FocasDeviceOptions d) => new()
|
|
||||||
{
|
|
||||||
HostAddress = d.HostAddress, Series = d.Series, DeviceName = d.DeviceName,
|
|
||||||
_source = d,
|
|
||||||
};
|
|
||||||
|
|
||||||
public FocasDeviceOptions ToDefinition()
|
|
||||||
{
|
|
||||||
var baseDef = _source ?? new FocasDeviceOptions(HostAddress.Trim());
|
|
||||||
return baseDef with
|
|
||||||
{
|
|
||||||
HostAddress = HostAddress.Trim(),
|
|
||||||
Series = Series,
|
|
||||||
DeviceName = string.IsNullOrWhiteSpace(DeviceName) ? null : DeviceName.Trim(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string? ValidateRow(FocasDeviceRow row, IReadOnlyList<FocasDeviceRow> all, int? editIndex)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(row.HostAddress)) return "Host address is required.";
|
|
||||||
for (var i = 0; i < all.Count; i++)
|
|
||||||
if (i != editIndex && string.Equals(all[i].HostAddress, row.HostAddress, StringComparison.OrdinalIgnoreCase))
|
|
||||||
return $"Duplicate device host address '{row.HostAddress}'.";
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Flat mutable model — all scalar properties settable for Blazor @bind-Value.
|
|
||||||
// Collections (Devices, Tags) are kept on the component and passed in on ToOptions().
|
|
||||||
public sealed class FormModel
|
|
||||||
{
|
|
||||||
// Connection
|
|
||||||
public int TimeoutSeconds { get; set; } = 2;
|
|
||||||
|
|
||||||
// Probe
|
|
||||||
public bool ProbeEnabled { get; set; } = true;
|
|
||||||
public int ProbeIntervalSeconds { get; set; } = 5;
|
|
||||||
public int ProbeTimeoutSeconds { get; set; } = 2;
|
|
||||||
public int AdminProbeTimeoutSeconds { get; set; } = 10;
|
|
||||||
|
|
||||||
// Alarm projection
|
|
||||||
public bool AlarmProjectionEnabled { get; set; } = false;
|
|
||||||
public int AlarmProjectionPollIntervalSeconds { get; set; } = 2;
|
|
||||||
|
|
||||||
// Handle recycle
|
|
||||||
public bool HandleRecycleEnabled { get; set; } = false;
|
|
||||||
public int HandleRecycleIntervalMinutes { get; set; } = 60;
|
|
||||||
|
|
||||||
// Fixed tree
|
|
||||||
public bool FixedTreeEnabled { get; set; } = false;
|
|
||||||
public int FixedTreePollIntervalMs { get; set; } = 250;
|
|
||||||
public int FixedTreeProgramPollIntervalSeconds { get; set; } = 1;
|
|
||||||
public int FixedTreeTimerPollIntervalSeconds { get; set; } = 30;
|
|
||||||
|
|
||||||
// Common
|
|
||||||
public string? ResilienceConfig { get; set; }
|
|
||||||
public byte[] RowVersion { get; set; } = [];
|
|
||||||
|
|
||||||
public static FormModel FromOptions(FocasDriverOptions o) => new()
|
|
||||||
{
|
|
||||||
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
|
|
||||||
ProbeEnabled = o.Probe.Enabled,
|
|
||||||
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
|
|
||||||
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
|
|
||||||
AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
|
|
||||||
AlarmProjectionEnabled = o.AlarmProjection.Enabled,
|
|
||||||
AlarmProjectionPollIntervalSeconds = (int)o.AlarmProjection.PollInterval.TotalSeconds,
|
|
||||||
HandleRecycleEnabled = o.HandleRecycle.Enabled,
|
|
||||||
HandleRecycleIntervalMinutes = (int)o.HandleRecycle.Interval.TotalMinutes,
|
|
||||||
FixedTreeEnabled = o.FixedTree.Enabled,
|
|
||||||
FixedTreePollIntervalMs = (int)o.FixedTree.PollInterval.TotalMilliseconds,
|
|
||||||
FixedTreeProgramPollIntervalSeconds = (int)o.FixedTree.ProgramPollInterval.TotalSeconds,
|
|
||||||
FixedTreeTimerPollIntervalSeconds = (int)o.FixedTree.TimerPollInterval.TotalSeconds,
|
|
||||||
};
|
|
||||||
|
|
||||||
public FocasDriverOptions ToOptions(
|
|
||||||
IReadOnlyList<FocasDeviceOptions> devices) => new()
|
|
||||||
{
|
|
||||||
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
|
|
||||||
Probe = new FocasProbeOptions
|
|
||||||
{
|
|
||||||
Enabled = ProbeEnabled,
|
|
||||||
Interval = TimeSpan.FromSeconds(ProbeIntervalSeconds),
|
|
||||||
Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds),
|
|
||||||
},
|
|
||||||
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
|
|
||||||
AlarmProjection = new FocasAlarmProjectionOptions
|
|
||||||
{
|
|
||||||
Enabled = AlarmProjectionEnabled,
|
|
||||||
PollInterval = TimeSpan.FromSeconds(AlarmProjectionPollIntervalSeconds),
|
|
||||||
},
|
|
||||||
HandleRecycle = new FocasHandleRecycleOptions
|
|
||||||
{
|
|
||||||
Enabled = HandleRecycleEnabled,
|
|
||||||
Interval = TimeSpan.FromMinutes(HandleRecycleIntervalMinutes),
|
|
||||||
},
|
|
||||||
FixedTree = new FocasFixedTreeOptions
|
|
||||||
{
|
|
||||||
Enabled = FixedTreeEnabled,
|
|
||||||
PollInterval = TimeSpan.FromMilliseconds(FixedTreePollIntervalMs),
|
|
||||||
ProgramPollInterval = TimeSpan.FromSeconds(FixedTreeProgramPollIntervalSeconds),
|
|
||||||
TimerPollInterval = TimeSpan.FromSeconds(FixedTreeTimerPollIntervalSeconds),
|
|
||||||
},
|
|
||||||
Devices = devices,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-460
@@ -1,460 +0,0 @@
|
|||||||
@page "/clusters/{ClusterId}/drivers/new/galaxy"
|
|
||||||
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
|
|
||||||
@rendermode RenderMode.InteractiveServer
|
|
||||||
@using Microsoft.AspNetCore.Components.Forms
|
|
||||||
@using Microsoft.EntityFrameworkCore
|
|
||||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients
|
|
||||||
@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
|
|
||||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
|
||||||
@using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config
|
|
||||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
|
||||||
@inject NavigationManager Nav
|
|
||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
||||||
<h4 class="mb-0">@(IsNew ? "New AVEVA Galaxy driver" : "Edit AVEVA Galaxy driver") · <span class="mono">@ClusterId</span></h4>
|
|
||||||
<a href="/clusters/@ClusterId/drivers" class="btn btn-outline-secondary btn-sm">Cancel</a>
|
|
||||||
</div>
|
|
||||||
<ClusterNav ClusterId="@ClusterId" ActiveTab="drivers" />
|
|
||||||
|
|
||||||
@if (!_loaded)
|
|
||||||
{
|
|
||||||
<p>Loading…</p>
|
|
||||||
}
|
|
||||||
else if (!IsNew && _existing is null)
|
|
||||||
{
|
|
||||||
<section class="panel notice rise" style="animation-delay:.02s">
|
|
||||||
Driver instance <span class="mono">@DriverInstanceId</span> was not found in cluster <span class="mono">@ClusterId</span>.
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="galaxyDriverEdit">
|
|
||||||
<DataAnnotationsValidator />
|
|
||||||
<DriverFormShell IsNew="IsNew" Busy="_busy" Error="@_error"
|
|
||||||
CancelHref="@($"/clusters/{ClusterId}/drivers")"
|
|
||||||
OnDelete="@(IsNew ? null : (EventCallback?)EventCallback.Factory.Create(this, DeleteAsync))">
|
|
||||||
|
|
||||||
<DriverIdentitySection Model="_identityModel" IsNew="IsNew" ShowDriverType="false" />
|
|
||||||
|
|
||||||
@if (!IsNew && !string.IsNullOrEmpty(DriverInstanceId))
|
|
||||||
{
|
|
||||||
<DriverStatusPanel DriverInstanceId="@DriverInstanceId" Enabled="@_identityModel.Enabled" />
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="mt-2 mb-3">
|
|
||||||
<DriverTestConnectButton DriverType="@DriverTypeKey"
|
|
||||||
GetConfigJson="@SerializeCurrentConfig"
|
|
||||||
TimeoutSeconds="@_form.Galaxy.ProbeTimeoutSeconds" />
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
|
|
||||||
@onclick="@(() => _showPicker = true)">
|
|
||||||
Pick address
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DriverTagPicker @bind-Visible="_showPicker"
|
|
||||||
Title="Galaxy address"
|
|
||||||
CurrentAddress="@_pickedAddress"
|
|
||||||
OnPickAddress="@OnAddressPicked">
|
|
||||||
<GalaxyAddressPickerBody CurrentAddress="@_pickedAddress"
|
|
||||||
CurrentAddressChanged="@((s) => _pickedAddress = s)"
|
|
||||||
GetConfigJson="@SerializeCurrentConfig" />
|
|
||||||
</DriverTagPicker>
|
|
||||||
|
|
||||||
@* mxaccessgw connection *@
|
|
||||||
<section class="panel rise mt-3" style="animation-delay:.06s">
|
|
||||||
<div class="panel-head">mxaccessgw connection</div>
|
|
||||||
<div style="padding:1rem">
|
|
||||||
<div class="row g-3">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label class="form-label">Gateway endpoint</label>
|
|
||||||
<InputText @bind-Value="_form.Galaxy.GatewayEndpoint" class="form-control form-control-sm mono"
|
|
||||||
placeholder="https://localhost:5001" />
|
|
||||||
<div class="form-text">gRPC endpoint of the mxaccessgw process.</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label class="form-label">API key secret ref</label>
|
|
||||||
<InputText @bind-Value="_form.Galaxy.GatewayApiKeySecretRef" class="form-control form-control-sm mono"
|
|
||||||
placeholder="env:MX_API_KEY" />
|
|
||||||
<div class="form-text">Forms: <code>env:NAME</code>, <code>file:PATH</code>, <code>dev:KEY</code>. Cleartext is accepted but triggers a startup warning.</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="form-check form-switch mt-4">
|
|
||||||
<InputCheckbox @bind-Value="_form.Galaxy.GatewayUseTls" class="form-check-input" id="gwTls" />
|
|
||||||
<label class="form-check-label" for="gwTls">Use TLS</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-5">
|
|
||||||
<label class="form-label">CA certificate path (optional)</label>
|
|
||||||
<InputText @bind-Value="_form.Galaxy.GatewayCaCertificatePath" class="form-control form-control-sm mono"
|
|
||||||
placeholder="C:\certs\ca.pem" />
|
|
||||||
</div>
|
|
||||||
<div class="col-md-2">
|
|
||||||
<label class="form-label">Connect timeout (s)</label>
|
|
||||||
<InputNumber @bind-Value="_form.Galaxy.GatewayConnectTimeoutSeconds" class="form-control form-control-sm" />
|
|
||||||
<div class="form-text">Default 10 s.</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-2">
|
|
||||||
<label class="form-label">Call timeout (s)</label>
|
|
||||||
<InputNumber @bind-Value="_form.Galaxy.GatewayDefaultCallTimeoutSeconds" class="form-control form-control-sm" />
|
|
||||||
<div class="form-text">Default 30 s.</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-2">
|
|
||||||
<label class="form-label">Stream timeout (s, 0 = unlimited)</label>
|
|
||||||
<InputNumber @bind-Value="_form.Galaxy.GatewayStreamTimeoutSeconds" class="form-control form-control-sm" />
|
|
||||||
<div class="form-text">Default 0 (lifetime of driver).</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
@* MXAccess *@
|
|
||||||
<section class="panel rise mt-3" style="animation-delay:.08s">
|
|
||||||
<div class="panel-head">MXAccess</div>
|
|
||||||
<div style="padding:1rem">
|
|
||||||
<div class="row g-3">
|
|
||||||
<div class="col-md-4">
|
|
||||||
<label class="form-label">Client name</label>
|
|
||||||
<InputText @bind-Value="_form.Galaxy.MxClientName" class="form-control form-control-sm"
|
|
||||||
placeholder="OtOpcUa-Primary" />
|
|
||||||
<div class="form-text">Must be unique per OtOpcUa instance — redundancy pairs each get a distinct name.</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label">Publishing interval (ms)</label>
|
|
||||||
<InputNumber @bind-Value="_form.Galaxy.MxPublishingIntervalMs" class="form-control form-control-sm" />
|
|
||||||
<div class="form-text">Default 1000 ms.</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-2">
|
|
||||||
<label class="form-label">Write user ID</label>
|
|
||||||
<InputNumber @bind-Value="_form.Galaxy.MxWriteUserId" class="form-control form-control-sm" />
|
|
||||||
<div class="form-text">0 = anonymous.</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label">Event pump channel capacity</label>
|
|
||||||
<InputNumber @bind-Value="_form.Galaxy.MxEventPumpChannelCapacity" class="form-control form-control-sm" />
|
|
||||||
<div class="form-text">Default 50000. Raise if events.dropped appears.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
@* Repository *@
|
|
||||||
<section class="panel rise mt-3" style="animation-delay:.10s">
|
|
||||||
<div class="panel-head">Galaxy repository</div>
|
|
||||||
<div style="padding:1rem">
|
|
||||||
<div class="row g-3">
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label">Discover page size</label>
|
|
||||||
<InputNumber @bind-Value="_form.Galaxy.RepositoryDiscoverPageSize" class="form-control form-control-sm" />
|
|
||||||
<div class="form-text">Default 5000 objects per page.</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="form-check form-switch mt-4">
|
|
||||||
<InputCheckbox @bind-Value="_form.Galaxy.RepositoryWatchDeployEvents" class="form-check-input" id="watchDeploy" />
|
|
||||||
<label class="form-check-label" for="watchDeploy">Watch deploy events</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-text mt-0">Triggers address-space rebuild on Galaxy re-deploy.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
@* Reconnect *@
|
|
||||||
<section class="panel rise mt-3" style="animation-delay:.12s">
|
|
||||||
<div class="panel-head">Reconnect backoff</div>
|
|
||||||
<div style="padding:1rem">
|
|
||||||
<div class="row g-3">
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label">Initial backoff (ms)</label>
|
|
||||||
<InputNumber @bind-Value="_form.Galaxy.ReconnectInitialBackoffMs" class="form-control form-control-sm" />
|
|
||||||
<div class="form-text">Default 500 ms.</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label">Max backoff (ms)</label>
|
|
||||||
<InputNumber @bind-Value="_form.Galaxy.ReconnectMaxBackoffMs" class="form-control form-control-sm" />
|
|
||||||
<div class="form-text">Default 30000 ms.</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="form-check form-switch mt-4">
|
|
||||||
<InputCheckbox @bind-Value="_form.Galaxy.ReconnectReplayOnSessionLost" class="form-check-input" id="replayOnLost" />
|
|
||||||
<label class="form-check-label" for="replayOnLost">Replay subscriptions on session lost</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
@* Diagnostics *@
|
|
||||||
<section class="panel rise mt-3" style="animation-delay:.14s">
|
|
||||||
<div class="panel-head">Diagnostics</div>
|
|
||||||
<div style="padding:1rem">
|
|
||||||
<div class="row g-3">
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label">Admin UI probe timeout (seconds)</label>
|
|
||||||
<InputNumber @bind-Value="_form.Galaxy.ProbeTimeoutSeconds" class="form-control form-control-sm" />
|
|
||||||
<div class="form-text">Max 60. Used by Test Connect. Default 30.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
|
|
||||||
</DriverFormShell>
|
|
||||||
</EditForm>
|
|
||||||
}
|
|
||||||
|
|
||||||
@code {
|
|
||||||
[Parameter] public string ClusterId { get; set; } = "";
|
|
||||||
[Parameter] public string? DriverInstanceId { get; set; }
|
|
||||||
|
|
||||||
private const string DriverTypeKey = "GalaxyMxGateway";
|
|
||||||
|
|
||||||
private bool IsNew => string.IsNullOrEmpty(DriverInstanceId);
|
|
||||||
|
|
||||||
private static readonly System.Text.Json.JsonSerializerOptions _jsonOpts = new()
|
|
||||||
{
|
|
||||||
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
|
|
||||||
PropertyNameCaseInsensitive = true,
|
|
||||||
UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip,
|
|
||||||
WriteIndented = false,
|
|
||||||
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
|
|
||||||
};
|
|
||||||
|
|
||||||
private FormModel _form = new();
|
|
||||||
private DriverIdentitySection.DriverIdentityModel _identityModel = new() { DriverType = DriverTypeKey };
|
|
||||||
private DriverInstance? _existing;
|
|
||||||
private bool _loaded;
|
|
||||||
private bool _busy;
|
|
||||||
private string? _error;
|
|
||||||
|
|
||||||
// Address picker state
|
|
||||||
private bool _showPicker;
|
|
||||||
private string _pickedAddress = "";
|
|
||||||
|
|
||||||
private void OnAddressPicked(string address) => _pickedAddress = address;
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
|
||||||
{
|
|
||||||
await using var db = await DbFactory.CreateDbContextAsync();
|
|
||||||
|
|
||||||
if (IsNew)
|
|
||||||
{
|
|
||||||
_identityModel = new()
|
|
||||||
{
|
|
||||||
DriverInstanceId = "",
|
|
||||||
Name = "",
|
|
||||||
DriverType = DriverTypeKey,
|
|
||||||
Enabled = true,
|
|
||||||
};
|
|
||||||
_form = new FormModel();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_existing = await db.DriverInstances.AsNoTracking()
|
|
||||||
.FirstOrDefaultAsync(d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
|
|
||||||
if (_existing is not null)
|
|
||||||
{
|
|
||||||
_identityModel = new()
|
|
||||||
{
|
|
||||||
DriverInstanceId = _existing.DriverInstanceId,
|
|
||||||
Name = _existing.Name,
|
|
||||||
DriverType = _existing.DriverType,
|
|
||||||
Enabled = _existing.Enabled,
|
|
||||||
};
|
|
||||||
var opts = TryDeserialize(_existing.DriverConfig) ?? CreateDefaultOptions();
|
|
||||||
_form = new FormModel();
|
|
||||||
_form.Galaxy = GalaxyFormModel.FromRecord(opts);
|
|
||||||
_form.ResilienceConfig = _existing.ResilienceConfig;
|
|
||||||
_form.RowVersion = _existing.RowVersion;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_loaded = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static GalaxyDriverOptions CreateDefaultOptions() => new(
|
|
||||||
Gateway: new GalaxyGatewayOptions("https://localhost:5001", "env:MX_API_KEY"),
|
|
||||||
MxAccess: new GalaxyMxAccessOptions("OtOpcUa"),
|
|
||||||
Repository: new GalaxyRepositoryOptions(),
|
|
||||||
Reconnect: new GalaxyReconnectOptions());
|
|
||||||
|
|
||||||
private async Task SubmitAsync()
|
|
||||||
{
|
|
||||||
_busy = true; _error = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var opts = _form.Galaxy.ToRecord();
|
|
||||||
var configJson = System.Text.Json.JsonSerializer.Serialize(opts, _jsonOpts);
|
|
||||||
await using var db = await DbFactory.CreateDbContextAsync();
|
|
||||||
if (IsNew)
|
|
||||||
{
|
|
||||||
if (await db.DriverInstances.AnyAsync(d => d.DriverInstanceId == _identityModel.DriverInstanceId))
|
|
||||||
{
|
|
||||||
_error = $"Driver instance '{_identityModel.DriverInstanceId}' already exists."; return;
|
|
||||||
}
|
|
||||||
db.DriverInstances.Add(new DriverInstance
|
|
||||||
{
|
|
||||||
DriverInstanceId = _identityModel.DriverInstanceId,
|
|
||||||
ClusterId = ClusterId,
|
|
||||||
Name = _identityModel.Name,
|
|
||||||
DriverType = DriverTypeKey,
|
|
||||||
Enabled = _identityModel.Enabled,
|
|
||||||
DriverConfig = configJson,
|
|
||||||
ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var entity = await db.DriverInstances.FirstOrDefaultAsync(
|
|
||||||
d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
|
|
||||||
if (entity is null) { _error = "Row no longer exists."; return; }
|
|
||||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
|
|
||||||
entity.Name = _identityModel.Name;
|
|
||||||
entity.Enabled = _identityModel.Enabled;
|
|
||||||
entity.DriverConfig = configJson;
|
|
||||||
entity.ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig;
|
|
||||||
}
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
|
|
||||||
}
|
|
||||||
catch (DbUpdateConcurrencyException)
|
|
||||||
{
|
|
||||||
_error = "Another user changed this driver instance while you were editing. Reload to see the latest values, then re-apply your changes.";
|
|
||||||
}
|
|
||||||
catch (Exception ex) { _error = ex.Message; }
|
|
||||||
finally { _busy = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task DeleteAsync()
|
|
||||||
{
|
|
||||||
if (IsNew) return;
|
|
||||||
_busy = true; _error = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await using var db = await DbFactory.CreateDbContextAsync();
|
|
||||||
var entity = await db.DriverInstances.FirstOrDefaultAsync(
|
|
||||||
d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
|
|
||||||
if (entity is null) { Nav.NavigateTo($"/clusters/{ClusterId}/drivers"); return; }
|
|
||||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
|
|
||||||
db.DriverInstances.Remove(entity);
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
|
|
||||||
}
|
|
||||||
catch (DbUpdateConcurrencyException)
|
|
||||||
{
|
|
||||||
_error = "Another user changed this driver instance while you were viewing it. Reload before deleting.";
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_error = $"Delete failed: {ex.Message}. (Likely because equipment/tags still reference this driver — remove them first.)";
|
|
||||||
}
|
|
||||||
finally { _busy = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
private string SerializeCurrentConfig()
|
|
||||||
=> System.Text.Json.JsonSerializer.Serialize(_form.Galaxy.ToRecord(), _jsonOpts);
|
|
||||||
|
|
||||||
private static GalaxyDriverOptions? TryDeserialize(string json)
|
|
||||||
{
|
|
||||||
try { return System.Text.Json.JsonSerializer.Deserialize<GalaxyDriverOptions>(json, _jsonOpts); }
|
|
||||||
catch { return null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class FormModel
|
|
||||||
{
|
|
||||||
public GalaxyFormModel Galaxy { get; set; } = new();
|
|
||||||
public string? ResilienceConfig { get; set; }
|
|
||||||
public byte[] RowVersion { get; set; } = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Mutable flat mirror of <see cref="GalaxyDriverOptions"/> and its nested records.
|
|
||||||
/// All positional-record fields are flattened with a section prefix to avoid name
|
|
||||||
/// collisions. <see cref="FromRecord"/> / <see cref="ToRecord"/> handle translation.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class GalaxyFormModel
|
|
||||||
{
|
|
||||||
// GalaxyGatewayOptions
|
|
||||||
public string GatewayEndpoint { get; set; } = "https://localhost:5001";
|
|
||||||
public string GatewayApiKeySecretRef { get; set; } = "env:MX_API_KEY";
|
|
||||||
public bool GatewayUseTls { get; set; } = true;
|
|
||||||
public string? GatewayCaCertificatePath { get; set; }
|
|
||||||
public int GatewayConnectTimeoutSeconds { get; set; } = 10;
|
|
||||||
public int GatewayDefaultCallTimeoutSeconds { get; set; } = 30;
|
|
||||||
public int GatewayStreamTimeoutSeconds { get; set; } = 0;
|
|
||||||
|
|
||||||
// GalaxyMxAccessOptions
|
|
||||||
public string MxClientName { get; set; } = "OtOpcUa";
|
|
||||||
public int MxPublishingIntervalMs { get; set; } = 1000;
|
|
||||||
public int MxWriteUserId { get; set; } = 0;
|
|
||||||
public int MxEventPumpChannelCapacity { get; set; } = 50_000;
|
|
||||||
|
|
||||||
// GalaxyRepositoryOptions
|
|
||||||
public int RepositoryDiscoverPageSize { get; set; } = 5000;
|
|
||||||
public bool RepositoryWatchDeployEvents { get; set; } = true;
|
|
||||||
|
|
||||||
// GalaxyReconnectOptions
|
|
||||||
public int ReconnectInitialBackoffMs { get; set; } = 500;
|
|
||||||
public int ReconnectMaxBackoffMs { get; set; } = 30_000;
|
|
||||||
public bool ReconnectReplayOnSessionLost { get; set; } = true;
|
|
||||||
|
|
||||||
// GalaxyDriverOptions top-level
|
|
||||||
public int ProbeTimeoutSeconds { get; set; } = 30;
|
|
||||||
|
|
||||||
public static GalaxyFormModel FromRecord(GalaxyDriverOptions r)
|
|
||||||
{
|
|
||||||
// Null-coalesce each nested record to its default so that persisted configs
|
|
||||||
// that pre-date a section (e.g. no Reconnect key, or PascalCase keys that
|
|
||||||
// don't match the camelCase deserializer) don't cause a NullReferenceException.
|
|
||||||
var gw = r.Gateway ?? new GalaxyGatewayOptions("https://localhost:5001", "env:MX_API_KEY");
|
|
||||||
var mx = r.MxAccess ?? new GalaxyMxAccessOptions("OtOpcUa");
|
|
||||||
var repo = r.Repository ?? new GalaxyRepositoryOptions();
|
|
||||||
var rc = r.Reconnect ?? new GalaxyReconnectOptions();
|
|
||||||
return new()
|
|
||||||
{
|
|
||||||
GatewayEndpoint = gw.Endpoint,
|
|
||||||
GatewayApiKeySecretRef = gw.ApiKeySecretRef,
|
|
||||||
GatewayUseTls = gw.UseTls,
|
|
||||||
GatewayCaCertificatePath = gw.CaCertificatePath,
|
|
||||||
GatewayConnectTimeoutSeconds = gw.ConnectTimeoutSeconds,
|
|
||||||
GatewayDefaultCallTimeoutSeconds = gw.DefaultCallTimeoutSeconds,
|
|
||||||
GatewayStreamTimeoutSeconds = gw.StreamTimeoutSeconds,
|
|
||||||
MxClientName = mx.ClientName,
|
|
||||||
MxPublishingIntervalMs = mx.PublishingIntervalMs,
|
|
||||||
MxWriteUserId = mx.WriteUserId,
|
|
||||||
MxEventPumpChannelCapacity = mx.EventPumpChannelCapacity,
|
|
||||||
RepositoryDiscoverPageSize = repo.DiscoverPageSize,
|
|
||||||
RepositoryWatchDeployEvents = repo.WatchDeployEvents,
|
|
||||||
ReconnectInitialBackoffMs = rc.InitialBackoffMs,
|
|
||||||
ReconnectMaxBackoffMs = rc.MaxBackoffMs,
|
|
||||||
ReconnectReplayOnSessionLost = rc.ReplayOnSessionLost,
|
|
||||||
ProbeTimeoutSeconds = r.ProbeTimeoutSeconds,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public GalaxyDriverOptions ToRecord() => new(
|
|
||||||
Gateway: new GalaxyGatewayOptions(
|
|
||||||
Endpoint: GatewayEndpoint,
|
|
||||||
ApiKeySecretRef: GatewayApiKeySecretRef,
|
|
||||||
UseTls: GatewayUseTls,
|
|
||||||
CaCertificatePath: string.IsNullOrWhiteSpace(GatewayCaCertificatePath) ? null : GatewayCaCertificatePath,
|
|
||||||
ConnectTimeoutSeconds: GatewayConnectTimeoutSeconds,
|
|
||||||
DefaultCallTimeoutSeconds: GatewayDefaultCallTimeoutSeconds,
|
|
||||||
StreamTimeoutSeconds: GatewayStreamTimeoutSeconds),
|
|
||||||
MxAccess: new GalaxyMxAccessOptions(
|
|
||||||
ClientName: MxClientName,
|
|
||||||
PublishingIntervalMs: MxPublishingIntervalMs,
|
|
||||||
WriteUserId: MxWriteUserId,
|
|
||||||
EventPumpChannelCapacity: MxEventPumpChannelCapacity),
|
|
||||||
Repository: new GalaxyRepositoryOptions(
|
|
||||||
DiscoverPageSize: RepositoryDiscoverPageSize,
|
|
||||||
WatchDeployEvents: RepositoryWatchDeployEvents),
|
|
||||||
Reconnect: new GalaxyReconnectOptions(
|
|
||||||
InitialBackoffMs: ReconnectInitialBackoffMs,
|
|
||||||
MaxBackoffMs: ReconnectMaxBackoffMs,
|
|
||||||
ReplayOnSessionLost: ReconnectReplayOnSessionLost))
|
|
||||||
{
|
|
||||||
ProbeTimeoutSeconds = ProbeTimeoutSeconds,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-572
@@ -1,572 +0,0 @@
|
|||||||
@page "/clusters/{ClusterId}/drivers/new/modbustcp"
|
|
||||||
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
|
|
||||||
@rendermode RenderMode.InteractiveServer
|
|
||||||
@using Microsoft.AspNetCore.Components.Forms
|
|
||||||
@using Microsoft.EntityFrameworkCore
|
|
||||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients
|
|
||||||
@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
|
|
||||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
|
||||||
@using ZB.MOM.WW.OtOpcUa.Driver.Modbus
|
|
||||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
|
||||||
@inject NavigationManager Nav
|
|
||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
||||||
<h4 class="mb-0">@(IsNew ? "New Modbus/TCP driver" : "Edit Modbus/TCP driver") · <span class="mono">@ClusterId</span></h4>
|
|
||||||
<a href="/clusters/@ClusterId/drivers" class="btn btn-outline-secondary btn-sm">Cancel</a>
|
|
||||||
</div>
|
|
||||||
<ClusterNav ClusterId="@ClusterId" ActiveTab="drivers" />
|
|
||||||
|
|
||||||
@if (!_loaded)
|
|
||||||
{
|
|
||||||
<p>Loading…</p>
|
|
||||||
}
|
|
||||||
else if (!IsNew && _existing is null)
|
|
||||||
{
|
|
||||||
<section class="panel notice rise" style="animation-delay:.02s">
|
|
||||||
Driver instance <span class="mono">@DriverInstanceId</span> was not found in cluster <span class="mono">@ClusterId</span>.
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="modbustcpDriverEdit">
|
|
||||||
<DataAnnotationsValidator />
|
|
||||||
<DriverFormShell IsNew="IsNew" Busy="_busy" Error="@_error"
|
|
||||||
CancelHref="@($"/clusters/{ClusterId}/drivers")"
|
|
||||||
OnDelete="@(IsNew ? null : (EventCallback?)EventCallback.Factory.Create(this, DeleteAsync))">
|
|
||||||
|
|
||||||
<DriverIdentitySection Model="_identityModel" IsNew="IsNew" ShowDriverType="false" />
|
|
||||||
|
|
||||||
@if (!IsNew && !string.IsNullOrEmpty(DriverInstanceId))
|
|
||||||
{
|
|
||||||
<DriverStatusPanel DriverInstanceId="@DriverInstanceId" Enabled="@_identityModel.Enabled" />
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="mt-2 mb-3">
|
|
||||||
<DriverTestConnectButton DriverType="@DriverTypeKey"
|
|
||||||
GetConfigJson="@SerializeCurrentConfig"
|
|
||||||
TimeoutSeconds="@_form.AdminProbeTimeoutSeconds" />
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
|
|
||||||
@onclick="@(() => _showPicker = true)">
|
|
||||||
Pick address
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DriverTagPicker @bind-Visible="_showPicker"
|
|
||||||
Title="Modbus address"
|
|
||||||
CurrentAddress="@_pickedAddress"
|
|
||||||
OnPickAddress="@OnAddressPicked">
|
|
||||||
<ModbusAddressPickerBody CurrentAddress="@_pickedAddress"
|
|
||||||
CurrentAddressChanged="@((s) => _pickedAddress = s)" />
|
|
||||||
</DriverTagPicker>
|
|
||||||
|
|
||||||
@* Transport *@
|
|
||||||
<section class="panel rise mt-3" style="animation-delay:.06s">
|
|
||||||
<div class="panel-head">Transport</div>
|
|
||||||
<div style="padding:1rem">
|
|
||||||
<div class="row g-3">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label class="form-label">Host</label>
|
|
||||||
<InputText @bind-Value="_form.Host" class="form-control form-control-sm" placeholder="127.0.0.1" />
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label">Port</label>
|
|
||||||
<InputNumber @bind-Value="_form.Port" class="form-control form-control-sm" />
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label">Unit ID (slave ID)</label>
|
|
||||||
<InputNumber @bind-Value="_form.UnitId" class="form-control form-control-sm" />
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label">Timeout (seconds)</label>
|
|
||||||
<InputNumber @bind-Value="_form.TimeoutSeconds" class="form-control form-control-sm" />
|
|
||||||
<div class="form-text">Default 2 s.</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label">PLC family</label>
|
|
||||||
<InputSelect @bind-Value="_form.Family" class="form-select form-select-sm">
|
|
||||||
@foreach (var e in Enum.GetValues<ModbusFamily>())
|
|
||||||
{
|
|
||||||
<option value="@e">@e</option>
|
|
||||||
}
|
|
||||||
</InputSelect>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label">MELSEC sub-family</label>
|
|
||||||
<InputSelect @bind-Value="_form.MelsecSubFamily" class="form-select form-select-sm">
|
|
||||||
@foreach (var e in Enum.GetValues<MelsecFamily>())
|
|
||||||
{
|
|
||||||
<option value="@e">@e</option>
|
|
||||||
}
|
|
||||||
</InputSelect>
|
|
||||||
<div class="form-text">Only used when Family = MELSEC.</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="form-check form-switch mt-4">
|
|
||||||
<InputCheckbox @bind-Value="_form.AutoReconnect" class="form-check-input" id="autoReconnect" />
|
|
||||||
<label class="form-check-label" for="autoReconnect">Auto-reconnect</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
@* Batch sizes *@
|
|
||||||
<section class="panel rise mt-3" style="animation-delay:.08s">
|
|
||||||
<div class="panel-head">Batch sizes</div>
|
|
||||||
<div style="padding:1rem">
|
|
||||||
<div class="row g-3">
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label">Max registers per read</label>
|
|
||||||
<InputNumber @bind-Value="_form.MaxRegistersPerRead" class="form-control form-control-sm" />
|
|
||||||
<div class="form-text">Spec max 125. Reduce for limited devices.</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label">Max registers per write</label>
|
|
||||||
<InputNumber @bind-Value="_form.MaxRegistersPerWrite" class="form-control form-control-sm" />
|
|
||||||
<div class="form-text">Spec max 123.</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label">Max coils per read</label>
|
|
||||||
<InputNumber @bind-Value="_form.MaxCoilsPerRead" class="form-control form-control-sm" />
|
|
||||||
<div class="form-text">Spec max 2000.</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label">Max read gap (coalescing)</label>
|
|
||||||
<InputNumber @bind-Value="_form.MaxReadGap" class="form-control form-control-sm" />
|
|
||||||
<div class="form-text">0 = no coalescing. Typical 5–32.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
@* Write options *@
|
|
||||||
<section class="panel rise mt-3" style="animation-delay:.10s">
|
|
||||||
<div class="panel-head">Write options</div>
|
|
||||||
<div style="padding:1rem">
|
|
||||||
<div class="row g-3">
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="form-check form-switch">
|
|
||||||
<InputCheckbox @bind-Value="_form.UseFC15ForSingleCoilWrites" class="form-check-input" id="useFC15" />
|
|
||||||
<label class="form-check-label" for="useFC15">Use FC15 for single coil writes</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="form-check form-switch">
|
|
||||||
<InputCheckbox @bind-Value="_form.UseFC16ForSingleRegisterWrites" class="form-check-input" id="useFC16" />
|
|
||||||
<label class="form-check-label" for="useFC16">Use FC16 for single register writes</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="form-check form-switch">
|
|
||||||
<InputCheckbox @bind-Value="_form.DisableFC23" class="form-check-input" id="disableFC23" />
|
|
||||||
<label class="form-check-label" for="disableFC23">Disable FC23 (reserved — no effect today)</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="form-check form-switch">
|
|
||||||
<InputCheckbox @bind-Value="_form.WriteOnChangeOnly" class="form-check-input" id="writeOnChangeOnly" />
|
|
||||||
<label class="form-check-label" for="writeOnChangeOnly">Write on change only</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
@* Keep-alive *@
|
|
||||||
<section class="panel rise mt-3" style="animation-delay:.12s">
|
|
||||||
<div class="panel-head">TCP keep-alive</div>
|
|
||||||
<div style="padding:1rem">
|
|
||||||
<div class="row g-3">
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="form-check form-switch mt-4">
|
|
||||||
<InputCheckbox @bind-Value="_form.KeepAliveEnabled" class="form-check-input" id="kaEnabled" />
|
|
||||||
<label class="form-check-label" for="kaEnabled">Enabled</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label">Time (seconds)</label>
|
|
||||||
<InputNumber @bind-Value="_form.KeepAliveTimeSeconds" class="form-control form-control-sm" />
|
|
||||||
<div class="form-text">Idle time before first probe. Default 30 s.</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label">Interval (seconds)</label>
|
|
||||||
<InputNumber @bind-Value="_form.KeepAliveIntervalSeconds" class="form-control form-control-sm" />
|
|
||||||
<div class="form-text">Between probes once started. Default 10 s.</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label">Retry count</label>
|
|
||||||
<InputNumber @bind-Value="_form.KeepAliveRetryCount" class="form-control form-control-sm" />
|
|
||||||
<div class="form-text">Probes before declaring socket dead. Default 3.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
@* Reconnect backoff *@
|
|
||||||
<section class="panel rise mt-3" style="animation-delay:.14s">
|
|
||||||
<div class="panel-head">Reconnect backoff</div>
|
|
||||||
<div style="padding:1rem">
|
|
||||||
<div class="row g-3">
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label">Initial delay (seconds)</label>
|
|
||||||
<InputNumber @bind-Value="_form.ReconnectInitialDelaySeconds" class="form-control form-control-sm" />
|
|
||||||
<div class="form-text">0 = immediate retry.</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label">Max delay (seconds)</label>
|
|
||||||
<InputNumber @bind-Value="_form.ReconnectMaxDelaySeconds" class="form-control form-control-sm" />
|
|
||||||
<div class="form-text">Default 30 s.</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label">Backoff multiplier</label>
|
|
||||||
<InputNumber @bind-Value="_form.ReconnectBackoffMultiplier" class="form-control form-control-sm" />
|
|
||||||
<div class="form-text">Default 2.0 (doubles each step).</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label">Idle disconnect (seconds, 0 = off)</label>
|
|
||||||
<InputNumber @bind-Value="_form.IdleDisconnectTimeoutSeconds" class="form-control form-control-sm" />
|
|
||||||
<div class="form-text">Proactive close after idle period. 0 = disabled.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
@* Probe *@
|
|
||||||
<section class="panel rise mt-3" style="animation-delay:.16s">
|
|
||||||
<div class="panel-head">Connectivity probe</div>
|
|
||||||
<div style="padding:1rem">
|
|
||||||
<div class="row g-3">
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="form-check form-switch mt-4">
|
|
||||||
<InputCheckbox @bind-Value="_form.ProbeEnabled" class="form-check-input" id="probeEnabled" />
|
|
||||||
<label class="form-check-label" for="probeEnabled">Enabled</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label">Probe interval (seconds)</label>
|
|
||||||
<InputNumber @bind-Value="_form.ProbeIntervalSeconds" class="form-control form-control-sm" />
|
|
||||||
<div class="form-text">Default 5 s.</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label">Probe timeout (seconds)</label>
|
|
||||||
<InputNumber @bind-Value="_form.ProbeTimeoutSeconds" class="form-control form-control-sm" />
|
|
||||||
<div class="form-text">Default 2 s.</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label">Probe register address</label>
|
|
||||||
<InputNumber @bind-Value="_form.ProbeAddress" class="form-control form-control-sm" />
|
|
||||||
<div class="form-text">Zero-based. Default 0 (FC03 at register 0).</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label">Admin UI probe timeout (seconds)</label>
|
|
||||||
<InputNumber @bind-Value="_form.AdminProbeTimeoutSeconds" class="form-control form-control-sm" />
|
|
||||||
<div class="form-text">Max 60. Used by Test Connect. Default 5.</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label">Auto-prohibit re-probe interval (seconds, 0 = off)</label>
|
|
||||||
<InputNumber @bind-Value="_form.AutoProhibitReprobeIntervalSeconds" class="form-control form-control-sm" />
|
|
||||||
<div class="form-text">Interval to retry auto-prohibited coalesced ranges.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="panel notice rise mt-3" style="animation-delay:.18s">
|
|
||||||
<div class="panel-head">Tags</div>
|
|
||||||
<div style="padding:1rem" class="text-muted">
|
|
||||||
Tag authoring moves to the Raw tree (<strong>/raw</strong>) in v3 Batch 2.
|
|
||||||
Pre-declared driver tags have been retired.
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
|
|
||||||
</DriverFormShell>
|
|
||||||
</EditForm>
|
|
||||||
}
|
|
||||||
|
|
||||||
@code {
|
|
||||||
[Parameter] public string ClusterId { get; set; } = "";
|
|
||||||
[Parameter] public string? DriverInstanceId { get; set; }
|
|
||||||
|
|
||||||
private const string DriverTypeKey = "Modbus";
|
|
||||||
|
|
||||||
private bool IsNew => string.IsNullOrEmpty(DriverInstanceId);
|
|
||||||
|
|
||||||
private static readonly System.Text.Json.JsonSerializerOptions _jsonOpts = new()
|
|
||||||
{
|
|
||||||
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
|
|
||||||
UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip,
|
|
||||||
WriteIndented = false,
|
|
||||||
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
|
|
||||||
};
|
|
||||||
|
|
||||||
private FormModel _form = new();
|
|
||||||
private DriverIdentitySection.DriverIdentityModel _identityModel = new() { DriverType = DriverTypeKey };
|
|
||||||
private DriverInstance? _existing;
|
|
||||||
private bool _loaded;
|
|
||||||
private bool _busy;
|
|
||||||
private string? _error;
|
|
||||||
|
|
||||||
// Address picker state
|
|
||||||
private bool _showPicker;
|
|
||||||
private string _pickedAddress = "";
|
|
||||||
|
|
||||||
private void OnAddressPicked(string address) => _pickedAddress = address;
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
|
||||||
{
|
|
||||||
await using var db = await DbFactory.CreateDbContextAsync();
|
|
||||||
|
|
||||||
if (IsNew)
|
|
||||||
{
|
|
||||||
_identityModel = new()
|
|
||||||
{
|
|
||||||
DriverInstanceId = "",
|
|
||||||
Name = "",
|
|
||||||
DriverType = DriverTypeKey,
|
|
||||||
Enabled = true,
|
|
||||||
};
|
|
||||||
_form = new FormModel();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_existing = await db.DriverInstances.AsNoTracking()
|
|
||||||
.FirstOrDefaultAsync(d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
|
|
||||||
if (_existing is not null)
|
|
||||||
{
|
|
||||||
_identityModel = new()
|
|
||||||
{
|
|
||||||
DriverInstanceId = _existing.DriverInstanceId,
|
|
||||||
Name = _existing.Name,
|
|
||||||
DriverType = _existing.DriverType,
|
|
||||||
Enabled = _existing.Enabled,
|
|
||||||
};
|
|
||||||
var opts = TryDeserialize(_existing.DriverConfig) ?? new ModbusDriverOptions();
|
|
||||||
_form = FormModel.FromOptions(opts);
|
|
||||||
_form.ResilienceConfig = _existing.ResilienceConfig;
|
|
||||||
_form.RowVersion = _existing.RowVersion;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_loaded = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task SubmitAsync()
|
|
||||||
{
|
|
||||||
_busy = true; _error = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var configJson = System.Text.Json.JsonSerializer.Serialize(_form.ToOptions(), _jsonOpts);
|
|
||||||
await using var db = await DbFactory.CreateDbContextAsync();
|
|
||||||
if (IsNew)
|
|
||||||
{
|
|
||||||
if (await db.DriverInstances.AnyAsync(d => d.DriverInstanceId == _identityModel.DriverInstanceId))
|
|
||||||
{
|
|
||||||
_error = $"Driver instance '{_identityModel.DriverInstanceId}' already exists.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
db.DriverInstances.Add(new DriverInstance
|
|
||||||
{
|
|
||||||
DriverInstanceId = _identityModel.DriverInstanceId,
|
|
||||||
ClusterId = ClusterId,
|
|
||||||
Name = _identityModel.Name,
|
|
||||||
DriverType = DriverTypeKey,
|
|
||||||
Enabled = _identityModel.Enabled,
|
|
||||||
DriverConfig = configJson,
|
|
||||||
ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var entity = await db.DriverInstances.FirstOrDefaultAsync(
|
|
||||||
d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
|
|
||||||
if (entity is null) { _error = "Row no longer exists."; return; }
|
|
||||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
|
|
||||||
entity.Name = _identityModel.Name;
|
|
||||||
entity.Enabled = _identityModel.Enabled;
|
|
||||||
entity.DriverConfig = configJson;
|
|
||||||
entity.ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig;
|
|
||||||
}
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
|
|
||||||
}
|
|
||||||
catch (DbUpdateConcurrencyException)
|
|
||||||
{
|
|
||||||
_error = "Another user changed this driver instance while you were editing. Reload to see the latest values, then re-apply your changes.";
|
|
||||||
}
|
|
||||||
catch (Exception ex) { _error = ex.Message; }
|
|
||||||
finally { _busy = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task DeleteAsync()
|
|
||||||
{
|
|
||||||
if (IsNew) return;
|
|
||||||
_busy = true; _error = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await using var db = await DbFactory.CreateDbContextAsync();
|
|
||||||
var entity = await db.DriverInstances.FirstOrDefaultAsync(
|
|
||||||
d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
|
|
||||||
if (entity is null) { Nav.NavigateTo($"/clusters/{ClusterId}/drivers"); return; }
|
|
||||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
|
|
||||||
db.DriverInstances.Remove(entity);
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
|
|
||||||
}
|
|
||||||
catch (DbUpdateConcurrencyException)
|
|
||||||
{
|
|
||||||
_error = "Another user changed this driver instance while you were viewing it. Reload before deleting.";
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_error = $"Delete failed: {ex.Message}. (Likely because equipment/tags still reference this driver — remove them first.)";
|
|
||||||
}
|
|
||||||
finally { _busy = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
private string SerializeCurrentConfig()
|
|
||||||
=> System.Text.Json.JsonSerializer.Serialize(_form.ToOptions(), _jsonOpts);
|
|
||||||
|
|
||||||
private static ModbusDriverOptions? TryDeserialize(string json)
|
|
||||||
{
|
|
||||||
try { return System.Text.Json.JsonSerializer.Deserialize<ModbusDriverOptions>(json, _jsonOpts); }
|
|
||||||
catch { return null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Flat mutable model — all scalars exposed as settable properties so Blazor @bind-Value works.
|
|
||||||
// Collection (Tags) is kept on the component (_tags) and passed in when building the final Options.
|
|
||||||
public sealed class FormModel
|
|
||||||
{
|
|
||||||
// Transport
|
|
||||||
public string Host { get; set; } = "127.0.0.1";
|
|
||||||
public int Port { get; set; } = 502;
|
|
||||||
public int UnitId { get; set; } = 1;
|
|
||||||
public int TimeoutSeconds { get; set; } = 2;
|
|
||||||
|
|
||||||
// Family
|
|
||||||
public ModbusFamily Family { get; set; } = ModbusFamily.Generic;
|
|
||||||
public MelsecFamily MelsecSubFamily { get; set; } = MelsecFamily.Q_L_iQR;
|
|
||||||
|
|
||||||
// Transport flags
|
|
||||||
public bool AutoReconnect { get; set; } = true;
|
|
||||||
public int IdleDisconnectTimeoutSeconds { get; set; } = 0;
|
|
||||||
|
|
||||||
// Batch sizes (using int; clamped to ushort on ToOptions)
|
|
||||||
public int MaxRegistersPerRead { get; set; } = 125;
|
|
||||||
public int MaxRegistersPerWrite { get; set; } = 123;
|
|
||||||
public int MaxCoilsPerRead { get; set; } = 2000;
|
|
||||||
public int MaxReadGap { get; set; } = 0;
|
|
||||||
|
|
||||||
// Write options
|
|
||||||
public bool UseFC15ForSingleCoilWrites { get; set; } = false;
|
|
||||||
public bool UseFC16ForSingleRegisterWrites { get; set; } = false;
|
|
||||||
public bool DisableFC23 { get; set; } = false;
|
|
||||||
public bool WriteOnChangeOnly { get; set; } = false;
|
|
||||||
|
|
||||||
// Keep-alive
|
|
||||||
public bool KeepAliveEnabled { get; set; } = true;
|
|
||||||
public int KeepAliveTimeSeconds { get; set; } = 30;
|
|
||||||
public int KeepAliveIntervalSeconds { get; set; } = 10;
|
|
||||||
public int KeepAliveRetryCount { get; set; } = 3;
|
|
||||||
|
|
||||||
// Reconnect backoff
|
|
||||||
public int ReconnectInitialDelaySeconds { get; set; } = 0;
|
|
||||||
public int ReconnectMaxDelaySeconds { get; set; } = 30;
|
|
||||||
public double ReconnectBackoffMultiplier { get; set; } = 2.0;
|
|
||||||
|
|
||||||
// Probe
|
|
||||||
public bool ProbeEnabled { get; set; } = true;
|
|
||||||
public int ProbeIntervalSeconds { get; set; } = 5;
|
|
||||||
public int ProbeTimeoutSeconds { get; set; } = 2;
|
|
||||||
public int ProbeAddress { get; set; } = 0;
|
|
||||||
|
|
||||||
// Auto-prohibit re-probe (0 = disabled)
|
|
||||||
public int AutoProhibitReprobeIntervalSeconds { get; set; } = 0;
|
|
||||||
|
|
||||||
// Admin UI probe timeout
|
|
||||||
public int AdminProbeTimeoutSeconds { get; set; } = 5;
|
|
||||||
|
|
||||||
// Persistence
|
|
||||||
public string? ResilienceConfig { get; set; }
|
|
||||||
public byte[] RowVersion { get; set; } = [];
|
|
||||||
|
|
||||||
public static FormModel FromOptions(ModbusDriverOptions o) => new()
|
|
||||||
{
|
|
||||||
Host = o.Host,
|
|
||||||
Port = o.Port,
|
|
||||||
UnitId = o.UnitId,
|
|
||||||
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
|
|
||||||
Family = o.Family,
|
|
||||||
MelsecSubFamily = o.MelsecSubFamily,
|
|
||||||
AutoReconnect = o.AutoReconnect,
|
|
||||||
IdleDisconnectTimeoutSeconds = o.IdleDisconnectTimeout.HasValue ? (int)o.IdleDisconnectTimeout.Value.TotalSeconds : 0,
|
|
||||||
MaxRegistersPerRead = o.MaxRegistersPerRead,
|
|
||||||
MaxRegistersPerWrite = o.MaxRegistersPerWrite,
|
|
||||||
MaxCoilsPerRead = o.MaxCoilsPerRead,
|
|
||||||
MaxReadGap = o.MaxReadGap,
|
|
||||||
UseFC15ForSingleCoilWrites = o.UseFC15ForSingleCoilWrites,
|
|
||||||
UseFC16ForSingleRegisterWrites = o.UseFC16ForSingleRegisterWrites,
|
|
||||||
DisableFC23 = o.DisableFC23,
|
|
||||||
WriteOnChangeOnly = o.WriteOnChangeOnly,
|
|
||||||
KeepAliveEnabled = o.KeepAlive.Enabled,
|
|
||||||
KeepAliveTimeSeconds = (int)o.KeepAlive.Time.TotalSeconds,
|
|
||||||
KeepAliveIntervalSeconds = (int)o.KeepAlive.Interval.TotalSeconds,
|
|
||||||
KeepAliveRetryCount = o.KeepAlive.RetryCount,
|
|
||||||
ReconnectInitialDelaySeconds = (int)o.Reconnect.InitialDelay.TotalSeconds,
|
|
||||||
ReconnectMaxDelaySeconds = (int)o.Reconnect.MaxDelay.TotalSeconds,
|
|
||||||
ReconnectBackoffMultiplier = o.Reconnect.BackoffMultiplier,
|
|
||||||
ProbeEnabled = o.Probe.Enabled,
|
|
||||||
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
|
|
||||||
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
|
|
||||||
ProbeAddress = o.Probe.ProbeAddress,
|
|
||||||
AutoProhibitReprobeIntervalSeconds = o.AutoProhibitReprobeInterval.HasValue
|
|
||||||
? (int)o.AutoProhibitReprobeInterval.Value.TotalSeconds : 0,
|
|
||||||
AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
|
|
||||||
};
|
|
||||||
|
|
||||||
public ModbusDriverOptions ToOptions() => new()
|
|
||||||
{
|
|
||||||
Host = Host,
|
|
||||||
Port = Port,
|
|
||||||
UnitId = (byte)Math.Clamp(UnitId, 0, 255),
|
|
||||||
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
|
|
||||||
Probe = new ModbusProbeOptions
|
|
||||||
{
|
|
||||||
Enabled = ProbeEnabled,
|
|
||||||
Interval = TimeSpan.FromSeconds(ProbeIntervalSeconds),
|
|
||||||
Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds),
|
|
||||||
ProbeAddress = (ushort)Math.Clamp(ProbeAddress, 0, 65535),
|
|
||||||
},
|
|
||||||
MaxRegistersPerRead = (ushort)Math.Clamp(MaxRegistersPerRead, 0, 65535),
|
|
||||||
MaxRegistersPerWrite = (ushort)Math.Clamp(MaxRegistersPerWrite, 0, 65535),
|
|
||||||
MaxCoilsPerRead = (ushort)Math.Clamp(MaxCoilsPerRead, 0, 65535),
|
|
||||||
UseFC15ForSingleCoilWrites = UseFC15ForSingleCoilWrites,
|
|
||||||
UseFC16ForSingleRegisterWrites = UseFC16ForSingleRegisterWrites,
|
|
||||||
DisableFC23 = DisableFC23,
|
|
||||||
AutoProhibitReprobeInterval = AutoProhibitReprobeIntervalSeconds > 0
|
|
||||||
? TimeSpan.FromSeconds(AutoProhibitReprobeIntervalSeconds) : null,
|
|
||||||
MaxReadGap = (ushort)Math.Clamp(MaxReadGap, 0, 65535),
|
|
||||||
Family = Family,
|
|
||||||
MelsecSubFamily = MelsecSubFamily,
|
|
||||||
WriteOnChangeOnly = WriteOnChangeOnly,
|
|
||||||
AutoReconnect = AutoReconnect,
|
|
||||||
KeepAlive = new ModbusKeepAliveOptions
|
|
||||||
{
|
|
||||||
Enabled = KeepAliveEnabled,
|
|
||||||
Time = TimeSpan.FromSeconds(KeepAliveTimeSeconds),
|
|
||||||
Interval = TimeSpan.FromSeconds(KeepAliveIntervalSeconds),
|
|
||||||
RetryCount = KeepAliveRetryCount,
|
|
||||||
},
|
|
||||||
IdleDisconnectTimeout = IdleDisconnectTimeoutSeconds > 0
|
|
||||||
? TimeSpan.FromSeconds(IdleDisconnectTimeoutSeconds) : null,
|
|
||||||
Reconnect = new ModbusReconnectOptions
|
|
||||||
{
|
|
||||||
InitialDelay = TimeSpan.FromSeconds(ReconnectInitialDelaySeconds),
|
|
||||||
MaxDelay = TimeSpan.FromSeconds(ReconnectMaxDelaySeconds),
|
|
||||||
BackoffMultiplier = ReconnectBackoffMultiplier,
|
|
||||||
},
|
|
||||||
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-554
@@ -1,554 +0,0 @@
|
|||||||
@page "/clusters/{ClusterId}/drivers/new/opcuaclient"
|
|
||||||
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
|
|
||||||
@rendermode RenderMode.InteractiveServer
|
|
||||||
@using Microsoft.AspNetCore.Components.Forms
|
|
||||||
@using Microsoft.EntityFrameworkCore
|
|
||||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients
|
|
||||||
@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
|
|
||||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
|
||||||
@using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient
|
|
||||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
|
||||||
@inject NavigationManager Nav
|
|
||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
||||||
<h4 class="mb-0">@(IsNew ? "New OPC UA Client driver" : "Edit OPC UA Client driver") · <span class="mono">@ClusterId</span></h4>
|
|
||||||
<a href="/clusters/@ClusterId/drivers" class="btn btn-outline-secondary btn-sm">Cancel</a>
|
|
||||||
</div>
|
|
||||||
<ClusterNav ClusterId="@ClusterId" ActiveTab="drivers" />
|
|
||||||
|
|
||||||
@if (!_loaded)
|
|
||||||
{
|
|
||||||
<p>Loading…</p>
|
|
||||||
}
|
|
||||||
else if (!IsNew && _existing is null)
|
|
||||||
{
|
|
||||||
<section class="panel notice rise" style="animation-delay:.02s">
|
|
||||||
Driver instance <span class="mono">@DriverInstanceId</span> was not found in cluster <span class="mono">@ClusterId</span>.
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="opcuaclientDriverEdit">
|
|
||||||
<DataAnnotationsValidator />
|
|
||||||
<DriverFormShell IsNew="IsNew" Busy="_busy" Error="@_error"
|
|
||||||
CancelHref="@($"/clusters/{ClusterId}/drivers")"
|
|
||||||
OnDelete="@(IsNew ? null : (EventCallback?)EventCallback.Factory.Create(this, DeleteAsync))">
|
|
||||||
|
|
||||||
<DriverIdentitySection Model="_identityModel" IsNew="IsNew" ShowDriverType="false" />
|
|
||||||
|
|
||||||
@if (!IsNew && !string.IsNullOrEmpty(DriverInstanceId))
|
|
||||||
{
|
|
||||||
<DriverStatusPanel DriverInstanceId="@DriverInstanceId" Enabled="@_identityModel.Enabled" />
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="mt-2 mb-3">
|
|
||||||
<DriverTestConnectButton DriverType="@DriverTypeKey"
|
|
||||||
GetConfigJson="@SerializeCurrentConfig"
|
|
||||||
TimeoutSeconds="@_form.OpcUa.ProbeTimeoutSeconds" />
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
|
|
||||||
@onclick="@(() => _showPicker = true)">
|
|
||||||
Pick address
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DriverTagPicker @bind-Visible="_showPicker"
|
|
||||||
Title="OPC UA address"
|
|
||||||
CurrentAddress="@_pickedAddress"
|
|
||||||
OnPickAddress="@OnAddressPicked">
|
|
||||||
<OpcUaClientAddressPickerBody CurrentAddress="@_pickedAddress"
|
|
||||||
CurrentAddressChanged="@((s) => _pickedAddress = s)"
|
|
||||||
GetConfigJson="@SerializeCurrentConfig" />
|
|
||||||
</DriverTagPicker>
|
|
||||||
|
|
||||||
@* Endpoint *@
|
|
||||||
<section class="panel rise mt-3" style="animation-delay:.06s">
|
|
||||||
<div class="panel-head">Endpoint</div>
|
|
||||||
<div style="padding:1rem">
|
|
||||||
<div class="row g-3">
|
|
||||||
<div class="col-md-8">
|
|
||||||
<label class="form-label">Endpoint URL</label>
|
|
||||||
<InputText @bind-Value="_form.OpcUa.EndpointUrl" class="form-control form-control-sm mono"
|
|
||||||
placeholder="opc.tcp://plc.internal:4840" />
|
|
||||||
<div class="form-text">Single-endpoint shortcut. When EndpointUrls list is non-empty, this field is ignored.</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<label class="form-label">Browse root NodeId (blank = ObjectsFolder)</label>
|
|
||||||
<InputText @bind-Value="_form.OpcUa.BrowseRoot" class="form-control form-control-sm mono"
|
|
||||||
placeholder="i=85" />
|
|
||||||
<div class="form-text">Restrict mirroring to a sub-tree.</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<label class="form-label">Application URI</label>
|
|
||||||
<InputText @bind-Value="_form.OpcUa.ApplicationUri" class="form-control form-control-sm mono" />
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<label class="form-label">Session name</label>
|
|
||||||
<InputText @bind-Value="_form.OpcUa.SessionName" class="form-control form-control-sm" />
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="form-check form-switch mt-4">
|
|
||||||
<InputCheckbox @bind-Value="_form.OpcUa.AutoAcceptCertificates" class="form-check-input" id="autoAcceptCerts" />
|
|
||||||
<label class="form-check-label" for="autoAcceptCerts">Auto-accept certificates <span class="badge bg-warning text-dark">Dev only</span></label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row g-3 mt-1">
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label">Per-endpoint connect timeout (s)</label>
|
|
||||||
<InputNumber @bind-Value="_form.OpcUa.PerEndpointConnectTimeoutSeconds" class="form-control form-control-sm" />
|
|
||||||
<div class="form-text">Default 3 s — failover sweep budget.</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label">Operation timeout (s)</label>
|
|
||||||
<InputNumber @bind-Value="_form.OpcUa.TimeoutSeconds" class="form-control form-control-sm" />
|
|
||||||
<div class="form-text">Default 10 s — steady-state reads/writes.</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label">Session timeout (s)</label>
|
|
||||||
<InputNumber @bind-Value="_form.OpcUa.SessionTimeoutSeconds" class="form-control form-control-sm" />
|
|
||||||
<div class="form-text">Default 120 s.</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label">Keep-alive interval (s)</label>
|
|
||||||
<InputNumber @bind-Value="_form.OpcUa.KeepAliveIntervalSeconds" class="form-control form-control-sm" />
|
|
||||||
<div class="form-text">Default 5 s.</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label">Reconnect period (s)</label>
|
|
||||||
<InputNumber @bind-Value="_form.OpcUa.ReconnectPeriodSeconds" class="form-control form-control-sm" />
|
|
||||||
<div class="form-text">Initial delay after session drop. Default 5 s.</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label">Max discovered nodes</label>
|
|
||||||
<InputNumber @bind-Value="_form.OpcUa.MaxDiscoveredNodes" class="form-control form-control-sm" />
|
|
||||||
<div class="form-text">Default 10000.</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label">Max browse depth</label>
|
|
||||||
<InputNumber @bind-Value="_form.OpcUa.MaxBrowseDepth" class="form-control form-control-sm" />
|
|
||||||
<div class="form-text">Default 10.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row g-3 mt-1">
|
|
||||||
<div class="col-12">
|
|
||||||
<CollectionEditor TRow="EndpointUrlRow" Items="_endpoints"
|
|
||||||
Title="Endpoint URLs" ItemNoun="endpoint" AnimationDelay=".07s"
|
|
||||||
NewRow="@(() => new EndpointUrlRow())" Clone="@(r => r.Clone())"
|
|
||||||
Validate="EndpointUrlRow.ValidateRow">
|
|
||||||
<HeaderTemplate>
|
|
||||||
<tr><th>Endpoint URL (failover list — first reachable wins)</th><th></th></tr>
|
|
||||||
</HeaderTemplate>
|
|
||||||
<RowTemplate Context="e">
|
|
||||||
<td class="mono">@e.Url</td>
|
|
||||||
</RowTemplate>
|
|
||||||
<EditTemplate Context="e">
|
|
||||||
<label class="form-label">Endpoint URL</label>
|
|
||||||
<input class="form-control form-control-sm mono" @bind="e.Url"
|
|
||||||
placeholder="opc.tcp://plc.internal:4840" />
|
|
||||||
<div class="form-text">When this list is non-empty, the single Endpoint URL above is ignored.</div>
|
|
||||||
</EditTemplate>
|
|
||||||
</CollectionEditor>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
@* Security *@
|
|
||||||
<section class="panel rise mt-3" style="animation-delay:.08s">
|
|
||||||
<div class="panel-head">Security</div>
|
|
||||||
<div style="padding:1rem">
|
|
||||||
<div class="row g-3">
|
|
||||||
<div class="col-md-4">
|
|
||||||
<label class="form-label">Security mode</label>
|
|
||||||
<InputSelect @bind-Value="_form.OpcUa.SecurityMode" class="form-select form-select-sm">
|
|
||||||
@foreach (var e in Enum.GetValues<OpcUaSecurityMode>())
|
|
||||||
{
|
|
||||||
<option value="@e">@e</option>
|
|
||||||
}
|
|
||||||
</InputSelect>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<label class="form-label">Security policy</label>
|
|
||||||
<InputSelect @bind-Value="_form.OpcUa.SecurityPolicy" class="form-select form-select-sm">
|
|
||||||
@foreach (var e in Enum.GetValues<OpcUaSecurityPolicy>())
|
|
||||||
{
|
|
||||||
<option value="@e">@e</option>
|
|
||||||
}
|
|
||||||
</InputSelect>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
@* Authentication *@
|
|
||||||
<section class="panel rise mt-3" style="animation-delay:.10s">
|
|
||||||
<div class="panel-head">Authentication</div>
|
|
||||||
<div style="padding:1rem">
|
|
||||||
<div class="row g-3">
|
|
||||||
<div class="col-md-4">
|
|
||||||
<label class="form-label">Auth type</label>
|
|
||||||
<InputSelect @bind-Value="_form.OpcUa.AuthType" class="form-select form-select-sm">
|
|
||||||
@foreach (var e in Enum.GetValues<OpcUaAuthType>())
|
|
||||||
{
|
|
||||||
<option value="@e">@e</option>
|
|
||||||
}
|
|
||||||
</InputSelect>
|
|
||||||
</div>
|
|
||||||
@if (_form.OpcUa.AuthType == OpcUaAuthType.Username)
|
|
||||||
{
|
|
||||||
<div class="col-md-4">
|
|
||||||
<label class="form-label">Username</label>
|
|
||||||
<InputText @bind-Value="_form.OpcUa.Username" class="form-control form-control-sm" />
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<label class="form-label">Password</label>
|
|
||||||
<InputText @bind-Value="_form.OpcUa.Password" type="password" class="form-control form-control-sm" autocomplete="new-password" />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@if (_form.OpcUa.AuthType == OpcUaAuthType.Certificate)
|
|
||||||
{
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label class="form-label">User certificate path (PFX/PEM)</label>
|
|
||||||
<InputText @bind-Value="_form.OpcUa.UserCertificatePath" class="form-control form-control-sm mono"
|
|
||||||
placeholder="C:\certs\user.pfx" />
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<label class="form-label">Certificate password (if PFX-locked)</label>
|
|
||||||
<InputText @bind-Value="_form.OpcUa.UserCertificatePassword" type="password" class="form-control form-control-sm" autocomplete="new-password" />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
@* Namespace mapping *@
|
|
||||||
<section class="panel rise mt-3" style="animation-delay:.12s">
|
|
||||||
<div class="panel-head">Namespace mapping</div>
|
|
||||||
<div style="padding:1rem">
|
|
||||||
<div class="row g-3">
|
|
||||||
<div class="col-md-4">
|
|
||||||
<label class="form-label">Target namespace kind</label>
|
|
||||||
<InputSelect @bind-Value="_form.OpcUa.TargetNamespaceKind" class="form-select form-select-sm">
|
|
||||||
@foreach (var e in Enum.GetValues<OpcUaTargetNamespaceKind>())
|
|
||||||
{
|
|
||||||
<option value="@e">@e</option>
|
|
||||||
}
|
|
||||||
</InputSelect>
|
|
||||||
<div class="form-text">Equipment = raw data re-mapped to UNS. SystemPlatform = processed data; hierarchy preserved as-is.</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-12">
|
|
||||||
<label class="form-label">UNS mapping table (read-only — edit via raw JSON import)</label>
|
|
||||||
<pre class="form-control form-control-sm mono" style="min-height:3rem;overflow:auto;white-space:pre-wrap;">@_unsMappingTableJson</pre>
|
|
||||||
<div class="form-text">Keys = remote browse-path prefixes; values = UNS Area/Line/Name paths. Required when TargetNamespaceKind = Equipment.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
@* Diagnostics *@
|
|
||||||
<section class="panel rise mt-3" style="animation-delay:.14s">
|
|
||||||
<div class="panel-head">Diagnostics</div>
|
|
||||||
<div style="padding:1rem">
|
|
||||||
<div class="row g-3">
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label">Admin UI probe timeout (seconds)</label>
|
|
||||||
<InputNumber @bind-Value="_form.OpcUa.ProbeTimeoutSeconds" class="form-control form-control-sm" />
|
|
||||||
<div class="form-text">Max 60. Used by Test Connect. Default 15.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
|
|
||||||
</DriverFormShell>
|
|
||||||
</EditForm>
|
|
||||||
}
|
|
||||||
|
|
||||||
@code {
|
|
||||||
[Parameter] public string ClusterId { get; set; } = "";
|
|
||||||
[Parameter] public string? DriverInstanceId { get; set; }
|
|
||||||
|
|
||||||
private const string DriverTypeKey = "OpcUaClient";
|
|
||||||
|
|
||||||
private bool IsNew => string.IsNullOrEmpty(DriverInstanceId);
|
|
||||||
|
|
||||||
private static readonly System.Text.Json.JsonSerializerOptions _jsonOpts = new()
|
|
||||||
{
|
|
||||||
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
|
|
||||||
UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip,
|
|
||||||
WriteIndented = false,
|
|
||||||
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
|
|
||||||
};
|
|
||||||
|
|
||||||
private FormModel _form = new();
|
|
||||||
private DriverIdentitySection.DriverIdentityModel _identityModel = new() { DriverType = DriverTypeKey };
|
|
||||||
private DriverInstance? _existing;
|
|
||||||
private bool _loaded;
|
|
||||||
private bool _busy;
|
|
||||||
private string? _error;
|
|
||||||
|
|
||||||
// Address picker state
|
|
||||||
private bool _showPicker;
|
|
||||||
private string _pickedAddress = "";
|
|
||||||
|
|
||||||
private void OnAddressPicked(string address) => _pickedAddress = address;
|
|
||||||
|
|
||||||
// Held separately because EndpointUrls is a collection — edited via the CollectionEditor modal.
|
|
||||||
private List<EndpointUrlRow> _endpoints = [];
|
|
||||||
|
|
||||||
// Read-only JSON snippet for the UnsMappingTable, which has no list editor yet.
|
|
||||||
private string _unsMappingTableJson = "{}";
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
|
||||||
{
|
|
||||||
await using var db = await DbFactory.CreateDbContextAsync();
|
|
||||||
|
|
||||||
if (IsNew)
|
|
||||||
{
|
|
||||||
_identityModel = new()
|
|
||||||
{
|
|
||||||
DriverInstanceId = "",
|
|
||||||
Name = "",
|
|
||||||
DriverType = DriverTypeKey,
|
|
||||||
Enabled = true,
|
|
||||||
};
|
|
||||||
_form = new FormModel();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_existing = await db.DriverInstances.AsNoTracking()
|
|
||||||
.FirstOrDefaultAsync(d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
|
|
||||||
if (_existing is not null)
|
|
||||||
{
|
|
||||||
_identityModel = new()
|
|
||||||
{
|
|
||||||
DriverInstanceId = _existing.DriverInstanceId,
|
|
||||||
Name = _existing.Name,
|
|
||||||
DriverType = _existing.DriverType,
|
|
||||||
Enabled = _existing.Enabled,
|
|
||||||
};
|
|
||||||
var opts = TryDeserialize(_existing.DriverConfig) ?? new OpcUaClientDriverOptions();
|
|
||||||
_form = new FormModel();
|
|
||||||
_form.OpcUa = OpcUaClientFormModel.FromRecord(opts);
|
|
||||||
_endpoints = opts.EndpointUrls.Select(EndpointUrlRow.FromUrl).ToList();
|
|
||||||
_unsMappingTableJson = System.Text.Json.JsonSerializer.Serialize(opts.UnsMappingTable, _jsonOpts);
|
|
||||||
_form.ResilienceConfig = _existing.ResilienceConfig;
|
|
||||||
_form.RowVersion = _existing.RowVersion;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_loaded = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task SubmitAsync()
|
|
||||||
{
|
|
||||||
_busy = true; _error = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var opts = _form.OpcUa.ToRecord(_endpoints.Select(r => r.ToUrl()).ToList());
|
|
||||||
var configJson = System.Text.Json.JsonSerializer.Serialize(opts, _jsonOpts);
|
|
||||||
await using var db = await DbFactory.CreateDbContextAsync();
|
|
||||||
if (IsNew)
|
|
||||||
{
|
|
||||||
if (await db.DriverInstances.AnyAsync(d => d.DriverInstanceId == _identityModel.DriverInstanceId))
|
|
||||||
{
|
|
||||||
_error = $"Driver instance '{_identityModel.DriverInstanceId}' already exists."; return;
|
|
||||||
}
|
|
||||||
db.DriverInstances.Add(new DriverInstance
|
|
||||||
{
|
|
||||||
DriverInstanceId = _identityModel.DriverInstanceId,
|
|
||||||
ClusterId = ClusterId,
|
|
||||||
Name = _identityModel.Name,
|
|
||||||
DriverType = DriverTypeKey,
|
|
||||||
Enabled = _identityModel.Enabled,
|
|
||||||
DriverConfig = configJson,
|
|
||||||
ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var entity = await db.DriverInstances.FirstOrDefaultAsync(
|
|
||||||
d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
|
|
||||||
if (entity is null) { _error = "Row no longer exists."; return; }
|
|
||||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
|
|
||||||
entity.Name = _identityModel.Name;
|
|
||||||
entity.Enabled = _identityModel.Enabled;
|
|
||||||
entity.DriverConfig = configJson;
|
|
||||||
entity.ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig;
|
|
||||||
}
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
|
|
||||||
}
|
|
||||||
catch (DbUpdateConcurrencyException)
|
|
||||||
{
|
|
||||||
_error = "Another user changed this driver instance while you were editing. Reload to see the latest values, then re-apply your changes.";
|
|
||||||
}
|
|
||||||
catch (Exception ex) { _error = ex.Message; }
|
|
||||||
finally { _busy = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task DeleteAsync()
|
|
||||||
{
|
|
||||||
if (IsNew) return;
|
|
||||||
_busy = true; _error = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await using var db = await DbFactory.CreateDbContextAsync();
|
|
||||||
var entity = await db.DriverInstances.FirstOrDefaultAsync(
|
|
||||||
d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
|
|
||||||
if (entity is null) { Nav.NavigateTo($"/clusters/{ClusterId}/drivers"); return; }
|
|
||||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
|
|
||||||
db.DriverInstances.Remove(entity);
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
|
|
||||||
}
|
|
||||||
catch (DbUpdateConcurrencyException)
|
|
||||||
{
|
|
||||||
_error = "Another user changed this driver instance while you were viewing it. Reload before deleting.";
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_error = $"Delete failed: {ex.Message}. (Likely because equipment/tags still reference this driver — remove them first.)";
|
|
||||||
}
|
|
||||||
finally { _busy = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
private string SerializeCurrentConfig()
|
|
||||||
=> System.Text.Json.JsonSerializer.Serialize(
|
|
||||||
_form.OpcUa.ToRecord(_endpoints.Select(r => r.ToUrl()).ToList()), _jsonOpts);
|
|
||||||
|
|
||||||
private static OpcUaClientDriverOptions? TryDeserialize(string json)
|
|
||||||
{
|
|
||||||
try { return System.Text.Json.JsonSerializer.Deserialize<OpcUaClientDriverOptions>(json, _jsonOpts); }
|
|
||||||
catch { return null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class FormModel
|
|
||||||
{
|
|
||||||
public OpcUaClientFormModel OpcUa { get; set; } = new();
|
|
||||||
public string? ResilienceConfig { get; set; }
|
|
||||||
public byte[] RowVersion { get; set; } = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Mutable VM for a single endpoint URL row. EndpointUrls is a plain
|
|
||||||
/// <c>List<string></c> (a failover list) so the row is a thin wrapper the
|
|
||||||
/// <see cref="CollectionEditor{TRow}"/> modal can bind to.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class EndpointUrlRow
|
|
||||||
{
|
|
||||||
public string Url { get; set; } = "";
|
|
||||||
public EndpointUrlRow Clone() => (EndpointUrlRow)MemberwiseClone();
|
|
||||||
public static EndpointUrlRow FromUrl(string u) => new() { Url = u };
|
|
||||||
public string ToUrl() => Url.Trim();
|
|
||||||
|
|
||||||
public static string? ValidateRow(EndpointUrlRow row, IReadOnlyList<EndpointUrlRow> all, int? editIndex)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(row.Url)) return "URL is required.";
|
|
||||||
if (!row.Url.Trim().StartsWith("opc.tcp://", StringComparison.OrdinalIgnoreCase))
|
|
||||||
return "Endpoint URL must start with opc.tcp://";
|
|
||||||
for (var i = 0; i < all.Count; i++)
|
|
||||||
if (i != editIndex && string.Equals(all[i].Url.Trim(), row.Url.Trim(), StringComparison.OrdinalIgnoreCase))
|
|
||||||
return $"Duplicate endpoint '{row.Url}'.";
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Mutable mirror of <see cref="OpcUaClientDriverOptions"/> with int wrappers for
|
|
||||||
/// TimeSpan fields so Blazor InputNumber can bind them.
|
|
||||||
/// EndpointUrls is edited via the CollectionEditor (held on the page as a row list and
|
|
||||||
/// threaded into <see cref="ToRecord"/>); UnsMappingTable is shown as read-only JSON and
|
|
||||||
/// survives round-trip via the original deserialized record, re-serialized unchanged.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class OpcUaClientFormModel
|
|
||||||
{
|
|
||||||
// Connection
|
|
||||||
public string EndpointUrl { get; set; } = "opc.tcp://localhost:4840";
|
|
||||||
public string? BrowseRoot { get; set; }
|
|
||||||
public string ApplicationUri { get; set; } = "urn:localhost:OtOpcUa:GatewayClient";
|
|
||||||
public string SessionName { get; set; } = "OtOpcUa-Gateway";
|
|
||||||
public bool AutoAcceptCertificates { get; set; } = false;
|
|
||||||
public int PerEndpointConnectTimeoutSeconds { get; set; } = 3;
|
|
||||||
public int TimeoutSeconds { get; set; } = 10;
|
|
||||||
public int SessionTimeoutSeconds { get; set; } = 120;
|
|
||||||
public int KeepAliveIntervalSeconds { get; set; } = 5;
|
|
||||||
public int ReconnectPeriodSeconds { get; set; } = 5;
|
|
||||||
public int MaxDiscoveredNodes { get; set; } = 10_000;
|
|
||||||
public int MaxBrowseDepth { get; set; } = 10;
|
|
||||||
|
|
||||||
// Security
|
|
||||||
public OpcUaSecurityMode SecurityMode { get; set; } = OpcUaSecurityMode.None;
|
|
||||||
public OpcUaSecurityPolicy SecurityPolicy { get; set; } = OpcUaSecurityPolicy.None;
|
|
||||||
|
|
||||||
// Authentication
|
|
||||||
public OpcUaAuthType AuthType { get; set; } = OpcUaAuthType.Anonymous;
|
|
||||||
public string? Username { get; set; }
|
|
||||||
public string? Password { get; set; }
|
|
||||||
public string? UserCertificatePath { get; set; }
|
|
||||||
public string? UserCertificatePassword { get; set; }
|
|
||||||
|
|
||||||
// Namespace mapping
|
|
||||||
public OpcUaTargetNamespaceKind TargetNamespaceKind { get; set; } = OpcUaTargetNamespaceKind.Equipment;
|
|
||||||
|
|
||||||
// Diagnostics
|
|
||||||
public int ProbeTimeoutSeconds { get; set; } = 15;
|
|
||||||
|
|
||||||
// Preserved read-only collection (round-tripped unchanged from original record)
|
|
||||||
internal IReadOnlyDictionary<string, string> _unsMappingTable = new System.Collections.Generic.Dictionary<string, string>();
|
|
||||||
|
|
||||||
public static OpcUaClientFormModel FromRecord(OpcUaClientDriverOptions r) => new()
|
|
||||||
{
|
|
||||||
EndpointUrl = r.EndpointUrl,
|
|
||||||
BrowseRoot = r.BrowseRoot,
|
|
||||||
ApplicationUri = r.ApplicationUri,
|
|
||||||
SessionName = r.SessionName,
|
|
||||||
AutoAcceptCertificates = r.AutoAcceptCertificates,
|
|
||||||
PerEndpointConnectTimeoutSeconds = (int)r.PerEndpointConnectTimeout.TotalSeconds,
|
|
||||||
TimeoutSeconds = (int)r.Timeout.TotalSeconds,
|
|
||||||
SessionTimeoutSeconds = (int)r.SessionTimeout.TotalSeconds,
|
|
||||||
KeepAliveIntervalSeconds = (int)r.KeepAliveInterval.TotalSeconds,
|
|
||||||
ReconnectPeriodSeconds = (int)r.ReconnectPeriod.TotalSeconds,
|
|
||||||
MaxDiscoveredNodes = r.MaxDiscoveredNodes,
|
|
||||||
MaxBrowseDepth = r.MaxBrowseDepth,
|
|
||||||
SecurityMode = r.SecurityMode,
|
|
||||||
SecurityPolicy = r.SecurityPolicy,
|
|
||||||
AuthType = r.AuthType,
|
|
||||||
Username = r.Username,
|
|
||||||
Password = r.Password,
|
|
||||||
UserCertificatePath = r.UserCertificatePath,
|
|
||||||
UserCertificatePassword = r.UserCertificatePassword,
|
|
||||||
TargetNamespaceKind = r.TargetNamespaceKind,
|
|
||||||
ProbeTimeoutSeconds = r.ProbeTimeoutSeconds,
|
|
||||||
_unsMappingTable = r.UnsMappingTable,
|
|
||||||
};
|
|
||||||
|
|
||||||
public OpcUaClientDriverOptions ToRecord(IReadOnlyList<string> endpointUrls) => new()
|
|
||||||
{
|
|
||||||
EndpointUrl = EndpointUrl,
|
|
||||||
EndpointUrls = endpointUrls,
|
|
||||||
BrowseRoot = string.IsNullOrWhiteSpace(BrowseRoot) ? null : BrowseRoot,
|
|
||||||
ApplicationUri = ApplicationUri,
|
|
||||||
SessionName = SessionName,
|
|
||||||
AutoAcceptCertificates = AutoAcceptCertificates,
|
|
||||||
PerEndpointConnectTimeout = TimeSpan.FromSeconds(PerEndpointConnectTimeoutSeconds),
|
|
||||||
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
|
|
||||||
SessionTimeout = TimeSpan.FromSeconds(SessionTimeoutSeconds),
|
|
||||||
KeepAliveInterval = TimeSpan.FromSeconds(KeepAliveIntervalSeconds),
|
|
||||||
ReconnectPeriod = TimeSpan.FromSeconds(ReconnectPeriodSeconds),
|
|
||||||
MaxDiscoveredNodes = MaxDiscoveredNodes,
|
|
||||||
MaxBrowseDepth = MaxBrowseDepth,
|
|
||||||
SecurityMode = SecurityMode,
|
|
||||||
SecurityPolicy = SecurityPolicy,
|
|
||||||
AuthType = AuthType,
|
|
||||||
Username = string.IsNullOrWhiteSpace(Username) ? null : Username,
|
|
||||||
Password = string.IsNullOrWhiteSpace(Password) ? null : Password,
|
|
||||||
UserCertificatePath = string.IsNullOrWhiteSpace(UserCertificatePath) ? null : UserCertificatePath,
|
|
||||||
UserCertificatePassword = string.IsNullOrWhiteSpace(UserCertificatePassword) ? null : UserCertificatePassword,
|
|
||||||
TargetNamespaceKind = TargetNamespaceKind,
|
|
||||||
UnsMappingTable = _unsMappingTable,
|
|
||||||
ProbeTimeoutSeconds = ProbeTimeoutSeconds,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-356
@@ -1,356 +0,0 @@
|
|||||||
@page "/clusters/{ClusterId}/drivers/new/s7"
|
|
||||||
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
|
|
||||||
@rendermode RenderMode.InteractiveServer
|
|
||||||
@using Microsoft.AspNetCore.Components.Forms
|
|
||||||
@using Microsoft.EntityFrameworkCore
|
|
||||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients
|
|
||||||
@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
|
|
||||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
|
||||||
@using ZB.MOM.WW.OtOpcUa.Driver.S7
|
|
||||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
|
||||||
@inject NavigationManager Nav
|
|
||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
||||||
<h4 class="mb-0">@(IsNew ? "New Siemens S7 driver" : "Edit Siemens S7 driver") · <span class="mono">@ClusterId</span></h4>
|
|
||||||
<a href="/clusters/@ClusterId/drivers" class="btn btn-outline-secondary btn-sm">Cancel</a>
|
|
||||||
</div>
|
|
||||||
<ClusterNav ClusterId="@ClusterId" ActiveTab="drivers" />
|
|
||||||
|
|
||||||
@if (!_loaded)
|
|
||||||
{
|
|
||||||
<p>Loading…</p>
|
|
||||||
}
|
|
||||||
else if (!IsNew && _existing is null)
|
|
||||||
{
|
|
||||||
<section class="panel notice rise" style="animation-delay:.02s">
|
|
||||||
Driver instance <span class="mono">@DriverInstanceId</span> was not found in cluster <span class="mono">@ClusterId</span>.
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="s7DriverEdit">
|
|
||||||
<DataAnnotationsValidator />
|
|
||||||
<DriverFormShell IsNew="IsNew" Busy="_busy" Error="@_error"
|
|
||||||
CancelHref="@($"/clusters/{ClusterId}/drivers")"
|
|
||||||
OnDelete="@(IsNew ? null : (EventCallback?)EventCallback.Factory.Create(this, DeleteAsync))">
|
|
||||||
|
|
||||||
<DriverIdentitySection Model="_identityModel" IsNew="IsNew" ShowDriverType="false" />
|
|
||||||
|
|
||||||
@if (!IsNew && !string.IsNullOrEmpty(DriverInstanceId))
|
|
||||||
{
|
|
||||||
<DriverStatusPanel DriverInstanceId="@DriverInstanceId" Enabled="@_identityModel.Enabled" />
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="mt-2 mb-3">
|
|
||||||
<DriverTestConnectButton DriverType="@DriverTypeKey"
|
|
||||||
GetConfigJson="@SerializeCurrentConfig"
|
|
||||||
TimeoutSeconds="@_form.AdminProbeTimeoutSeconds" />
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
|
|
||||||
@onclick="@(() => _showPicker = true)">
|
|
||||||
Pick address
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DriverTagPicker @bind-Visible="_showPicker"
|
|
||||||
Title="S7 address"
|
|
||||||
CurrentAddress="@_pickedAddress"
|
|
||||||
OnPickAddress="@OnAddressPicked">
|
|
||||||
<S7AddressPickerBody CurrentAddress="@_pickedAddress"
|
|
||||||
CurrentAddressChanged="@((s) => _pickedAddress = s)" />
|
|
||||||
</DriverTagPicker>
|
|
||||||
|
|
||||||
@* Connection *@
|
|
||||||
<section class="panel rise mt-3" style="animation-delay:.05s">
|
|
||||||
<div class="panel-head">Connection</div>
|
|
||||||
<div style="padding:1rem">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6 mb-3">
|
|
||||||
<label class="form-label" for="s7Host">Host</label>
|
|
||||||
<InputText id="s7Host" @bind-Value="_form.Host"
|
|
||||||
class="form-control form-control-sm mono"
|
|
||||||
placeholder="192.168.0.1" />
|
|
||||||
<div class="form-text">PLC IP address or hostname.</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3 mb-3">
|
|
||||||
<label class="form-label" for="s7Port">Port</label>
|
|
||||||
<InputNumber id="s7Port" @bind-Value="_form.Port"
|
|
||||||
class="form-control form-control-sm" />
|
|
||||||
<div class="form-text">ISO-on-TCP; usually 102.</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3 mb-3">
|
|
||||||
<label class="form-label" for="s7TimeoutSec">Timeout (seconds)</label>
|
|
||||||
<InputNumber id="s7TimeoutSec" @bind-Value="_form.TimeoutSeconds"
|
|
||||||
class="form-control form-control-sm" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-4 mb-3">
|
|
||||||
<label class="form-label" for="s7CpuType">CPU type</label>
|
|
||||||
<InputSelect id="s7CpuType" @bind-Value="_form.CpuType"
|
|
||||||
class="form-select form-select-sm">
|
|
||||||
@foreach (var v in Enum.GetValues<S7CpuType>())
|
|
||||||
{
|
|
||||||
<option value="@v">@v</option>
|
|
||||||
}
|
|
||||||
</InputSelect>
|
|
||||||
<div class="form-text">Controls ISO-TSAP slot byte during handshake.</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-2 mb-3">
|
|
||||||
<label class="form-label" for="s7Rack">Rack</label>
|
|
||||||
<InputNumber id="s7Rack" @bind-Value="_form.Rack"
|
|
||||||
class="form-control form-control-sm" />
|
|
||||||
<div class="form-text">Almost always 0.</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-2 mb-3">
|
|
||||||
<label class="form-label" for="s7Slot">Slot</label>
|
|
||||||
<InputNumber id="s7Slot" @bind-Value="_form.Slot"
|
|
||||||
class="form-control form-control-sm" />
|
|
||||||
<div class="form-text">S7-300/400 = 2; S7-1200/1500 = 0.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
@* Probe *@
|
|
||||||
<section class="panel rise mt-3" style="animation-delay:.08s">
|
|
||||||
<div class="panel-head">Connectivity probe</div>
|
|
||||||
<div style="padding:1rem">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-3 mb-3">
|
|
||||||
<div class="form-check form-switch mt-2">
|
|
||||||
<InputCheckbox id="s7ProbeEnabled" @bind-Value="_form.ProbeEnabled"
|
|
||||||
class="form-check-input" />
|
|
||||||
<label class="form-check-label" for="s7ProbeEnabled">Probe enabled</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3 mb-3">
|
|
||||||
<label class="form-label" for="s7ProbeIntervalSec">Probe interval (s)</label>
|
|
||||||
<InputNumber id="s7ProbeIntervalSec" @bind-Value="_form.ProbeIntervalSeconds"
|
|
||||||
class="form-control form-control-sm" />
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3 mb-3">
|
|
||||||
<label class="form-label" for="s7ProbeTimeoutSec">Probe timeout (s)</label>
|
|
||||||
<InputNumber id="s7ProbeTimeoutSec" @bind-Value="_form.ProbeTimeoutSeconds"
|
|
||||||
class="form-control form-control-sm" />
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3 mb-3">
|
|
||||||
<label class="form-label" for="s7AdminProbeTimeout">Admin probe timeout (s)</label>
|
|
||||||
<InputNumber id="s7AdminProbeTimeout" @bind-Value="_form.AdminProbeTimeoutSeconds"
|
|
||||||
class="form-control form-control-sm" />
|
|
||||||
<div class="form-text">Test Connect timeout (1–60 s).</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
@* Tags *@
|
|
||||||
<section class="panel notice rise mt-3" style="animation-delay:.18s">
|
|
||||||
<div class="panel-head">Tags</div>
|
|
||||||
<div style="padding:1rem" class="text-muted">
|
|
||||||
Tag authoring moves to the Raw tree (<strong>/raw</strong>) in v3 Batch 2.
|
|
||||||
Pre-declared driver tags have been retired.
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
|
|
||||||
</DriverFormShell>
|
|
||||||
</EditForm>
|
|
||||||
}
|
|
||||||
|
|
||||||
@code {
|
|
||||||
[Parameter] public string ClusterId { get; set; } = "";
|
|
||||||
[Parameter] public string? DriverInstanceId { get; set; }
|
|
||||||
|
|
||||||
private const string DriverTypeKey = "S7";
|
|
||||||
|
|
||||||
private bool IsNew => string.IsNullOrEmpty(DriverInstanceId);
|
|
||||||
|
|
||||||
private static readonly System.Text.Json.JsonSerializerOptions _jsonOpts = new()
|
|
||||||
{
|
|
||||||
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
|
|
||||||
UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip,
|
|
||||||
WriteIndented = false,
|
|
||||||
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
|
|
||||||
};
|
|
||||||
|
|
||||||
private FormModel _form = new();
|
|
||||||
private DriverIdentitySection.DriverIdentityModel _identityModel = new() { DriverType = DriverTypeKey };
|
|
||||||
private DriverInstance? _existing;
|
|
||||||
private bool _loaded, _busy;
|
|
||||||
private string? _error;
|
|
||||||
|
|
||||||
// Address picker state
|
|
||||||
private bool _showPicker;
|
|
||||||
private string _pickedAddress = "";
|
|
||||||
|
|
||||||
private void OnAddressPicked(string address) => _pickedAddress = address;
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
|
||||||
{
|
|
||||||
await using var db = await DbFactory.CreateDbContextAsync();
|
|
||||||
|
|
||||||
if (IsNew)
|
|
||||||
{
|
|
||||||
_identityModel = new() { DriverType = DriverTypeKey, Enabled = true };
|
|
||||||
_form = FormModel.FromOptions(new S7DriverOptions());
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_existing = await db.DriverInstances.AsNoTracking()
|
|
||||||
.FirstOrDefaultAsync(d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
|
|
||||||
if (_existing is not null)
|
|
||||||
{
|
|
||||||
_identityModel = new()
|
|
||||||
{
|
|
||||||
DriverInstanceId = _existing.DriverInstanceId,
|
|
||||||
Name = _existing.Name,
|
|
||||||
DriverType = _existing.DriverType,
|
|
||||||
Enabled = _existing.Enabled,
|
|
||||||
};
|
|
||||||
var opts = TryDeserialize(_existing.DriverConfig) ?? new S7DriverOptions();
|
|
||||||
_form = FormModel.FromOptions(opts);
|
|
||||||
_form.ResilienceConfig = _existing.ResilienceConfig;
|
|
||||||
_form.RowVersion = _existing.RowVersion;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_loaded = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task SubmitAsync()
|
|
||||||
{
|
|
||||||
_busy = true; _error = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var opts = _form.ToOptions();
|
|
||||||
var configJson = System.Text.Json.JsonSerializer.Serialize(opts, _jsonOpts);
|
|
||||||
await using var db = await DbFactory.CreateDbContextAsync();
|
|
||||||
if (IsNew)
|
|
||||||
{
|
|
||||||
if (await db.DriverInstances.AnyAsync(d => d.DriverInstanceId == _identityModel.DriverInstanceId))
|
|
||||||
{
|
|
||||||
_error = $"Driver instance '{_identityModel.DriverInstanceId}' already exists."; return;
|
|
||||||
}
|
|
||||||
db.DriverInstances.Add(new DriverInstance
|
|
||||||
{
|
|
||||||
DriverInstanceId = _identityModel.DriverInstanceId,
|
|
||||||
ClusterId = ClusterId,
|
|
||||||
Name = _identityModel.Name,
|
|
||||||
DriverType = DriverTypeKey,
|
|
||||||
Enabled = _identityModel.Enabled,
|
|
||||||
DriverConfig = configJson,
|
|
||||||
ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var entity = await db.DriverInstances.FirstOrDefaultAsync(
|
|
||||||
d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
|
|
||||||
if (entity is null) { _error = "Row no longer exists."; return; }
|
|
||||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
|
|
||||||
entity.Name = _identityModel.Name;
|
|
||||||
entity.Enabled = _identityModel.Enabled;
|
|
||||||
entity.DriverConfig = configJson;
|
|
||||||
entity.ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig;
|
|
||||||
}
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
|
|
||||||
}
|
|
||||||
catch (DbUpdateConcurrencyException)
|
|
||||||
{
|
|
||||||
_error = "Another user changed this driver instance while you were editing. Reload to see the latest values, then re-apply your changes.";
|
|
||||||
}
|
|
||||||
catch (Exception ex) { _error = ex.Message; }
|
|
||||||
finally { _busy = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task DeleteAsync()
|
|
||||||
{
|
|
||||||
if (IsNew) return;
|
|
||||||
_busy = true; _error = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await using var db = await DbFactory.CreateDbContextAsync();
|
|
||||||
var entity = await db.DriverInstances.FirstOrDefaultAsync(
|
|
||||||
d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
|
|
||||||
if (entity is null) { Nav.NavigateTo($"/clusters/{ClusterId}/drivers"); return; }
|
|
||||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
|
|
||||||
db.DriverInstances.Remove(entity);
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
|
|
||||||
}
|
|
||||||
catch (DbUpdateConcurrencyException)
|
|
||||||
{
|
|
||||||
_error = "Another user changed this driver instance while you were viewing it. Reload before deleting.";
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_error = $"Delete failed: {ex.Message}. (Likely because equipment/tags still reference this driver — remove them first.)";
|
|
||||||
}
|
|
||||||
finally { _busy = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
private string SerializeCurrentConfig()
|
|
||||||
=> System.Text.Json.JsonSerializer.Serialize(
|
|
||||||
_form.ToOptions(), _jsonOpts);
|
|
||||||
|
|
||||||
private static S7DriverOptions? TryDeserialize(string json)
|
|
||||||
{
|
|
||||||
try { return System.Text.Json.JsonSerializer.Deserialize<S7DriverOptions>(json, _jsonOpts); }
|
|
||||||
catch { return null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Flat mutable model — all scalar properties settable for Blazor @bind-Value.
|
|
||||||
public sealed class FormModel
|
|
||||||
{
|
|
||||||
// Connection
|
|
||||||
public string Host { get; set; } = "127.0.0.1";
|
|
||||||
public int Port { get; set; } = 102;
|
|
||||||
public S7CpuType CpuType { get; set; } = S7CpuType.S71500;
|
|
||||||
public short Rack { get; set; } = 0;
|
|
||||||
public short Slot { get; set; } = 0;
|
|
||||||
public int TimeoutSeconds { get; set; } = 5;
|
|
||||||
|
|
||||||
// Probe
|
|
||||||
public bool ProbeEnabled { get; set; } = true;
|
|
||||||
public int ProbeIntervalSeconds { get; set; } = 5;
|
|
||||||
public int ProbeTimeoutSeconds { get; set; } = 2;
|
|
||||||
public int AdminProbeTimeoutSeconds { get; set; } = 5;
|
|
||||||
|
|
||||||
// Common
|
|
||||||
public string? ResilienceConfig { get; set; }
|
|
||||||
public byte[] RowVersion { get; set; } = [];
|
|
||||||
|
|
||||||
public static FormModel FromOptions(S7DriverOptions o) => new()
|
|
||||||
{
|
|
||||||
Host = o.Host,
|
|
||||||
Port = o.Port,
|
|
||||||
CpuType = o.CpuType,
|
|
||||||
Rack = o.Rack,
|
|
||||||
Slot = o.Slot,
|
|
||||||
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
|
|
||||||
ProbeEnabled = o.Probe.Enabled,
|
|
||||||
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
|
|
||||||
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
|
|
||||||
AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
|
|
||||||
};
|
|
||||||
|
|
||||||
public S7DriverOptions ToOptions() => new()
|
|
||||||
{
|
|
||||||
Host = Host,
|
|
||||||
Port = Port,
|
|
||||||
CpuType = CpuType,
|
|
||||||
Rack = Rack,
|
|
||||||
Slot = Slot,
|
|
||||||
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
|
|
||||||
Probe = new S7ProbeOptions
|
|
||||||
{
|
|
||||||
Enabled = ProbeEnabled,
|
|
||||||
Interval = TimeSpan.FromSeconds(ProbeIntervalSeconds),
|
|
||||||
Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds),
|
|
||||||
},
|
|
||||||
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-410
@@ -1,410 +0,0 @@
|
|||||||
@page "/clusters/{ClusterId}/drivers/new/twincat"
|
|
||||||
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
|
|
||||||
@rendermode RenderMode.InteractiveServer
|
|
||||||
@using Microsoft.AspNetCore.Components.Forms
|
|
||||||
@using Microsoft.EntityFrameworkCore
|
|
||||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients
|
|
||||||
@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
|
|
||||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
|
||||||
@using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT
|
|
||||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
|
||||||
@inject NavigationManager Nav
|
|
||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
||||||
<h4 class="mb-0">@(IsNew ? "New Beckhoff TwinCAT driver" : "Edit Beckhoff TwinCAT driver") · <span class="mono">@ClusterId</span></h4>
|
|
||||||
<a href="/clusters/@ClusterId/drivers" class="btn btn-outline-secondary btn-sm">Cancel</a>
|
|
||||||
</div>
|
|
||||||
<ClusterNav ClusterId="@ClusterId" ActiveTab="drivers" />
|
|
||||||
|
|
||||||
@if (!_loaded)
|
|
||||||
{
|
|
||||||
<p>Loading…</p>
|
|
||||||
}
|
|
||||||
else if (!IsNew && _existing is null)
|
|
||||||
{
|
|
||||||
<section class="panel notice rise" style="animation-delay:.02s">
|
|
||||||
Driver instance <span class="mono">@DriverInstanceId</span> was not found in cluster <span class="mono">@ClusterId</span>.
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="twincatDriverEdit">
|
|
||||||
<DataAnnotationsValidator />
|
|
||||||
<DriverFormShell IsNew="IsNew" Busy="_busy" Error="@_error"
|
|
||||||
CancelHref="@($"/clusters/{ClusterId}/drivers")"
|
|
||||||
OnDelete="@(IsNew ? null : (EventCallback?)EventCallback.Factory.Create(this, DeleteAsync))">
|
|
||||||
|
|
||||||
<DriverIdentitySection Model="_identityModel" IsNew="IsNew" ShowDriverType="false" />
|
|
||||||
|
|
||||||
@if (!IsNew && !string.IsNullOrEmpty(DriverInstanceId))
|
|
||||||
{
|
|
||||||
<DriverStatusPanel DriverInstanceId="@DriverInstanceId" Enabled="@_identityModel.Enabled" />
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="mt-2 mb-3">
|
|
||||||
<DriverTestConnectButton DriverType="@DriverTypeKey"
|
|
||||||
GetConfigJson="@SerializeCurrentConfig"
|
|
||||||
TimeoutSeconds="@_form.AdminProbeTimeoutSeconds" />
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
|
|
||||||
@onclick="@(() => _showPicker = true)">
|
|
||||||
Pick address
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DriverTagPicker @bind-Visible="_showPicker"
|
|
||||||
Title="TwinCAT address"
|
|
||||||
CurrentAddress="@_pickedAddress"
|
|
||||||
OnPickAddress="@OnAddressPicked">
|
|
||||||
<TwinCATAddressPickerBody CurrentAddress="@_pickedAddress"
|
|
||||||
CurrentAddressChanged="@((s) => _pickedAddress = s)"
|
|
||||||
GetConfigJson="@SerializeCurrentConfig" />
|
|
||||||
</DriverTagPicker>
|
|
||||||
|
|
||||||
@* Options *@
|
|
||||||
<section class="panel rise mt-3" style="animation-delay:.05s">
|
|
||||||
<div class="panel-head">Options</div>
|
|
||||||
<div style="padding:1rem">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-3 mb-3">
|
|
||||||
<label class="form-label" for="tcTimeoutSec">Timeout (seconds)</label>
|
|
||||||
<InputNumber id="tcTimeoutSec" @bind-Value="_form.TimeoutSeconds"
|
|
||||||
class="form-control form-control-sm" />
|
|
||||||
<div class="form-text">Default 2 s per operation.</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4 mb-3">
|
|
||||||
<div class="form-check form-switch mt-4">
|
|
||||||
<InputCheckbox id="tcNativeNotif" @bind-Value="_form.UseNativeNotifications"
|
|
||||||
class="form-check-input" />
|
|
||||||
<label class="form-check-label" for="tcNativeNotif">Use native ADS notifications</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-text">Recommended. Disable for AMS routers with notification limits.</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4 mb-3">
|
|
||||||
<div class="form-check form-switch mt-4">
|
|
||||||
<InputCheckbox id="tcControllerBrowse" @bind-Value="_form.EnableControllerBrowse"
|
|
||||||
class="form-check-input" />
|
|
||||||
<label class="form-check-label" for="tcControllerBrowse">Enable controller browse</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-text">Walks the symbol table via SymbolLoaderFactory at discovery. Default off.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-4 mb-3">
|
|
||||||
<label class="form-label" for="tcNotifDelay">Notification max delay (ms)</label>
|
|
||||||
<InputNumber id="tcNotifDelay" @bind-Value="_form.NotificationMaxDelayMs"
|
|
||||||
class="form-control form-control-sm" />
|
|
||||||
<div class="form-text">0 = push immediately. Increase to coalesce high-churn signals.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
@* Probe *@
|
|
||||||
<section class="panel rise mt-3" style="animation-delay:.08s">
|
|
||||||
<div class="panel-head">Connectivity probe</div>
|
|
||||||
<div style="padding:1rem">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-3 mb-3">
|
|
||||||
<div class="form-check form-switch mt-2">
|
|
||||||
<InputCheckbox id="tcProbeEnabled" @bind-Value="_form.ProbeEnabled"
|
|
||||||
class="form-check-input" />
|
|
||||||
<label class="form-check-label" for="tcProbeEnabled">Probe enabled</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3 mb-3">
|
|
||||||
<label class="form-label" for="tcProbeInterval">Probe interval (s)</label>
|
|
||||||
<InputNumber id="tcProbeInterval" @bind-Value="_form.ProbeIntervalSeconds"
|
|
||||||
class="form-control form-control-sm" />
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3 mb-3">
|
|
||||||
<label class="form-label" for="tcProbeTimeout">Probe timeout (s)</label>
|
|
||||||
<InputNumber id="tcProbeTimeout" @bind-Value="_form.ProbeTimeoutSeconds"
|
|
||||||
class="form-control form-control-sm" />
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3 mb-3">
|
|
||||||
<label class="form-label" for="tcAdminProbe">Admin probe timeout (s)</label>
|
|
||||||
<InputNumber id="tcAdminProbe" @bind-Value="_form.AdminProbeTimeoutSeconds"
|
|
||||||
class="form-control form-control-sm" />
|
|
||||||
<div class="form-text">Test Connect timeout (1–60 s).</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
@* Devices *@
|
|
||||||
<CollectionEditor TRow="TwinCATDeviceRow" Items="_devices" Title="Devices" ItemNoun="device"
|
|
||||||
AnimationDelay=".11s"
|
|
||||||
NewRow="@(() => new TwinCATDeviceRow())" Clone="@(r => r.Clone())"
|
|
||||||
Validate="TwinCATDeviceRow.ValidateRow">
|
|
||||||
<HeaderTemplate>
|
|
||||||
<tr><th>Host address</th><th>Device name</th><th></th></tr>
|
|
||||||
</HeaderTemplate>
|
|
||||||
<RowTemplate Context="d">
|
|
||||||
<td class="mono">@d.HostAddress</td>
|
|
||||||
<td>@(string.IsNullOrWhiteSpace(d.DeviceName) ? "—" : d.DeviceName)</td>
|
|
||||||
</RowTemplate>
|
|
||||||
<EditTemplate Context="d">
|
|
||||||
<div class="row g-3">
|
|
||||||
<div class="col-md-6"><label class="form-label">Host address (AMS Net Id:port)</label>
|
|
||||||
<input class="form-control form-control-sm mono" @bind="d.HostAddress"
|
|
||||||
placeholder="192.168.0.1.1.1:851" /></div>
|
|
||||||
<div class="col-md-6"><label class="form-label">Device name</label>
|
|
||||||
<input class="form-control form-control-sm" @bind="d.DeviceName" /></div>
|
|
||||||
</div>
|
|
||||||
</EditTemplate>
|
|
||||||
</CollectionEditor>
|
|
||||||
|
|
||||||
@* Tags *@
|
|
||||||
<section class="panel notice rise mt-3" style="animation-delay:.18s">
|
|
||||||
<div class="panel-head">Tags</div>
|
|
||||||
<div style="padding:1rem" class="text-muted">
|
|
||||||
Tag authoring moves to the Raw tree (<strong>/raw</strong>) in v3 Batch 2.
|
|
||||||
Pre-declared driver tags have been retired.
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
|
|
||||||
</DriverFormShell>
|
|
||||||
</EditForm>
|
|
||||||
}
|
|
||||||
|
|
||||||
@code {
|
|
||||||
[Parameter] public string ClusterId { get; set; } = "";
|
|
||||||
[Parameter] public string? DriverInstanceId { get; set; }
|
|
||||||
|
|
||||||
private const string DriverTypeKey = "TwinCat";
|
|
||||||
|
|
||||||
private bool IsNew => string.IsNullOrEmpty(DriverInstanceId);
|
|
||||||
|
|
||||||
private static readonly System.Text.Json.JsonSerializerOptions _jsonOpts = new()
|
|
||||||
{
|
|
||||||
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
|
|
||||||
UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip,
|
|
||||||
WriteIndented = false,
|
|
||||||
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
|
|
||||||
};
|
|
||||||
|
|
||||||
private FormModel _form = new();
|
|
||||||
private DriverIdentitySection.DriverIdentityModel _identityModel = new() { DriverType = DriverTypeKey };
|
|
||||||
private DriverInstance? _existing;
|
|
||||||
private bool _loaded, _busy;
|
|
||||||
private string? _error;
|
|
||||||
|
|
||||||
// Address picker state
|
|
||||||
private bool _showPicker;
|
|
||||||
private string _pickedAddress = "";
|
|
||||||
|
|
||||||
private void OnAddressPicked(string address) => _pickedAddress = address;
|
|
||||||
|
|
||||||
// Held separately because Devices is a collection — edited via the CollectionEditor modal.
|
|
||||||
private List<TwinCATDeviceRow> _devices = [];
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
|
||||||
{
|
|
||||||
await using var db = await DbFactory.CreateDbContextAsync();
|
|
||||||
|
|
||||||
if (IsNew)
|
|
||||||
{
|
|
||||||
_identityModel = new() { DriverType = DriverTypeKey, Enabled = true };
|
|
||||||
_form = FormModel.FromOptions(new TwinCATDriverOptions());
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_existing = await db.DriverInstances.AsNoTracking()
|
|
||||||
.FirstOrDefaultAsync(d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
|
|
||||||
if (_existing is not null)
|
|
||||||
{
|
|
||||||
_identityModel = new()
|
|
||||||
{
|
|
||||||
DriverInstanceId = _existing.DriverInstanceId,
|
|
||||||
Name = _existing.Name,
|
|
||||||
DriverType = _existing.DriverType,
|
|
||||||
Enabled = _existing.Enabled,
|
|
||||||
};
|
|
||||||
var opts = TryDeserialize(_existing.DriverConfig) ?? new TwinCATDriverOptions();
|
|
||||||
_form = FormModel.FromOptions(opts);
|
|
||||||
_form.ResilienceConfig = _existing.ResilienceConfig;
|
|
||||||
_form.RowVersion = _existing.RowVersion;
|
|
||||||
_devices = opts.Devices.Select(TwinCATDeviceRow.FromDefinition).ToList();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_loaded = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task SubmitAsync()
|
|
||||||
{
|
|
||||||
_busy = true; _error = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var configJson = System.Text.Json.JsonSerializer.Serialize(
|
|
||||||
_form.ToOptions(
|
|
||||||
_devices.Select(r => r.ToDefinition()).ToList()),
|
|
||||||
_jsonOpts);
|
|
||||||
await using var db = await DbFactory.CreateDbContextAsync();
|
|
||||||
if (IsNew)
|
|
||||||
{
|
|
||||||
if (await db.DriverInstances.AnyAsync(d => d.DriverInstanceId == _identityModel.DriverInstanceId))
|
|
||||||
{
|
|
||||||
_error = $"Driver instance '{_identityModel.DriverInstanceId}' already exists."; return;
|
|
||||||
}
|
|
||||||
db.DriverInstances.Add(new DriverInstance
|
|
||||||
{
|
|
||||||
DriverInstanceId = _identityModel.DriverInstanceId,
|
|
||||||
ClusterId = ClusterId,
|
|
||||||
Name = _identityModel.Name,
|
|
||||||
DriverType = DriverTypeKey,
|
|
||||||
Enabled = _identityModel.Enabled,
|
|
||||||
DriverConfig = configJson,
|
|
||||||
ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var entity = await db.DriverInstances.FirstOrDefaultAsync(
|
|
||||||
d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
|
|
||||||
if (entity is null) { _error = "Row no longer exists."; return; }
|
|
||||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
|
|
||||||
entity.Name = _identityModel.Name;
|
|
||||||
entity.Enabled = _identityModel.Enabled;
|
|
||||||
entity.DriverConfig = configJson;
|
|
||||||
entity.ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig;
|
|
||||||
}
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
|
|
||||||
}
|
|
||||||
catch (DbUpdateConcurrencyException)
|
|
||||||
{
|
|
||||||
_error = "Another user changed this driver instance while you were editing. Reload to see the latest values, then re-apply your changes.";
|
|
||||||
}
|
|
||||||
catch (Exception ex) { _error = ex.Message; }
|
|
||||||
finally { _busy = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task DeleteAsync()
|
|
||||||
{
|
|
||||||
if (IsNew) return;
|
|
||||||
_busy = true; _error = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await using var db = await DbFactory.CreateDbContextAsync();
|
|
||||||
var entity = await db.DriverInstances.FirstOrDefaultAsync(
|
|
||||||
d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
|
|
||||||
if (entity is null) { Nav.NavigateTo($"/clusters/{ClusterId}/drivers"); return; }
|
|
||||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
|
|
||||||
db.DriverInstances.Remove(entity);
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
|
|
||||||
}
|
|
||||||
catch (DbUpdateConcurrencyException)
|
|
||||||
{
|
|
||||||
_error = "Another user changed this driver instance while you were viewing it. Reload before deleting.";
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_error = $"Delete failed: {ex.Message}. (Likely because equipment/tags still reference this driver — remove them first.)";
|
|
||||||
}
|
|
||||||
finally { _busy = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
private string SerializeCurrentConfig()
|
|
||||||
=> System.Text.Json.JsonSerializer.Serialize(
|
|
||||||
_form.ToOptions(
|
|
||||||
_devices.Select(r => r.ToDefinition()).ToList()),
|
|
||||||
_jsonOpts);
|
|
||||||
|
|
||||||
private static TwinCATDriverOptions? TryDeserialize(string json)
|
|
||||||
{
|
|
||||||
try { return System.Text.Json.JsonSerializer.Deserialize<TwinCATDriverOptions>(json, _jsonOpts); }
|
|
||||||
catch { return null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mutable VM for the modal editor — TwinCATDeviceOptions is an immutable record.
|
|
||||||
public sealed class TwinCATDeviceRow
|
|
||||||
{
|
|
||||||
public string HostAddress { get; set; } = "";
|
|
||||||
public string? DeviceName { get; set; }
|
|
||||||
|
|
||||||
// Original record (null for newly-added rows). Preserves any fields the editor doesn't
|
|
||||||
// expose across a load→save.
|
|
||||||
private TwinCATDeviceOptions? _source;
|
|
||||||
|
|
||||||
public TwinCATDeviceRow Clone() => (TwinCATDeviceRow)MemberwiseClone(); // _source is an immutable record ref — safe to share
|
|
||||||
|
|
||||||
public static TwinCATDeviceRow FromDefinition(TwinCATDeviceOptions d) => new()
|
|
||||||
{
|
|
||||||
HostAddress = d.HostAddress, DeviceName = d.DeviceName,
|
|
||||||
_source = d,
|
|
||||||
};
|
|
||||||
|
|
||||||
public TwinCATDeviceOptions ToDefinition()
|
|
||||||
{
|
|
||||||
var baseDef = _source ?? new TwinCATDeviceOptions(HostAddress.Trim());
|
|
||||||
return baseDef with
|
|
||||||
{
|
|
||||||
HostAddress = HostAddress.Trim(),
|
|
||||||
DeviceName = string.IsNullOrWhiteSpace(DeviceName) ? null : DeviceName.Trim(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string? ValidateRow(TwinCATDeviceRow row, IReadOnlyList<TwinCATDeviceRow> all, int? editIndex)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(row.HostAddress)) return "Host address is required.";
|
|
||||||
for (var i = 0; i < all.Count; i++)
|
|
||||||
if (i != editIndex && string.Equals(all[i].HostAddress, row.HostAddress, StringComparison.OrdinalIgnoreCase))
|
|
||||||
return $"Duplicate device host address '{row.HostAddress}'.";
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Flat mutable model — all scalar properties settable for Blazor @bind-Value.
|
|
||||||
// The Devices collection is kept on the component and passed in on ToOptions().
|
|
||||||
public sealed class FormModel
|
|
||||||
{
|
|
||||||
// Options
|
|
||||||
public int TimeoutSeconds { get; set; } = 2;
|
|
||||||
public bool UseNativeNotifications { get; set; } = true;
|
|
||||||
public bool EnableControllerBrowse { get; set; } = false;
|
|
||||||
public int NotificationMaxDelayMs { get; set; } = 0;
|
|
||||||
|
|
||||||
// Probe
|
|
||||||
public bool ProbeEnabled { get; set; } = true;
|
|
||||||
public int ProbeIntervalSeconds { get; set; } = 5;
|
|
||||||
public int ProbeTimeoutSeconds { get; set; } = 2;
|
|
||||||
public int AdminProbeTimeoutSeconds { get; set; } = 10;
|
|
||||||
|
|
||||||
// Common
|
|
||||||
public string? ResilienceConfig { get; set; }
|
|
||||||
public byte[] RowVersion { get; set; } = [];
|
|
||||||
|
|
||||||
public static FormModel FromOptions(TwinCATDriverOptions o) => new()
|
|
||||||
{
|
|
||||||
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
|
|
||||||
UseNativeNotifications = o.UseNativeNotifications,
|
|
||||||
EnableControllerBrowse = o.EnableControllerBrowse,
|
|
||||||
NotificationMaxDelayMs = o.NotificationMaxDelayMs,
|
|
||||||
ProbeEnabled = o.Probe.Enabled,
|
|
||||||
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
|
|
||||||
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
|
|
||||||
AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
|
|
||||||
};
|
|
||||||
|
|
||||||
public TwinCATDriverOptions ToOptions(
|
|
||||||
IReadOnlyList<TwinCATDeviceOptions> devices) => new()
|
|
||||||
{
|
|
||||||
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
|
|
||||||
UseNativeNotifications = UseNativeNotifications,
|
|
||||||
EnableControllerBrowse = EnableControllerBrowse,
|
|
||||||
NotificationMaxDelayMs = NotificationMaxDelayMs,
|
|
||||||
Probe = new TwinCATProbeOptions
|
|
||||||
{
|
|
||||||
Enabled = ProbeEnabled,
|
|
||||||
Interval = TimeSpan.FromSeconds(ProbeIntervalSeconds),
|
|
||||||
Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds),
|
|
||||||
},
|
|
||||||
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
|
|
||||||
Devices = devices,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
@page "/dev/context-menu-demo"
|
||||||
|
@* Dev-only host page to live-verify the reusable ContextMenu component. *@
|
||||||
|
@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
|
||||||
|
@rendermode InteractiveServer
|
||||||
|
|
||||||
|
<PageTitle>ContextMenu demo</PageTitle>
|
||||||
|
|
||||||
|
<h1>ContextMenu demo</h1>
|
||||||
|
<p>Right-click the card below, or use the <strong>⋯</strong> button.</p>
|
||||||
|
|
||||||
|
<div style="display:flex; align-items:center; gap:1rem; margin:1rem 0;">
|
||||||
|
<ContextMenu Items="_items" ShowEllipsis="false">
|
||||||
|
<div style="padding:2rem 3rem; border:1px dashed var(--bs-border-color); border-radius:.5rem; user-select:none;">
|
||||||
|
Right-click me
|
||||||
|
</div>
|
||||||
|
</ContextMenu>
|
||||||
|
|
||||||
|
<ContextMenu Items="_items" ShowEllipsis="true" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>Last action: <strong>@_lastAction</strong></p>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private string _lastAction = "(none)";
|
||||||
|
|
||||||
|
private IReadOnlyList<ContextMenuItem> _items => new List<ContextMenuItem>
|
||||||
|
{
|
||||||
|
new() { Label = "Edit", Icon = "✏️", OnClick = () => Set("Edit") },
|
||||||
|
new() { Label = "Duplicate", Icon = "📄", OnClick = () => Set("Duplicate") },
|
||||||
|
ContextMenuItem.Separator(),
|
||||||
|
new() { Label = "Delete", Icon = "🗑️", Disabled = true, OnClick = () => Set("Delete") },
|
||||||
|
};
|
||||||
|
|
||||||
|
private Task Set(string action)
|
||||||
|
{
|
||||||
|
_lastAction = action;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
@page "/raw"
|
||||||
|
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
|
||||||
|
@rendermode RenderMode.InteractiveServer
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw
|
||||||
|
@inject IRawTreeService Svc
|
||||||
|
|
||||||
|
@* Peer of GlobalUns (/uns) for the v3 Raw project tree — the cluster-rooted
|
||||||
|
Enterprise → Cluster → Folder → Driver → Device → TagGroup → Tag hierarchy.
|
||||||
|
Loads the eager roots on init, then hands off to RawTree, which lazily expands
|
||||||
|
each container via IRawTreeService.LoadChildrenAsync and surfaces per-node
|
||||||
|
context menus (real create/configure/tag modals arrive in later Batch-2 waves). *@
|
||||||
|
|
||||||
|
<PageTitle>Raw</PageTitle>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h4 class="mb-0">Raw</h4>
|
||||||
|
<span class="text-muted small">Changes apply on the next deployment.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="panel rise" style="animation-delay:.02s">
|
||||||
|
<div class="panel-head d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||||
|
<span>Project tree</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (_loading)
|
||||||
|
{
|
||||||
|
<div style="padding:1rem" class="text-muted">
|
||||||
|
<span class="spinner-border spinner-border-sm me-1"></span>Loading…
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (_loadError is not null)
|
||||||
|
{
|
||||||
|
<div style="padding:1rem" class="text-danger">@_loadError</div>
|
||||||
|
}
|
||||||
|
else if (_roots.Count == 0)
|
||||||
|
{
|
||||||
|
<div style="padding:1rem" class="text-muted">No clusters yet.</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div style="padding:.5rem 1rem">
|
||||||
|
<RawTree Roots="_roots" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private IReadOnlyList<RawNode> _roots = Array.Empty<RawNode>();
|
||||||
|
private bool _loading = true;
|
||||||
|
private string? _loadError;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_roots = await Svc.LoadRootsAsync();
|
||||||
|
// Auto-expand the Enterprise roots so the Cluster level (which owns the lazy plumbing) is
|
||||||
|
// visible on load, mirroring GlobalUns.ExpandStructural. Enterprise children (clusters) are
|
||||||
|
// eagerly materialised by LoadRootsAsync, so this needs no lazy fetch; clusters stay
|
||||||
|
// collapsed and load their folders/drivers on first expand.
|
||||||
|
foreach (var enterprise in _roots)
|
||||||
|
enterprise.Expanded = true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_loadError = $"Failed to load the Raw tree: {ex.Message}";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,17 +78,9 @@ else
|
|||||||
</InputSelect>
|
</InputSelect>
|
||||||
<ValidationMessage For="@(() => _form.UnsLineId)" />
|
<ValidationMessage For="@(() => _form.UnsLineId)" />
|
||||||
</div>
|
</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>
|
</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="row">
|
||||||
<div class="col-md-6 mb-3">
|
<div class="col-md-6 mb-3">
|
||||||
<label class="form-label" for="eq-ztag">ZTag (ERP)</label>
|
<label class="form-label" for="eq-ztag">ZTag (ERP)</label>
|
||||||
@@ -138,38 +130,48 @@ else
|
|||||||
}
|
}
|
||||||
else if (_activeTab == "tags")
|
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">
|
<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>
|
</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>
|
<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
|
else
|
||||||
{
|
{
|
||||||
<table class="table table-sm">
|
<table class="table table-sm align-middle">
|
||||||
<thead>
|
<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>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@foreach (var t in _tags)
|
@foreach (var r in _refs)
|
||||||
{
|
{
|
||||||
<tr @key="t.TagId">
|
<tr @key="r.UnsTagReferenceId">
|
||||||
<td>@t.Name</td>
|
<td>@r.EffectiveName</td>
|
||||||
<td class="mono">@t.DriverInstanceId</td>
|
<td class="mono small">@r.RawPath</td>
|
||||||
<td>@t.DataType</td>
|
<td>@r.DataType</td>
|
||||||
<td>@t.AccessLevel</td>
|
<td>@r.AccessLevel</td>
|
||||||
<td class="text-end">
|
<td>
|
||||||
<button type="button" class="btn btn-outline-secondary btn-sm me-1" @onclick="() => OpenEditTag(t.TagId)">Edit</button>
|
<input class="form-control form-control-sm" @bind="_overrideEdits[r.UnsTagReferenceId]"
|
||||||
<button type="button" class="btn btn-outline-danger btn-sm" @onclick="() => DeleteTag(t.TagId)">Delete</button>
|
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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
@@ -177,9 +179,8 @@ else
|
|||||||
</table>
|
</table>
|
||||||
}
|
}
|
||||||
|
|
||||||
<TagModal Visible="_tagModalVisible" IsNew="_tagModalIsNew" EquipmentId="@EquipmentId"
|
<AddReferenceModal Visible="_refModalVisible" EquipmentId="@EquipmentId"
|
||||||
Existing="_tagModalExisting" Drivers="_tagDriverOptions"
|
OnCommitted="OnReferencesCommittedAsync" OnCancel="@(() => { _refModalVisible = false; })" />
|
||||||
OnSaved="OnTagSavedAsync" OnCancel="@(() => { _tagModalVisible = false; })" />
|
|
||||||
}
|
}
|
||||||
else if (_activeTab == "vtags")
|
else if (_activeTab == "vtags")
|
||||||
{
|
{
|
||||||
@@ -290,15 +291,13 @@ else
|
|||||||
private EquipmentEditDto? _equipment;
|
private EquipmentEditDto? _equipment;
|
||||||
private FormModel _form = new();
|
private FormModel _form = new();
|
||||||
private IReadOnlyList<(string Id, string Display)> _lineOptions = Array.Empty<(string, string)>();
|
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). ---
|
// --- Tags tab state (v3 reference-only). _refs is null until the tab is first activated (lazy load +
|
||||||
private IReadOnlyList<EquipmentTagRow>? _tags;
|
// spinner). _overrideEdits carries the per-row editable display-name override, keyed by reference id. ---
|
||||||
private string? _tagError;
|
private IReadOnlyList<EquipmentReferenceRow>? _refs;
|
||||||
private bool _tagModalVisible;
|
private readonly Dictionary<string, string?> _overrideEdits = new(StringComparer.Ordinal);
|
||||||
private bool _tagModalIsNew;
|
private string? _refError;
|
||||||
private TagEditDto? _tagModalExisting;
|
private bool _refModalVisible;
|
||||||
private IReadOnlyList<(string Id, string Display, string DriverType, string DriverConfig)> _tagDriverOptions = Array.Empty<(string, string, string, string)>();
|
|
||||||
|
|
||||||
// --- Virtual Tags tab state. _vtags is null until the tab is first activated. ---
|
// --- Virtual Tags tab state. _vtags is null until the tab is first activated. ---
|
||||||
private IReadOnlyList<EquipmentVirtualTagRow>? _vtags;
|
private IReadOnlyList<EquipmentVirtualTagRow>? _vtags;
|
||||||
@@ -329,54 +328,48 @@ else
|
|||||||
{
|
{
|
||||||
_activeTab = tab;
|
_activeTab = tab;
|
||||||
if (IsNew) { return; }
|
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 == "vtags" && _vtags is null) { await ReloadVirtualTagsAsync(); }
|
||||||
else if (tab == "alarms" && _alarms is null) { await ReloadAlarmsAsync(); }
|
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;
|
_refError = null;
|
||||||
_tagModalIsNew = true;
|
_refModalVisible = true;
|
||||||
_tagModalExisting = null;
|
|
||||||
_tagDriverOptions = await Svc.LoadTagDriversForEquipmentAsync(EquipmentId!);
|
|
||||||
_tagModalVisible = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task OpenEditTag(string tagId)
|
private async Task OnReferencesCommittedAsync()
|
||||||
{
|
{
|
||||||
_tagError = null;
|
_refModalVisible = false;
|
||||||
var dto = await Svc.LoadTagAsync(tagId);
|
await ReloadReferencesAsync();
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task OnTagSavedAsync()
|
private async Task SaveOverride(EquipmentReferenceRow r)
|
||||||
{
|
{
|
||||||
_tagModalVisible = false;
|
_refError = null;
|
||||||
await ReloadTagsAsync();
|
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;
|
_refError = null;
|
||||||
_tagError = null;
|
var res = await Svc.RemoveReferenceAsync(r.UnsTagReferenceId, r.RowVersion);
|
||||||
// Load the tag fresh to capture its current RowVersion for the optimistic-concurrency delete.
|
if (res.Ok) { await ReloadReferencesAsync(); }
|
||||||
var dto = await Svc.LoadTagAsync(tagId);
|
else { _refError = res.Error; }
|
||||||
if (dto is null) { await ReloadTagsAsync(); return; }
|
|
||||||
var r = await Svc.DeleteTagAsync(tagId, dto.RowVersion);
|
|
||||||
if (r.Ok) { await ReloadTagsAsync(); }
|
|
||||||
else { _tagError = r.Error; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Virtual Tags tab handlers ---
|
// --- Virtual Tags tab handlers ---
|
||||||
@@ -478,7 +471,7 @@ else
|
|||||||
// path lands on Details because the field initializes to "details" and a fresh page instance
|
// 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
|
// 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.
|
// lazy loaders re-fetch for the (possibly different) equipment this parameter set targets.
|
||||||
_tags = null;
|
_refs = null;
|
||||||
_vtags = null;
|
_vtags = null;
|
||||||
_alarms = null;
|
_alarms = null;
|
||||||
if (!IsNew)
|
if (!IsNew)
|
||||||
@@ -489,7 +482,6 @@ else
|
|||||||
LoadFormFrom(_equipment);
|
LoadFormFrom(_equipment);
|
||||||
var ctx = await Svc.LoadEquipmentPickContextAsync(_equipment.UnsLineId);
|
var ctx = await Svc.LoadEquipmentPickContextAsync(_equipment.UnsLineId);
|
||||||
_lineOptions = ctx.Lines;
|
_lineOptions = ctx.Lines;
|
||||||
_driverOptions = ctx.Drivers;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -497,7 +489,6 @@ else
|
|||||||
_form = new FormModel { UnsLineId = LineId ?? "" };
|
_form = new FormModel { UnsLineId = LineId ?? "" };
|
||||||
var ctx = await Svc.LoadEquipmentPickContextAsync(LineId);
|
var ctx = await Svc.LoadEquipmentPickContextAsync(LineId);
|
||||||
_lineOptions = ctx.Lines;
|
_lineOptions = ctx.Lines;
|
||||||
_driverOptions = ctx.Drivers;
|
|
||||||
}
|
}
|
||||||
_loading = false;
|
_loading = false;
|
||||||
}
|
}
|
||||||
@@ -510,7 +501,6 @@ else
|
|||||||
Name = e.Name,
|
Name = e.Name,
|
||||||
MachineCode = e.MachineCode,
|
MachineCode = e.MachineCode,
|
||||||
UnsLineId = e.UnsLineId,
|
UnsLineId = e.UnsLineId,
|
||||||
DriverInstanceId = e.DriverInstanceId,
|
|
||||||
ZTag = e.ZTag,
|
ZTag = e.ZTag,
|
||||||
SAPID = e.SAPID,
|
SAPID = e.SAPID,
|
||||||
Manufacturer = e.Manufacturer,
|
Manufacturer = e.Manufacturer,
|
||||||
@@ -536,7 +526,6 @@ else
|
|||||||
_form.Name,
|
_form.Name,
|
||||||
_form.MachineCode,
|
_form.MachineCode,
|
||||||
_form.UnsLineId,
|
_form.UnsLineId,
|
||||||
_form.DriverInstanceId,
|
|
||||||
_form.ZTag,
|
_form.ZTag,
|
||||||
_form.SAPID,
|
_form.SAPID,
|
||||||
_form.Manufacturer,
|
_form.Manufacturer,
|
||||||
@@ -582,7 +571,6 @@ else
|
|||||||
public string Name { get; set; } = "";
|
public string Name { get; set; } = "";
|
||||||
[Required] public string MachineCode { get; set; } = "";
|
[Required] public string MachineCode { get; set; } = "";
|
||||||
[Required] public string UnsLineId { get; set; } = "";
|
[Required] public string UnsLineId { get; set; } = "";
|
||||||
public string? DriverInstanceId { get; set; }
|
|
||||||
public string? ZTag { get; set; }
|
public string? ZTag { get; set; }
|
||||||
public string? SAPID { get; set; }
|
public string? SAPID { get; set; }
|
||||||
public string? Manufacturer { get; set; }
|
public string? Manufacturer { get; set; }
|
||||||
|
|||||||
@@ -11,7 +11,6 @@
|
|||||||
<ul class="nav nav-tabs mb-3">
|
<ul class="nav nav-tabs mb-3">
|
||||||
<li class="nav-item"><a class="nav-link @Active("overview")" href="/clusters/@ClusterId">Overview</a></li>
|
<li class="nav-item"><a class="nav-link @Active("overview")" href="/clusters/@ClusterId">Overview</a></li>
|
||||||
<li class="nav-item"><a class="nav-link @Active("namespaces")" href="/clusters/@ClusterId/namespaces">Namespaces</a></li>
|
<li class="nav-item"><a class="nav-link @Active("namespaces")" href="/clusters/@ClusterId/namespaces">Namespaces</a></li>
|
||||||
<li class="nav-item"><a class="nav-link @Active("drivers")" href="/clusters/@ClusterId/drivers">Drivers</a></li>
|
|
||||||
<li class="nav-item"><a class="nav-link @Active("acls")" href="/clusters/@ClusterId/acls">ACLs</a></li>
|
<li class="nav-item"><a class="nav-link @Active("acls")" href="/clusters/@ClusterId/acls">ACLs</a></li>
|
||||||
<li class="nav-item"><a class="nav-link @Active("audit")" href="/clusters/@ClusterId/audit">Audit</a></li>
|
<li class="nav-item"><a class="nav-link @Active("audit")" href="/clusters/@ClusterId/audit">Audit</a></li>
|
||||||
<li class="nav-item"><a class="nav-link @Active("redundancy")" href="/clusters/@ClusterId/redundancy">Redundancy</a></li>
|
<li class="nav-item"><a class="nav-link @Active("redundancy")" href="/clusters/@ClusterId/redundancy">Redundancy</a></li>
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
@* Reusable right-click / context menu.
|
||||||
|
|
||||||
|
Consumers get two (independently optional) triggers over one item list:
|
||||||
|
• ChildContent — wrapped as a right-clickable surface (browser menu suppressed).
|
||||||
|
• ShowEllipsis — a "⋯" button, the keyboard/touch fallback for the right-click.
|
||||||
|
|
||||||
|
Mouse/touch open the menu anchored at the pointer (fixed positioning). Keyboard
|
||||||
|
activation of the "⋯" button carries no pointer coordinates, so the menu instead
|
||||||
|
anchors just below the button (absolute positioning inside the trigger wrap).
|
||||||
|
Outside-click (or a right-click elsewhere) closes via a transparent full-viewport
|
||||||
|
backdrop — no JS alert/confirm. Keyboard: Arrow up/down move selection, Enter/Space
|
||||||
|
activate, Esc closes; focus returns to the "⋯" trigger on close. *@
|
||||||
|
|
||||||
|
@if (ChildContent is not null)
|
||||||
|
{
|
||||||
|
<span class="ctx-surface" @oncontextmenu="OpenAt" @oncontextmenu:preventDefault="true">
|
||||||
|
@ChildContent
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (_open)
|
||||||
|
{
|
||||||
|
@* Transparent catch-all: any click — or a right-click — that misses the menu closes it. *@
|
||||||
|
<div class="ctx-backdrop" @onclick="Close" @oncontextmenu="Close" @oncontextmenu:preventDefault="true"></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (ShowEllipsis)
|
||||||
|
{
|
||||||
|
<span class="ctx-ellipsis-wrap">
|
||||||
|
<button type="button" class="ctx-ellipsis" title="More actions"
|
||||||
|
aria-haspopup="true" aria-expanded="@_open" @ref="_ellipsisRef" @onclick="OpenAt">
|
||||||
|
⋯@* ⋯ MIDLINE HORIZONTAL ELLIPSIS *@
|
||||||
|
</button>
|
||||||
|
@if (_open && _anchorToEllipsis)
|
||||||
|
{
|
||||||
|
@RenderMenu("ctx-menu ctx-menu--anchored", null)
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (_open && !_anchorToEllipsis)
|
||||||
|
{
|
||||||
|
@RenderMenu("ctx-menu", $"top:{_y.ToString(Culture)}px; left:{_x.ToString(Culture)}px")
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private static readonly System.Globalization.CultureInfo Culture =
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture;
|
||||||
|
|
||||||
|
/// <summary>The rows to show. Separators are permitted.</summary>
|
||||||
|
[Parameter] public IReadOnlyList<ContextMenuItem> Items { get; set; } = Array.Empty<ContextMenuItem>();
|
||||||
|
|
||||||
|
/// <summary>Wrapped as the right-clickable surface. Optional.</summary>
|
||||||
|
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Render the "⋯" trigger button (keyboard/touch fallback).</summary>
|
||||||
|
[Parameter] public bool ShowEllipsis { get; set; } = true;
|
||||||
|
|
||||||
|
private bool _open;
|
||||||
|
private double _x;
|
||||||
|
private double _y;
|
||||||
|
private bool _anchorToEllipsis;
|
||||||
|
private int _activeIndex = -1;
|
||||||
|
private ElementReference _menuRef;
|
||||||
|
private ElementReference _ellipsisRef;
|
||||||
|
private bool _focusMenuPending;
|
||||||
|
private bool _focusEllipsisPending;
|
||||||
|
|
||||||
|
// Mouse right-click / touch-tap carry real pointer coordinates → anchor the menu there
|
||||||
|
// (fixed). Keyboard activation of the "⋯" button dispatches a click with clientX/Y == 0
|
||||||
|
// (no pointer) → anchor the menu below the button instead of at viewport origin (0,0).
|
||||||
|
private void OpenAt(MouseEventArgs e)
|
||||||
|
{
|
||||||
|
_anchorToEllipsis = e.ClientX == 0 && e.ClientY == 0;
|
||||||
|
_x = e.ClientX;
|
||||||
|
_y = e.ClientY;
|
||||||
|
_activeIndex = FirstEnabledIndex();
|
||||||
|
_open = true;
|
||||||
|
_focusMenuPending = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Close()
|
||||||
|
{
|
||||||
|
if (!_open) return;
|
||||||
|
var returnFocus = _anchorToEllipsis; // keyboard-opened via the ⋯ button — restore its focus
|
||||||
|
_open = false;
|
||||||
|
_activeIndex = -1;
|
||||||
|
_anchorToEllipsis = false;
|
||||||
|
if (returnFocus && ShowEllipsis) _focusEllipsisPending = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task Activate(ContextMenuItem item)
|
||||||
|
{
|
||||||
|
if (item.Disabled || item.IsSeparator) return;
|
||||||
|
Close();
|
||||||
|
if (item.OnClick is not null) await item.OnClick.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnKeyDown(KeyboardEventArgs e)
|
||||||
|
{
|
||||||
|
switch (e.Key)
|
||||||
|
{
|
||||||
|
case "Escape":
|
||||||
|
Close();
|
||||||
|
break;
|
||||||
|
case "ArrowDown":
|
||||||
|
Move(1);
|
||||||
|
break;
|
||||||
|
case "ArrowUp":
|
||||||
|
Move(-1);
|
||||||
|
break;
|
||||||
|
case "Enter":
|
||||||
|
case " ":
|
||||||
|
if (_activeIndex >= 0 && _activeIndex < Items.Count)
|
||||||
|
{
|
||||||
|
await Activate(Items[_activeIndex]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance the selection in the given direction, skipping separators and
|
||||||
|
// disabled rows, wrapping around the ends.
|
||||||
|
private void Move(int dir)
|
||||||
|
{
|
||||||
|
if (Items.Count == 0) return;
|
||||||
|
var i = _activeIndex;
|
||||||
|
for (var step = 0; step < Items.Count; step++)
|
||||||
|
{
|
||||||
|
i = (i + dir + Items.Count) % Items.Count;
|
||||||
|
var item = Items[i];
|
||||||
|
if (!item.IsSeparator && !item.Disabled)
|
||||||
|
{
|
||||||
|
_activeIndex = i;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private int FirstEnabledIndex()
|
||||||
|
{
|
||||||
|
for (var i = 0; i < Items.Count; i++)
|
||||||
|
{
|
||||||
|
if (!Items[i].IsSeparator && !Items[i].Disabled) return i;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The menu body, shared between the pointer-anchored (fixed) and ellipsis-anchored
|
||||||
|
// (absolute) placements so the item loop lives in exactly one place.
|
||||||
|
private RenderFragment RenderMenu(string cssClass, string? style) => __builder =>
|
||||||
|
{
|
||||||
|
<div class="@cssClass" style="@style" role="menu" tabindex="0" @ref="_menuRef" @onkeydown="OnKeyDown">
|
||||||
|
@for (var i = 0; i < Items.Count; i++)
|
||||||
|
{
|
||||||
|
var item = Items[i];
|
||||||
|
if (item.IsSeparator)
|
||||||
|
{
|
||||||
|
<div class="ctx-separator" role="separator"></div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var index = i;
|
||||||
|
<button type="button" role="menuitem"
|
||||||
|
class="ctx-item @(index == _activeIndex ? "active" : "")"
|
||||||
|
disabled="@item.Disabled"
|
||||||
|
@onclick="() => Activate(item)">
|
||||||
|
@if (!string.IsNullOrEmpty(item.Icon))
|
||||||
|
{
|
||||||
|
<span class="ctx-icon">@item.Icon</span>
|
||||||
|
}
|
||||||
|
<span class="ctx-label">@item.Label</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
};
|
||||||
|
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (_focusMenuPending)
|
||||||
|
{
|
||||||
|
_focusMenuPending = false;
|
||||||
|
try { await _menuRef.FocusAsync(); }
|
||||||
|
catch { /* the menu may have closed before the focus round-trip completed */ }
|
||||||
|
}
|
||||||
|
if (_focusEllipsisPending)
|
||||||
|
{
|
||||||
|
_focusEllipsisPending = false;
|
||||||
|
try { await _ellipsisRef.FocusAsync(); }
|
||||||
|
catch { /* the trigger may have unmounted */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
/* Scoped styles for ContextMenu. Colours come from the shared ZB.MOM.WW.Theme
|
||||||
|
tokens (theme.css) so the menu tracks the app palette in light and dark. */
|
||||||
|
|
||||||
|
.ctx-surface {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Positioned wrap so a keyboard-opened menu can anchor below the "⋯" button. */
|
||||||
|
.ctx-ellipsis-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The "⋯" fallback trigger — a compact, quiet icon button. */
|
||||||
|
.ctx-ellipsis {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.75rem;
|
||||||
|
height: 1.75rem;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctx-ellipsis:hover,
|
||||||
|
.ctx-ellipsis[aria-expanded="true"] {
|
||||||
|
background: var(--hover-bg);
|
||||||
|
border-color: var(--bs-border-color);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Transparent full-viewport catch-all; sits just under the menu. */
|
||||||
|
.ctx-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1080;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctx-menu {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1081; /* above the backdrop, below toasts (1090) */
|
||||||
|
min-width: 11rem;
|
||||||
|
max-width: 20rem;
|
||||||
|
padding: 0.25rem;
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--bs-border-color);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.16);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keyboard-opened placement: anchored just below the "⋯" trigger instead of the pointer. */
|
||||||
|
.ctx-menu--anchored {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctx-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.35rem 0.6rem;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctx-item:hover:not(:disabled),
|
||||||
|
.ctx-item.active:not(:disabled) {
|
||||||
|
background: var(--hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctx-item:disabled {
|
||||||
|
color: var(--ink-faint);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctx-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 1.1rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctx-label {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctx-separator {
|
||||||
|
height: 1px;
|
||||||
|
margin: 0.25rem 0.3rem;
|
||||||
|
background: var(--bs-border-color);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One entry in a <see cref="ContextMenu"/>. An item is either an actionable
|
||||||
|
/// row (<see cref="Label"/> + <see cref="OnClick"/>) or a visual divider
|
||||||
|
/// (<see cref="IsSeparator"/> = true, in which case the other members are
|
||||||
|
/// ignored). Use the <see cref="Separator"/> factory for dividers.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ContextMenuItem
|
||||||
|
{
|
||||||
|
/// <summary>Text shown for the row.</summary>
|
||||||
|
public string Label { get; init; } = "";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Invoked when the row is activated (click / Enter). Async so per-node menu
|
||||||
|
/// actions (delete, redeploy, rename) can await their service calls without an
|
||||||
|
/// exception-swallowing <c>async void</c>; the menu awaits this and re-renders.
|
||||||
|
/// A synchronous handler returns <see cref="Task.CompletedTask"/>.
|
||||||
|
/// </summary>
|
||||||
|
public Func<Task>? OnClick { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Optional leading icon — an emoji or single glyph.</summary>
|
||||||
|
public string? Icon { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Renders the row greyed-out and non-interactive.</summary>
|
||||||
|
public bool Disabled { get; init; }
|
||||||
|
|
||||||
|
/// <summary>When true this entry is a horizontal divider, not a row.</summary>
|
||||||
|
public bool IsSeparator { get; init; }
|
||||||
|
|
||||||
|
/// <summary>A horizontal divider between groups of items.</summary>
|
||||||
|
public static ContextMenuItem Separator() => new() { IsSeparator = true };
|
||||||
|
}
|
||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
@* Embeddable AB CIP device (connection endpoint) form body — authors HostAddress/PlcFamily into the
|
||||||
|
Device's DeviceConfig (PascalCase, matching AbCipDeviceOptions). Hosted by DeviceModal. *@
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Driver.AbCip
|
||||||
|
|
||||||
|
<section class="panel rise" style="animation-delay:.04s">
|
||||||
|
<div class="panel-head">Connection</div>
|
||||||
|
<div style="padding:1rem">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Host address</label>
|
||||||
|
<InputText @bind-Value="_model.HostAddress" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="ab://gateway/1,0" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">PLC family</label>
|
||||||
|
<InputSelect @bind-Value="_model.PlcFamily" @bind-Value:after="EmitAsync" class="form-select form-select-sm">
|
||||||
|
@foreach (var e in Enum.GetValues<AbCipPlcFamily>())
|
||||||
|
{
|
||||||
|
<option value="@e">@e</option>
|
||||||
|
}
|
||||||
|
</InputSelect>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <summary>The device's DeviceConfig JSON (endpoint/connection keys).</summary>
|
||||||
|
[Parameter] public string DeviceConfigJson { get; set; } = "{}";
|
||||||
|
/// <summary>Fired (PascalCase HostAddress/PlcFamily) whenever the endpoint changes — enables @bind-DeviceConfigJson.</summary>
|
||||||
|
[Parameter] public EventCallback<string> DeviceConfigJsonChanged { get; set; }
|
||||||
|
|
||||||
|
private AbCipDeviceModel _model = new();
|
||||||
|
private string? _lastParsed;
|
||||||
|
|
||||||
|
protected override void OnParametersSet()
|
||||||
|
{
|
||||||
|
if (!string.Equals(_lastParsed, DeviceConfigJson, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
_model = AbCipDeviceModel.FromJson(DeviceConfigJson);
|
||||||
|
_lastParsed = DeviceConfigJson;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Serializes the current endpoint to DeviceConfig JSON.</summary>
|
||||||
|
public string GetConfigJson() => _model.ToJson();
|
||||||
|
|
||||||
|
private async Task EmitAsync()
|
||||||
|
{
|
||||||
|
var json = GetConfigJson();
|
||||||
|
_lastParsed = json;
|
||||||
|
await DeviceConfigJsonChanged.InvokeAsync(json);
|
||||||
|
}
|
||||||
|
}
|
||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Typed working model for an AB CIP <c>Device</c>'s connection endpoint (the v3 endpoint→DeviceConfig
|
||||||
|
/// split). Authors the <b>PascalCase</b> <c>HostAddress</c>/<c>PlcFamily</c> keys that
|
||||||
|
/// <c>AbCipDeviceOptions</c> binds — the exact casing the seed and runtime probe
|
||||||
|
/// (<c>PropertyNameCaseInsensitive</c>) expect. Preserves unrecognised keys across a load→save.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AbCipDeviceModel
|
||||||
|
{
|
||||||
|
/// <summary>libplctag gateway/path host address (e.g. <c>ab://gateway/1,0</c>).</summary>
|
||||||
|
public string HostAddress { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>Allen-Bradley PLC family.</summary>
|
||||||
|
public AbCipPlcFamily PlcFamily { get; set; } = AbCipPlcFamily.ControlLogix;
|
||||||
|
|
||||||
|
private System.Text.Json.Nodes.JsonObject _bag = new();
|
||||||
|
|
||||||
|
/// <summary>Loads a model from a DeviceConfig JSON string, defaulting absent keys and retaining
|
||||||
|
/// every original key so fields this editor doesn't expose survive a load→save.</summary>
|
||||||
|
/// <param name="json">The raw DeviceConfig JSON string, or null for a new/empty device.</param>
|
||||||
|
/// <returns>The populated <see cref="AbCipDeviceModel"/>.</returns>
|
||||||
|
public static AbCipDeviceModel FromJson(string? json)
|
||||||
|
{
|
||||||
|
var o = TagConfigJson.ParseOrNew(json);
|
||||||
|
return new AbCipDeviceModel
|
||||||
|
{
|
||||||
|
HostAddress = TagConfigJson.GetString(o, "HostAddress") ?? "",
|
||||||
|
PlcFamily = TagConfigJson.GetEnum(o, "PlcFamily", AbCipPlcFamily.ControlLogix),
|
||||||
|
_bag = o,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Serializes this model back to a DeviceConfig JSON string, writing the PascalCase endpoint
|
||||||
|
/// keys over the preserved key bag.</summary>
|
||||||
|
/// <returns>The serialised DeviceConfig JSON string.</returns>
|
||||||
|
public string ToJson()
|
||||||
|
{
|
||||||
|
TagConfigJson.Set(_bag, "HostAddress", HostAddress);
|
||||||
|
TagConfigJson.Set(_bag, "PlcFamily", PlcFamily);
|
||||||
|
return TagConfigJson.Serialize(_bag);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
|
||||||
|
/// <returns>An error message describing the validation failure, or <c>null</c> when the model is valid.</returns>
|
||||||
|
public string? Validate()
|
||||||
|
=> string.IsNullOrWhiteSpace(HostAddress) ? "Host address is required." : null;
|
||||||
|
}
|
||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
@* Embeddable AB Legacy device (connection endpoint) form body — authors HostAddress/PlcFamily into the
|
||||||
|
Device's DeviceConfig (PascalCase, matching AbLegacyDeviceOptions). Hosted by DeviceModal. *@
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies
|
||||||
|
|
||||||
|
<section class="panel rise" style="animation-delay:.04s">
|
||||||
|
<div class="panel-head">Connection</div>
|
||||||
|
<div style="padding:1rem">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Host address</label>
|
||||||
|
<InputText @bind-Value="_model.HostAddress" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="10.0.0.10" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">PLC family</label>
|
||||||
|
<InputSelect @bind-Value="_model.PlcFamily" @bind-Value:after="EmitAsync" class="form-select form-select-sm">
|
||||||
|
@foreach (var e in Enum.GetValues<AbLegacyPlcFamily>())
|
||||||
|
{
|
||||||
|
<option value="@e">@e</option>
|
||||||
|
}
|
||||||
|
</InputSelect>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <summary>The device's DeviceConfig JSON (endpoint/connection keys).</summary>
|
||||||
|
[Parameter] public string DeviceConfigJson { get; set; } = "{}";
|
||||||
|
/// <summary>Fired (PascalCase HostAddress/PlcFamily) whenever the endpoint changes — enables @bind-DeviceConfigJson.</summary>
|
||||||
|
[Parameter] public EventCallback<string> DeviceConfigJsonChanged { get; set; }
|
||||||
|
|
||||||
|
private AbLegacyDeviceModel _model = new();
|
||||||
|
private string? _lastParsed;
|
||||||
|
|
||||||
|
protected override void OnParametersSet()
|
||||||
|
{
|
||||||
|
if (!string.Equals(_lastParsed, DeviceConfigJson, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
_model = AbLegacyDeviceModel.FromJson(DeviceConfigJson);
|
||||||
|
_lastParsed = DeviceConfigJson;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Serializes the current endpoint to DeviceConfig JSON.</summary>
|
||||||
|
public string GetConfigJson() => _model.ToJson();
|
||||||
|
|
||||||
|
private async Task EmitAsync()
|
||||||
|
{
|
||||||
|
var json = GetConfigJson();
|
||||||
|
_lastParsed = json;
|
||||||
|
await DeviceConfigJsonChanged.InvokeAsync(json);
|
||||||
|
}
|
||||||
|
}
|
||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Typed working model for an AB Legacy <c>Device</c>'s connection endpoint (the v3 endpoint→DeviceConfig
|
||||||
|
/// split). Authors the <b>PascalCase</b> <c>HostAddress</c>/<c>PlcFamily</c> keys that
|
||||||
|
/// <c>AbLegacyDeviceOptions</c> binds — the exact casing the seed and runtime probe
|
||||||
|
/// (<c>PropertyNameCaseInsensitive</c>) expect. Preserves unrecognised keys across a load→save.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AbLegacyDeviceModel
|
||||||
|
{
|
||||||
|
/// <summary>PLC host / IP address.</summary>
|
||||||
|
public string HostAddress { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>Allen-Bradley legacy PLC family (SLC500 / MicroLogix / PLC-5).</summary>
|
||||||
|
public AbLegacyPlcFamily PlcFamily { get; set; } = AbLegacyPlcFamily.Slc500;
|
||||||
|
|
||||||
|
private System.Text.Json.Nodes.JsonObject _bag = new();
|
||||||
|
|
||||||
|
/// <summary>Loads a model from a DeviceConfig JSON string, defaulting absent keys and retaining
|
||||||
|
/// every original key so fields this editor doesn't expose survive a load→save.</summary>
|
||||||
|
/// <param name="json">The raw DeviceConfig JSON string, or null for a new/empty device.</param>
|
||||||
|
/// <returns>The populated <see cref="AbLegacyDeviceModel"/>.</returns>
|
||||||
|
public static AbLegacyDeviceModel FromJson(string? json)
|
||||||
|
{
|
||||||
|
var o = TagConfigJson.ParseOrNew(json);
|
||||||
|
return new AbLegacyDeviceModel
|
||||||
|
{
|
||||||
|
HostAddress = TagConfigJson.GetString(o, "HostAddress") ?? "",
|
||||||
|
PlcFamily = TagConfigJson.GetEnum(o, "PlcFamily", AbLegacyPlcFamily.Slc500),
|
||||||
|
_bag = o,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Serializes this model back to a DeviceConfig JSON string, writing the PascalCase endpoint
|
||||||
|
/// keys over the preserved key bag.</summary>
|
||||||
|
/// <returns>The serialised DeviceConfig JSON string.</returns>
|
||||||
|
public string ToJson()
|
||||||
|
{
|
||||||
|
TagConfigJson.Set(_bag, "HostAddress", HostAddress);
|
||||||
|
TagConfigJson.Set(_bag, "PlcFamily", PlcFamily);
|
||||||
|
return TagConfigJson.Serialize(_bag);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
|
||||||
|
/// <returns>An error message describing the validation failure, or <c>null</c> when the model is valid.</returns>
|
||||||
|
public string? Validate()
|
||||||
|
=> string.IsNullOrWhiteSpace(HostAddress) ? "Host address is required." : null;
|
||||||
|
}
|
||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
@* Embeddable FOCAS device (connection endpoint) form body — authors HostAddress/Series into the
|
||||||
|
Device's DeviceConfig (PascalCase, matching FocasDeviceOptions). Hosted by DeviceModal. *@
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Driver.FOCAS
|
||||||
|
|
||||||
|
<section class="panel rise" style="animation-delay:.04s">
|
||||||
|
<div class="panel-head">Connection</div>
|
||||||
|
<div style="padding:1rem">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Host address</label>
|
||||||
|
<InputText @bind-Value="_model.HostAddress" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="192.168.0.10:8193" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">CNC series</label>
|
||||||
|
<InputSelect @bind-Value="_model.Series" @bind-Value:after="EmitAsync" class="form-select form-select-sm">
|
||||||
|
@foreach (var e in Enum.GetValues<FocasCncSeries>())
|
||||||
|
{
|
||||||
|
<option value="@e">@e</option>
|
||||||
|
}
|
||||||
|
</InputSelect>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <summary>The device's DeviceConfig JSON (endpoint/connection keys).</summary>
|
||||||
|
[Parameter] public string DeviceConfigJson { get; set; } = "{}";
|
||||||
|
/// <summary>Fired (PascalCase HostAddress/Series) whenever the endpoint changes — enables @bind-DeviceConfigJson.</summary>
|
||||||
|
[Parameter] public EventCallback<string> DeviceConfigJsonChanged { get; set; }
|
||||||
|
|
||||||
|
private FocasDeviceModel _model = new();
|
||||||
|
private string? _lastParsed;
|
||||||
|
|
||||||
|
protected override void OnParametersSet()
|
||||||
|
{
|
||||||
|
if (!string.Equals(_lastParsed, DeviceConfigJson, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
_model = FocasDeviceModel.FromJson(DeviceConfigJson);
|
||||||
|
_lastParsed = DeviceConfigJson;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Serializes the current endpoint to DeviceConfig JSON.</summary>
|
||||||
|
public string GetConfigJson() => _model.ToJson();
|
||||||
|
|
||||||
|
private async Task EmitAsync()
|
||||||
|
{
|
||||||
|
var json = GetConfigJson();
|
||||||
|
_lastParsed = json;
|
||||||
|
await DeviceConfigJsonChanged.InvokeAsync(json);
|
||||||
|
}
|
||||||
|
}
|
||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Typed working model for a FOCAS <c>Device</c>'s connection endpoint (the v3 endpoint→DeviceConfig
|
||||||
|
/// split). Authors the <b>PascalCase</b> <c>HostAddress</c>/<c>Series</c> keys that
|
||||||
|
/// <c>FocasDeviceOptions</c> binds — the exact casing the seed and runtime probe
|
||||||
|
/// (<c>PropertyNameCaseInsensitive</c>) expect. Preserves unrecognised keys across a load→save.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FocasDeviceModel
|
||||||
|
{
|
||||||
|
/// <summary>CNC host address (host or host:port, e.g. <c>192.168.0.10:8193</c>).</summary>
|
||||||
|
public string HostAddress { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>CNC series, enabling per-series address validation.</summary>
|
||||||
|
public FocasCncSeries Series { get; set; } = FocasCncSeries.Unknown;
|
||||||
|
|
||||||
|
private System.Text.Json.Nodes.JsonObject _bag = new();
|
||||||
|
|
||||||
|
/// <summary>Loads a model from a DeviceConfig JSON string, defaulting absent keys and retaining
|
||||||
|
/// every original key so fields this editor doesn't expose survive a load→save.</summary>
|
||||||
|
/// <param name="json">The raw DeviceConfig JSON string, or null for a new/empty device.</param>
|
||||||
|
/// <returns>The populated <see cref="FocasDeviceModel"/>.</returns>
|
||||||
|
public static FocasDeviceModel FromJson(string? json)
|
||||||
|
{
|
||||||
|
var o = TagConfigJson.ParseOrNew(json);
|
||||||
|
return new FocasDeviceModel
|
||||||
|
{
|
||||||
|
HostAddress = TagConfigJson.GetString(o, "HostAddress") ?? "",
|
||||||
|
Series = TagConfigJson.GetEnum(o, "Series", FocasCncSeries.Unknown),
|
||||||
|
_bag = o,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Serializes this model back to a DeviceConfig JSON string, writing the PascalCase endpoint
|
||||||
|
/// keys over the preserved key bag.</summary>
|
||||||
|
/// <returns>The serialised DeviceConfig JSON string.</returns>
|
||||||
|
public string ToJson()
|
||||||
|
{
|
||||||
|
TagConfigJson.Set(_bag, "HostAddress", HostAddress);
|
||||||
|
TagConfigJson.Set(_bag, "Series", Series.ToString());
|
||||||
|
return TagConfigJson.Serialize(_bag);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
|
||||||
|
/// <returns>An error message describing the validation failure, or <c>null</c> when the model is valid.</returns>
|
||||||
|
public string? Validate()
|
||||||
|
=> string.IsNullOrWhiteSpace(HostAddress) ? "Host address is required." : null;
|
||||||
|
}
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
@* Galaxy device form — informational only. Galaxy connects to a single mxaccessgw gateway configured on
|
||||||
|
the driver, so there is no per-device connection endpoint to author. The DeviceConfig is round-tripped
|
||||||
|
verbatim (Test-connect uses the driver's gateway settings via the merged config). *@
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms
|
||||||
|
|
||||||
|
<section class="panel notice rise" style="animation-delay:.04s">
|
||||||
|
<div class="panel-head">Connection</div>
|
||||||
|
<div style="padding:1rem" class="text-muted">
|
||||||
|
Galaxy connects to a single <strong>mxaccessgw</strong> gateway configured on the driver — there is
|
||||||
|
no per-device endpoint to author here. Edit the gateway endpoint / API key on the driver's config.
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <summary>The device's DeviceConfig JSON (round-tripped verbatim — Galaxy has no per-device endpoint).</summary>
|
||||||
|
[Parameter] public string DeviceConfigJson { get; set; } = "{}";
|
||||||
|
/// <summary>Fired when the (preserved) DeviceConfig changes — no-op UI, kept for the modal contract.</summary>
|
||||||
|
[Parameter] public EventCallback<string> DeviceConfigJsonChanged { get; set; }
|
||||||
|
|
||||||
|
private GalaxyDeviceModel _model = new();
|
||||||
|
private string? _lastParsed;
|
||||||
|
|
||||||
|
protected override void OnParametersSet()
|
||||||
|
{
|
||||||
|
if (!string.Equals(_lastParsed, DeviceConfigJson, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
_model = GalaxyDeviceModel.FromJson(DeviceConfigJson);
|
||||||
|
_lastParsed = DeviceConfigJson;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Serializes the (preserved) DeviceConfig JSON.</summary>
|
||||||
|
public string GetConfigJson() => _model.ToJson();
|
||||||
|
}
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Device model for the AVEVA Galaxy driver. Galaxy connects to a SINGLE mxaccessgw gateway configured
|
||||||
|
/// on the <b>driver</b> (nested Gateway/MxAccess records) — there is no flat per-device connection
|
||||||
|
/// endpoint to author, so this model authors no endpoint keys. It simply round-trips the DeviceConfig
|
||||||
|
/// JSON verbatim (preserving any keys) so the device row stays valid. (Flagged: Galaxy has no v3
|
||||||
|
/// endpoint→DeviceConfig split; its connection lives on the driver config.)
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GalaxyDeviceModel
|
||||||
|
{
|
||||||
|
private System.Text.Json.Nodes.JsonObject _bag = new();
|
||||||
|
|
||||||
|
/// <summary>Loads a model from a DeviceConfig JSON string, retaining every original key.</summary>
|
||||||
|
/// <param name="json">The raw DeviceConfig JSON string, or null for a new/empty device.</param>
|
||||||
|
/// <returns>The populated <see cref="GalaxyDeviceModel"/>.</returns>
|
||||||
|
public static GalaxyDeviceModel FromJson(string? json)
|
||||||
|
=> new() { _bag = TagConfigJson.ParseOrNew(json) };
|
||||||
|
|
||||||
|
/// <summary>Serializes the (preserved) DeviceConfig back to a JSON string.</summary>
|
||||||
|
/// <returns>The serialised DeviceConfig JSON string.</returns>
|
||||||
|
public string ToJson() => TagConfigJson.Serialize(_bag);
|
||||||
|
|
||||||
|
/// <summary>Validation hook; Galaxy device rows carry no endpoint, so always valid.</summary>
|
||||||
|
/// <returns><c>null</c> — no per-device endpoint to validate.</returns>
|
||||||
|
public string? Validate() => null;
|
||||||
|
}
|
||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
@* Embeddable Modbus device (connection endpoint) form body — authors Host/Port/UnitId into the
|
||||||
|
Device's DeviceConfig (PascalCase, matching ModbusDriverOptions). Hosted by DeviceModal. *@
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms
|
||||||
|
|
||||||
|
<section class="panel rise" style="animation-delay:.04s">
|
||||||
|
<div class="panel-head">Connection</div>
|
||||||
|
<div style="padding:1rem">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Host</label>
|
||||||
|
<InputText @bind-Value="_model.Host" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="127.0.0.1" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Port</label>
|
||||||
|
<InputNumber @bind-Value="_model.Port" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Unit ID (slave ID)</label>
|
||||||
|
<InputNumber @bind-Value="_model.UnitId" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <summary>The device's DeviceConfig JSON (endpoint/connection keys).</summary>
|
||||||
|
[Parameter] public string DeviceConfigJson { get; set; } = "{}";
|
||||||
|
/// <summary>Fired (PascalCase Host/Port/UnitId) whenever the endpoint changes — enables @bind-DeviceConfigJson.</summary>
|
||||||
|
[Parameter] public EventCallback<string> DeviceConfigJsonChanged { get; set; }
|
||||||
|
|
||||||
|
private ModbusDeviceModel _model = new();
|
||||||
|
private string? _lastParsed;
|
||||||
|
|
||||||
|
protected override void OnParametersSet()
|
||||||
|
{
|
||||||
|
if (!string.Equals(_lastParsed, DeviceConfigJson, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
_model = ModbusDeviceModel.FromJson(DeviceConfigJson);
|
||||||
|
_lastParsed = DeviceConfigJson;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Serializes the current endpoint to DeviceConfig JSON.</summary>
|
||||||
|
public string GetConfigJson() => _model.ToJson();
|
||||||
|
|
||||||
|
private async Task EmitAsync()
|
||||||
|
{
|
||||||
|
var json = GetConfigJson();
|
||||||
|
_lastParsed = json;
|
||||||
|
await DeviceConfigJsonChanged.InvokeAsync(json);
|
||||||
|
}
|
||||||
|
}
|
||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Typed working model for a Modbus <c>Device</c>'s connection endpoint (the v3 endpoint→DeviceConfig
|
||||||
|
/// split). Authors the <b>PascalCase</b> <c>Host</c>/<c>Port</c>/<c>UnitId</c> keys that
|
||||||
|
/// <c>ModbusDriverOptions</c> binds — the exact casing the seed and runtime probe
|
||||||
|
/// (<c>PropertyNameCaseInsensitive</c>) expect. Preserves unrecognised keys across a load→save.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ModbusDeviceModel
|
||||||
|
{
|
||||||
|
/// <summary>PLC host / IP address.</summary>
|
||||||
|
public string Host { get; set; } = "127.0.0.1";
|
||||||
|
|
||||||
|
/// <summary>Modbus/TCP port (usually 502).</summary>
|
||||||
|
public int Port { get; set; } = 502;
|
||||||
|
|
||||||
|
/// <summary>Modbus unit / slave id (0-255).</summary>
|
||||||
|
public int UnitId { get; set; } = 1;
|
||||||
|
|
||||||
|
private System.Text.Json.Nodes.JsonObject _bag = new();
|
||||||
|
|
||||||
|
/// <summary>Loads a model from a DeviceConfig JSON string, defaulting absent keys and retaining
|
||||||
|
/// every original key so fields this editor doesn't expose survive a load→save.</summary>
|
||||||
|
/// <param name="json">The raw DeviceConfig JSON string, or null for a new/empty device.</param>
|
||||||
|
/// <returns>The populated <see cref="ModbusDeviceModel"/>.</returns>
|
||||||
|
public static ModbusDeviceModel FromJson(string? json)
|
||||||
|
{
|
||||||
|
var o = TagConfigJson.ParseOrNew(json);
|
||||||
|
return new ModbusDeviceModel
|
||||||
|
{
|
||||||
|
Host = TagConfigJson.GetString(o, "Host") ?? "127.0.0.1",
|
||||||
|
Port = TagConfigJson.GetInt(o, "Port", 502),
|
||||||
|
UnitId = TagConfigJson.GetInt(o, "UnitId", 1),
|
||||||
|
_bag = o,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Serializes this model back to a DeviceConfig JSON string, writing the PascalCase endpoint
|
||||||
|
/// keys over the preserved key bag.</summary>
|
||||||
|
/// <returns>The serialised DeviceConfig JSON string.</returns>
|
||||||
|
public string ToJson()
|
||||||
|
{
|
||||||
|
TagConfigJson.Set(_bag, "Host", Host);
|
||||||
|
TagConfigJson.Set(_bag, "Port", Port);
|
||||||
|
TagConfigJson.Set(_bag, "UnitId", UnitId);
|
||||||
|
return TagConfigJson.Serialize(_bag);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
|
||||||
|
/// <returns>An error message describing the validation failure, or <c>null</c> when the model is valid.</returns>
|
||||||
|
public string? Validate()
|
||||||
|
=> string.IsNullOrWhiteSpace(Host) ? "Host is required." : null;
|
||||||
|
}
|
||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
@* Embeddable OPC UA Client device (connection endpoint) form body — authors EndpointUrl into the
|
||||||
|
Device's DeviceConfig. Hosted by DeviceModal. *@
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms
|
||||||
|
|
||||||
|
<section class="panel rise" style="animation-delay:.04s">
|
||||||
|
<div class="panel-head">Endpoint</div>
|
||||||
|
<div style="padding:1rem">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label">Endpoint URL</label>
|
||||||
|
<InputText @bind-Value="_model.EndpointUrl" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="opc.tcp://plc.internal:4840" />
|
||||||
|
<div class="form-text">The upstream server. Used only when the driver's EndpointUrls failover list is empty.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <summary>The device's DeviceConfig JSON (endpoint/connection keys).</summary>
|
||||||
|
[Parameter] public string DeviceConfigJson { get; set; } = "{}";
|
||||||
|
/// <summary>Fired (PascalCase EndpointUrl) whenever the endpoint changes — enables @bind-DeviceConfigJson.</summary>
|
||||||
|
[Parameter] public EventCallback<string> DeviceConfigJsonChanged { get; set; }
|
||||||
|
|
||||||
|
private OpcUaClientDeviceModel _model = new();
|
||||||
|
private string? _lastParsed;
|
||||||
|
|
||||||
|
protected override void OnParametersSet()
|
||||||
|
{
|
||||||
|
if (!string.Equals(_lastParsed, DeviceConfigJson, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
_model = OpcUaClientDeviceModel.FromJson(DeviceConfigJson);
|
||||||
|
_lastParsed = DeviceConfigJson;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Serializes the current endpoint to DeviceConfig JSON.</summary>
|
||||||
|
public string GetConfigJson() => _model.ToJson();
|
||||||
|
|
||||||
|
private async Task EmitAsync()
|
||||||
|
{
|
||||||
|
var json = GetConfigJson();
|
||||||
|
_lastParsed = json;
|
||||||
|
await DeviceConfigJsonChanged.InvokeAsync(json);
|
||||||
|
}
|
||||||
|
}
|
||||||
+50
@@ -0,0 +1,50 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Typed working model for an OPC UA Client <c>Device</c>'s connection endpoint. Authors the PascalCase
|
||||||
|
/// <c>EndpointUrl</c> key that <c>OpcUaClientDriverOptions</c> binds. (The driver-level <c>EndpointUrls</c>
|
||||||
|
/// failover list stays on the driver config; a device's <c>EndpointUrl</c> is used only when that list
|
||||||
|
/// is empty.) Preserves unrecognised keys across a load→save.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class OpcUaClientDeviceModel
|
||||||
|
{
|
||||||
|
/// <summary>The single upstream server endpoint URL (opc.tcp://…).</summary>
|
||||||
|
public string EndpointUrl { get; set; } = "opc.tcp://localhost:4840";
|
||||||
|
|
||||||
|
private System.Text.Json.Nodes.JsonObject _bag = new();
|
||||||
|
|
||||||
|
/// <summary>Loads a model from a DeviceConfig JSON string, defaulting absent keys and retaining
|
||||||
|
/// every original key so fields this editor doesn't expose survive a load→save.</summary>
|
||||||
|
/// <param name="json">The raw DeviceConfig JSON string, or null for a new/empty device.</param>
|
||||||
|
/// <returns>The populated <see cref="OpcUaClientDeviceModel"/>.</returns>
|
||||||
|
public static OpcUaClientDeviceModel FromJson(string? json)
|
||||||
|
{
|
||||||
|
var o = TagConfigJson.ParseOrNew(json);
|
||||||
|
return new OpcUaClientDeviceModel
|
||||||
|
{
|
||||||
|
EndpointUrl = TagConfigJson.GetString(o, "EndpointUrl") ?? "opc.tcp://localhost:4840",
|
||||||
|
_bag = o,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Serializes this model back to a DeviceConfig JSON string, writing the PascalCase
|
||||||
|
/// <c>EndpointUrl</c> key over the preserved key bag.</summary>
|
||||||
|
/// <returns>The serialised DeviceConfig JSON string.</returns>
|
||||||
|
public string ToJson()
|
||||||
|
{
|
||||||
|
TagConfigJson.Set(_bag, "EndpointUrl", EndpointUrl);
|
||||||
|
return TagConfigJson.Serialize(_bag);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
|
||||||
|
/// <returns>An error message describing the validation failure, or <c>null</c> when the model is valid.</returns>
|
||||||
|
public string? Validate()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(EndpointUrl)) return "Endpoint URL is required.";
|
||||||
|
if (!EndpointUrl.Trim().StartsWith("opc.tcp://", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return "Endpoint URL must start with opc.tcp://";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
+56
@@ -0,0 +1,56 @@
|
|||||||
|
@* Embeddable S7 device (connection endpoint) form body — authors Host/Port/Rack/Slot into the
|
||||||
|
Device's DeviceConfig (PascalCase, matching S7DriverOptions). Hosted by DeviceModal. *@
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms
|
||||||
|
|
||||||
|
<section class="panel rise" style="animation-delay:.04s">
|
||||||
|
<div class="panel-head">Connection</div>
|
||||||
|
<div style="padding:1rem">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Host</label>
|
||||||
|
<InputText @bind-Value="_model.Host" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="192.168.0.1" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label">Port</label>
|
||||||
|
<InputNumber @bind-Value="_model.Port" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label">Rack</label>
|
||||||
|
<InputNumber @bind-Value="_model.Rack" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label">Slot</label>
|
||||||
|
<InputNumber @bind-Value="_model.Slot" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <summary>The device's DeviceConfig JSON (endpoint/connection keys).</summary>
|
||||||
|
[Parameter] public string DeviceConfigJson { get; set; } = "{}";
|
||||||
|
/// <summary>Fired (PascalCase Host/Port/Rack/Slot) whenever the endpoint changes — enables @bind-DeviceConfigJson.</summary>
|
||||||
|
[Parameter] public EventCallback<string> DeviceConfigJsonChanged { get; set; }
|
||||||
|
|
||||||
|
private S7DeviceModel _model = new();
|
||||||
|
private string? _lastParsed;
|
||||||
|
|
||||||
|
protected override void OnParametersSet()
|
||||||
|
{
|
||||||
|
if (!string.Equals(_lastParsed, DeviceConfigJson, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
_model = S7DeviceModel.FromJson(DeviceConfigJson);
|
||||||
|
_lastParsed = DeviceConfigJson;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Serializes the current endpoint to DeviceConfig JSON.</summary>
|
||||||
|
public string GetConfigJson() => _model.ToJson();
|
||||||
|
|
||||||
|
private async Task EmitAsync()
|
||||||
|
{
|
||||||
|
var json = GetConfigJson();
|
||||||
|
_lastParsed = json;
|
||||||
|
await DeviceConfigJsonChanged.InvokeAsync(json);
|
||||||
|
}
|
||||||
|
}
|
||||||
+60
@@ -0,0 +1,60 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Typed working model for an S7 <c>Device</c>'s connection endpoint (the v3 endpoint→DeviceConfig
|
||||||
|
/// split). Authors the <b>PascalCase</b> <c>Host</c>/<c>Port</c>/<c>Rack</c>/<c>Slot</c> keys that
|
||||||
|
/// <c>S7DriverOptions</c> binds — the exact casing the seed and runtime probe
|
||||||
|
/// (<c>PropertyNameCaseInsensitive</c>) expect. Preserves unrecognised keys across a load→save.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class S7DeviceModel
|
||||||
|
{
|
||||||
|
/// <summary>PLC host / IP address.</summary>
|
||||||
|
public string Host { get; set; } = "127.0.0.1";
|
||||||
|
|
||||||
|
/// <summary>ISO-on-TCP port (usually 102).</summary>
|
||||||
|
public int Port { get; set; } = 102;
|
||||||
|
|
||||||
|
/// <summary>PLC rack number (almost always 0).</summary>
|
||||||
|
public int Rack { get; set; } = 0;
|
||||||
|
|
||||||
|
/// <summary>PLC slot number (S7-300/400 = 2; S7-1200/1500 = 0).</summary>
|
||||||
|
public int Slot { get; set; } = 0;
|
||||||
|
|
||||||
|
private System.Text.Json.Nodes.JsonObject _bag = new();
|
||||||
|
|
||||||
|
/// <summary>Loads a model from a DeviceConfig JSON string, defaulting absent keys and retaining
|
||||||
|
/// every original key so fields this editor doesn't expose survive a load→save.</summary>
|
||||||
|
/// <param name="json">The raw DeviceConfig JSON string, or null for a new/empty device.</param>
|
||||||
|
/// <returns>The populated <see cref="S7DeviceModel"/>.</returns>
|
||||||
|
public static S7DeviceModel FromJson(string? json)
|
||||||
|
{
|
||||||
|
var o = TagConfigJson.ParseOrNew(json);
|
||||||
|
return new S7DeviceModel
|
||||||
|
{
|
||||||
|
Host = TagConfigJson.GetString(o, "Host") ?? "127.0.0.1",
|
||||||
|
Port = TagConfigJson.GetInt(o, "Port", 102),
|
||||||
|
Rack = TagConfigJson.GetInt(o, "Rack", 0),
|
||||||
|
Slot = TagConfigJson.GetInt(o, "Slot", 0),
|
||||||
|
_bag = o,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Serializes this model back to a DeviceConfig JSON string, writing the PascalCase endpoint
|
||||||
|
/// keys over the preserved key bag.</summary>
|
||||||
|
/// <returns>The serialised DeviceConfig JSON string.</returns>
|
||||||
|
public string ToJson()
|
||||||
|
{
|
||||||
|
TagConfigJson.Set(_bag, "Host", Host);
|
||||||
|
TagConfigJson.Set(_bag, "Port", Port);
|
||||||
|
TagConfigJson.Set(_bag, "Rack", Rack);
|
||||||
|
TagConfigJson.Set(_bag, "Slot", Slot);
|
||||||
|
return TagConfigJson.Serialize(_bag);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
|
||||||
|
/// <returns>An error message describing the validation failure, or <c>null</c> when the model is valid.</returns>
|
||||||
|
public string? Validate()
|
||||||
|
=> string.IsNullOrWhiteSpace(Host) ? "Host is required." : null;
|
||||||
|
}
|
||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
@* Embeddable TwinCAT device (connection endpoint) form body — authors HostAddress (AMS Net Id:port) into
|
||||||
|
the Device's DeviceConfig (PascalCase, matching TwinCATDeviceOptions). Hosted by DeviceModal. *@
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms
|
||||||
|
|
||||||
|
<section class="panel rise" style="animation-delay:.04s">
|
||||||
|
<div class="panel-head">Connection</div>
|
||||||
|
<div style="padding:1rem">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label">Host address (AMS Net Id:port)</label>
|
||||||
|
<InputText @bind-Value="_model.HostAddress" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="192.168.0.1.1.1:851" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <summary>The device's DeviceConfig JSON (endpoint/connection keys).</summary>
|
||||||
|
[Parameter] public string DeviceConfigJson { get; set; } = "{}";
|
||||||
|
/// <summary>Fired (PascalCase HostAddress) whenever the endpoint changes — enables @bind-DeviceConfigJson.</summary>
|
||||||
|
[Parameter] public EventCallback<string> DeviceConfigJsonChanged { get; set; }
|
||||||
|
|
||||||
|
private TwinCATDeviceModel _model = new();
|
||||||
|
private string? _lastParsed;
|
||||||
|
|
||||||
|
protected override void OnParametersSet()
|
||||||
|
{
|
||||||
|
if (!string.Equals(_lastParsed, DeviceConfigJson, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
_model = TwinCATDeviceModel.FromJson(DeviceConfigJson);
|
||||||
|
_lastParsed = DeviceConfigJson;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Serializes the current endpoint to DeviceConfig JSON.</summary>
|
||||||
|
public string GetConfigJson() => _model.ToJson();
|
||||||
|
|
||||||
|
private async Task EmitAsync()
|
||||||
|
{
|
||||||
|
var json = GetConfigJson();
|
||||||
|
_lastParsed = json;
|
||||||
|
await DeviceConfigJsonChanged.InvokeAsync(json);
|
||||||
|
}
|
||||||
|
}
|
||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Typed working model for a TwinCAT <c>Device</c>'s connection endpoint (the v3 endpoint→DeviceConfig
|
||||||
|
/// split). Authors the <b>PascalCase</b> <c>HostAddress</c> key (the AMS Net Id:port host string) that
|
||||||
|
/// <c>TwinCATDeviceOptions</c> binds — the exact casing the seed and runtime probe
|
||||||
|
/// (<c>PropertyNameCaseInsensitive</c>) expect. Preserves unrecognised keys across a load→save.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TwinCATDeviceModel
|
||||||
|
{
|
||||||
|
/// <summary>AMS Net Id:port host string (e.g. <c>192.168.0.1.1.1:851</c>).</summary>
|
||||||
|
public string HostAddress { get; set; } = "";
|
||||||
|
|
||||||
|
private System.Text.Json.Nodes.JsonObject _bag = new();
|
||||||
|
|
||||||
|
/// <summary>Loads a model from a DeviceConfig JSON string, defaulting absent keys and retaining
|
||||||
|
/// every original key so fields this editor doesn't expose survive a load→save.</summary>
|
||||||
|
/// <param name="json">The raw DeviceConfig JSON string, or null for a new/empty device.</param>
|
||||||
|
/// <returns>The populated <see cref="TwinCATDeviceModel"/>.</returns>
|
||||||
|
public static TwinCATDeviceModel FromJson(string? json)
|
||||||
|
{
|
||||||
|
var o = TagConfigJson.ParseOrNew(json);
|
||||||
|
return new TwinCATDeviceModel
|
||||||
|
{
|
||||||
|
HostAddress = TagConfigJson.GetString(o, "HostAddress") ?? "",
|
||||||
|
_bag = o,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Serializes this model back to a DeviceConfig JSON string, writing the PascalCase endpoint
|
||||||
|
/// key over the preserved key bag.</summary>
|
||||||
|
/// <returns>The serialised DeviceConfig JSON string.</returns>
|
||||||
|
public string ToJson()
|
||||||
|
{
|
||||||
|
TagConfigJson.Set(_bag, "HostAddress", HostAddress);
|
||||||
|
return TagConfigJson.Serialize(_bag);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
|
||||||
|
/// <returns>An error message describing the validation failure, or <c>null</c> when the model is valid.</returns>
|
||||||
|
public string? Validate()
|
||||||
|
=> string.IsNullOrWhiteSpace(HostAddress) ? "Host address is required." : null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
@* Create / edit modal for a raw-tree Device's connection endpoint (v3 endpoint→DeviceConfig split).
|
||||||
|
Dispatches to the right per-driver device form by DriverType. Test-connect builds the merged
|
||||||
|
Driver+Device config transiently (so unsaved edits probe correctly), via DriverDeviceConfigMerger —
|
||||||
|
the same merge LoadMergedProbeConfigAsync uses. The coordinator wires this into RawTree via @bind-Visible. *@
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Commons.Types
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Core.Abstractions
|
||||||
|
@inject IRawTreeService 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">@(_isNew ? "New device" : "Edit device") · <span class="mono">@_driverType</span></h5>
|
||||||
|
<button type="button" class="btn-close" aria-label="Close" @onclick="CloseAsync"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
@if (_loadError is not null)
|
||||||
|
{
|
||||||
|
<div class="panel notice mb-3" style="border-color:var(--alert)">@_loadError</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="row g-3 mb-2">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Device name</label>
|
||||||
|
<input class="form-control form-control-sm mono" @bind="_name" placeholder="Device1" />
|
||||||
|
<div class="form-text">A RawPath segment — letters, digits, dash, underscore.</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="form-check form-switch mt-4">
|
||||||
|
<input type="checkbox" class="form-check-input" id="devEnabled" @bind="_enabled" />
|
||||||
|
<label class="form-check-label" for="devEnabled">Enabled</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@switch (_driverType)
|
||||||
|
{
|
||||||
|
case DriverTypeNames.Modbus:
|
||||||
|
<ModbusDeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
|
||||||
|
break;
|
||||||
|
case DriverTypeNames.S7:
|
||||||
|
<S7DeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
|
||||||
|
break;
|
||||||
|
case DriverTypeNames.AbCip:
|
||||||
|
<AbCipDeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
|
||||||
|
break;
|
||||||
|
case DriverTypeNames.AbLegacy:
|
||||||
|
<AbLegacyDeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
|
||||||
|
break;
|
||||||
|
case DriverTypeNames.TwinCAT:
|
||||||
|
<TwinCATDeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
|
||||||
|
break;
|
||||||
|
case DriverTypeNames.FOCAS:
|
||||||
|
<FocasDeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
|
||||||
|
break;
|
||||||
|
case DriverTypeNames.OpcUaClient:
|
||||||
|
<OpcUaClientDeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
|
||||||
|
break;
|
||||||
|
case DriverTypeNames.Galaxy:
|
||||||
|
<GalaxyDeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
<div class="alert alert-warning">No typed device form for driver type <span class="mono">@_driverType</span>.</div>
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
<DriverTestConnectButton DriverType="@_driverType" GetConfigJson="@BuildMergedProbeConfig" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (_saveError is not null)
|
||||||
|
{
|
||||||
|
<div class="panel notice mt-3" style="border-color:var(--alert)">@_saveError</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" @onclick="CloseAsync" disabled="@_busy">Cancel</button>
|
||||||
|
@if (_loadError is null)
|
||||||
|
{
|
||||||
|
<button type="button" class="btn btn-primary" @onclick="SaveAsync" disabled="@_busy">
|
||||||
|
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||||
|
@(_isNew ? "Create device" : "Save changes")
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <summary>Whether the modal is shown (supports @bind-Visible).</summary>
|
||||||
|
[Parameter] public bool Visible { get; set; }
|
||||||
|
/// <summary>Two-way visibility callback.</summary>
|
||||||
|
[Parameter] public EventCallback<bool> VisibleChanged { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The device to edit; null ⇒ create a new device under <see cref="DriverInstanceId"/>.</summary>
|
||||||
|
[Parameter] public string? DeviceId { get; set; }
|
||||||
|
/// <summary>The owning driver instance (required for create; resolved from the device on edit).</summary>
|
||||||
|
[Parameter] public string? DriverInstanceId { get; set; }
|
||||||
|
/// <summary>The driver type (required for create; joined in from the device on edit).</summary>
|
||||||
|
[Parameter] public string? DriverType { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Raised after a successful create/update so the tree can refresh.</summary>
|
||||||
|
[Parameter] public EventCallback OnSaved { get; set; }
|
||||||
|
|
||||||
|
private bool _isNew;
|
||||||
|
private string _driverType = "";
|
||||||
|
private string? _resolvedDriverInstanceId;
|
||||||
|
private string _name = "";
|
||||||
|
private bool _enabled = true;
|
||||||
|
private string _deviceConfigJson = "{}";
|
||||||
|
private byte[] _rowVersion = [];
|
||||||
|
private string _parentDriverConfig = "{}";
|
||||||
|
|
||||||
|
private bool _busy;
|
||||||
|
private string? _loadError;
|
||||||
|
private string? _saveError;
|
||||||
|
private bool _openHandled;
|
||||||
|
|
||||||
|
protected override async Task OnParametersSetAsync()
|
||||||
|
{
|
||||||
|
if (Visible && !_openHandled)
|
||||||
|
{
|
||||||
|
_openHandled = true;
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
else if (!Visible)
|
||||||
|
{
|
||||||
|
_openHandled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadAsync()
|
||||||
|
{
|
||||||
|
_busy = false; _loadError = null; _saveError = null;
|
||||||
|
_isNew = string.IsNullOrEmpty(DeviceId);
|
||||||
|
|
||||||
|
if (_isNew)
|
||||||
|
{
|
||||||
|
_resolvedDriverInstanceId = DriverInstanceId;
|
||||||
|
_driverType = DriverType ?? "";
|
||||||
|
_name = "Device1";
|
||||||
|
_enabled = true;
|
||||||
|
_deviceConfigJson = "{}";
|
||||||
|
_rowVersion = [];
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var dto = await Svc.LoadDeviceForEditAsync(DeviceId!);
|
||||||
|
if (dto is null) { _loadError = $"Device '{DeviceId}' was not found."; return; }
|
||||||
|
_resolvedDriverInstanceId = dto.DriverInstanceId;
|
||||||
|
_driverType = dto.DriverType;
|
||||||
|
_name = dto.Name;
|
||||||
|
_enabled = dto.Enabled;
|
||||||
|
_deviceConfigJson = string.IsNullOrWhiteSpace(dto.DeviceConfig) ? "{}" : dto.DeviceConfig;
|
||||||
|
_rowVersion = dto.RowVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The parent driver's DriverConfig — folded with the (possibly unsaved) DeviceConfig for Test-connect.
|
||||||
|
if (!string.IsNullOrEmpty(_resolvedDriverInstanceId))
|
||||||
|
{
|
||||||
|
var drv = await Svc.LoadDriverForEditAsync(_resolvedDriverInstanceId);
|
||||||
|
_parentDriverConfig = drv?.DriverConfig ?? "{}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Builds the merged Driver+Device probe config from the CURRENT (possibly unsaved) form —
|
||||||
|
/// same merge as <c>LoadMergedProbeConfigAsync</c>, so Test-connect works before the device is saved.</summary>
|
||||||
|
private string BuildMergedProbeConfig()
|
||||||
|
=> DriverDeviceConfigMerger.Merge(
|
||||||
|
_parentDriverConfig,
|
||||||
|
new[] { new DriverDeviceConfigMerger.DeviceRow(_name, _deviceConfigJson) },
|
||||||
|
Array.Empty<RawTagEntry>());
|
||||||
|
|
||||||
|
private async Task SaveAsync()
|
||||||
|
{
|
||||||
|
_busy = true; _saveError = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
UnsMutationResult result;
|
||||||
|
if (_isNew)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(_resolvedDriverInstanceId))
|
||||||
|
{
|
||||||
|
_saveError = "No owning driver instance was supplied.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
result = await Svc.CreateDeviceAsync(_resolvedDriverInstanceId, _name, _deviceConfigJson);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
result = await Svc.UpdateDeviceAsync(DeviceId!, _name, _deviceConfigJson, _enabled, _rowVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.Ok)
|
||||||
|
{
|
||||||
|
_saveError = result.Error ?? "Save failed.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await OnSaved.InvokeAsync();
|
||||||
|
await SetVisibleAsync(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex) { _saveError = ex.Message; }
|
||||||
|
finally { _busy = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task CloseAsync() => SetVisibleAsync(false);
|
||||||
|
|
||||||
|
private async Task SetVisibleAsync(bool value)
|
||||||
|
{
|
||||||
|
Visible = value;
|
||||||
|
_openHandled = value;
|
||||||
|
await VisibleChanged.InvokeAsync(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
+47
-6
@@ -20,7 +20,7 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@foreach (var n in _roots) { @RenderNode(n, 0) }
|
@foreach (var n in _roots) { @RenderNode(n, 0, System.Array.Empty<string>()) }
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -34,6 +34,19 @@
|
|||||||
/// <summary>Fired when the user clicks a leaf (or any node — caller decides what to do with it).</summary>
|
/// <summary>Fired when the user clicks a leaf (or any node — caller decides what to do with it).</summary>
|
||||||
[Parameter] public EventCallback<BrowseNode> OnNodeSelected { get; set; }
|
[Parameter] public EventCallback<BrowseNode> OnNodeSelected { get; set; }
|
||||||
|
|
||||||
|
/// <summary>When true, leaves render a selection checkbox and clicking a leaf toggles it (WP6 "Browse
|
||||||
|
/// device…" multi-select) instead of single-selecting. Folders still expand/navigate as usual. Default
|
||||||
|
/// false preserves the single-select address-picker behavior.</summary>
|
||||||
|
[Parameter] public bool MultiSelect { get; set; }
|
||||||
|
|
||||||
|
/// <summary>In multi-select mode, the set of currently-selected leaf NodeIds (drives checkbox state +
|
||||||
|
/// row highlight). Owned by the parent; the tree only reads it.</summary>
|
||||||
|
[Parameter] public IReadOnlyCollection<string>? SelectedLeafIds { get; set; }
|
||||||
|
|
||||||
|
/// <summary>In multi-select mode, fired when a leaf's checkbox is toggled — carries the leaf plus its
|
||||||
|
/// captured ancestor-folder display-name path (root→parent) for optional TagGroup mirroring.</summary>
|
||||||
|
[Parameter] public EventCallback<ZB.MOM.WW.OtOpcUa.AdminUI.Browsing.BrowseLeafSelection> OnLeafToggled { get; set; }
|
||||||
|
|
||||||
private bool _loading = true;
|
private bool _loading = true;
|
||||||
private string? _error;
|
private string? _error;
|
||||||
private List<TreeItem>? _roots;
|
private List<TreeItem>? _roots;
|
||||||
@@ -86,10 +99,28 @@
|
|||||||
await OnNodeSelected.InvokeAsync(item.Node);
|
await OnNodeSelected.InvokeAsync(item.Node);
|
||||||
}
|
}
|
||||||
|
|
||||||
private RenderFragment RenderNode(TreeItem item, int depth) => __builder =>
|
private async Task ToggleLeafAsync(TreeItem item, IReadOnlyList<string> ancestorPath)
|
||||||
|
{
|
||||||
|
await OnLeafToggled.InvokeAsync(
|
||||||
|
new ZB.MOM.WW.OtOpcUa.AdminUI.Browsing.BrowseLeafSelection(item.Node, ancestorPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsLeafSelected(string nodeId) => SelectedLeafIds?.Contains(nodeId) == true;
|
||||||
|
|
||||||
|
private static IReadOnlyList<string> Append(IReadOnlyList<string> path, string segment)
|
||||||
|
{
|
||||||
|
var list = new List<string>(path.Count + 1);
|
||||||
|
list.AddRange(path);
|
||||||
|
list.Add(segment);
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
private RenderFragment RenderNode(TreeItem item, int depth, IReadOnlyList<string> ancestorPath) => __builder =>
|
||||||
{
|
{
|
||||||
var indent = $"padding-left:{depth * 18}px";
|
var indent = $"padding-left:{depth * 18}px";
|
||||||
var selectedCls = _selectedNodeIdLocal == item.Node.NodeId ? "bg-primary-subtle" : "";
|
var isMultiLeaf = MultiSelect && item.Node.Kind == BrowseNodeKind.Leaf;
|
||||||
|
var selectedCls = (isMultiLeaf ? IsLeafSelected(item.Node.NodeId) : _selectedNodeIdLocal == item.Node.NodeId)
|
||||||
|
? "bg-primary-subtle" : "";
|
||||||
<div class="d-flex align-items-center gap-1 py-1 @selectedCls" style="@indent">
|
<div class="d-flex align-items-center gap-1 py-1 @selectedCls" style="@indent">
|
||||||
@if (item.Node.Kind == BrowseNodeKind.Folder && item.Node.HasChildrenHint)
|
@if (item.Node.Kind == BrowseNodeKind.Folder && item.Node.HasChildrenHint)
|
||||||
{
|
{
|
||||||
@@ -102,8 +133,18 @@
|
|||||||
{
|
{
|
||||||
<span style="width:18px"></span>
|
<span style="width:18px"></span>
|
||||||
}
|
}
|
||||||
<a href="#" @onclick="@(() => SelectAsync(item))" @onclick:preventDefault
|
@if (isMultiLeaf)
|
||||||
class="text-decoration-none mono small">@item.Node.DisplayName</a>
|
{
|
||||||
|
<input type="checkbox" class="form-check-input" checked="@IsLeafSelected(item.Node.NodeId)"
|
||||||
|
@onchange="@(() => ToggleLeafAsync(item, ancestorPath))" />
|
||||||
|
<a href="#" @onclick="@(() => ToggleLeafAsync(item, ancestorPath))" @onclick:preventDefault
|
||||||
|
class="text-decoration-none mono small">@item.Node.DisplayName</a>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<a href="#" @onclick="@(() => SelectAsync(item))" @onclick:preventDefault
|
||||||
|
class="text-decoration-none mono small">@item.Node.DisplayName</a>
|
||||||
|
}
|
||||||
@if (item.Node.Kind == BrowseNodeKind.Leaf)
|
@if (item.Node.Kind == BrowseNodeKind.Leaf)
|
||||||
{
|
{
|
||||||
<span class="chip chip-idle ms-1" style="font-size:0.7rem">leaf</span>
|
<span class="chip chip-idle ms-1" style="font-size:0.7rem">leaf</span>
|
||||||
@@ -129,7 +170,7 @@
|
|||||||
@bind="item.Filter" @bind:event="oninput" />
|
@bind="item.Filter" @bind:event="oninput" />
|
||||||
@foreach (var c in FilterChildren(item))
|
@foreach (var c in FilterChildren(item))
|
||||||
{
|
{
|
||||||
@RenderNode(c, depth + 1)
|
@RenderNode(c, depth + 1, Append(ancestorPath, item.Node.DisplayName))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
+201
@@ -0,0 +1,201 @@
|
|||||||
|
@* Create / edit modal for a raw-tree DriverInstance's channel/protocol config (endpoint lives on the
|
||||||
|
device — see DeviceModal). Dispatches to the right per-driver form body by DriverType; the form body
|
||||||
|
carries its own resilience section. The coordinator wires this into RawTree via @bind-Visible. *@
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Core.Abstractions
|
||||||
|
@inject IRawTreeService 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-xl" role="document">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">@(_isNew ? "New driver" : "Configure driver") · <span class="mono">@_driverType</span></h5>
|
||||||
|
<button type="button" class="btn-close" aria-label="Close" @onclick="CloseAsync"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
@if (_loadError is not null)
|
||||||
|
{
|
||||||
|
<div class="panel notice mb-3" style="border-color:var(--alert)">@_loadError</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="row g-3 mb-2">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Driver name</label>
|
||||||
|
<input class="form-control form-control-sm mono" @bind="_name" placeholder="line3-modbus" />
|
||||||
|
<div class="form-text">A RawPath segment — letters, digits, dash, underscore.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@switch (_driverType)
|
||||||
|
{
|
||||||
|
case DriverTypeNames.Modbus:
|
||||||
|
<ModbusDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
|
||||||
|
break;
|
||||||
|
case DriverTypeNames.S7:
|
||||||
|
<S7DriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
|
||||||
|
break;
|
||||||
|
case DriverTypeNames.AbCip:
|
||||||
|
<AbCipDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
|
||||||
|
break;
|
||||||
|
case DriverTypeNames.AbLegacy:
|
||||||
|
<AbLegacyDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
|
||||||
|
break;
|
||||||
|
case DriverTypeNames.TwinCAT:
|
||||||
|
<TwinCATDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
|
||||||
|
break;
|
||||||
|
case DriverTypeNames.FOCAS:
|
||||||
|
<FocasDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
|
||||||
|
break;
|
||||||
|
case DriverTypeNames.OpcUaClient:
|
||||||
|
<OpcUaClientDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
|
||||||
|
break;
|
||||||
|
case DriverTypeNames.Galaxy:
|
||||||
|
<GalaxyDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
<div class="alert alert-warning">No typed config form for driver type <span class="mono">@_driverType</span>.</div>
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
<p class="form-text mt-3 mb-0">
|
||||||
|
The connection endpoint + Test-connect live on the driver's <strong>device</strong> — open the device modal to author host/port and verify connectivity.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
@if (_saveError is not null)
|
||||||
|
{
|
||||||
|
<div class="panel notice mt-3" style="border-color:var(--alert)">@_saveError</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" @onclick="CloseAsync" disabled="@_busy">Cancel</button>
|
||||||
|
@if (_loadError is null)
|
||||||
|
{
|
||||||
|
<button type="button" class="btn btn-primary" @onclick="SaveAsync" disabled="@_busy">
|
||||||
|
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||||
|
@(_isNew ? "Create driver" : "Save changes")
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <summary>Whether the modal is shown (supports @bind-Visible).</summary>
|
||||||
|
[Parameter] public bool Visible { get; set; }
|
||||||
|
/// <summary>Two-way visibility callback.</summary>
|
||||||
|
[Parameter] public EventCallback<bool> VisibleChanged { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The driver instance to edit; null ⇒ create a new driver.</summary>
|
||||||
|
[Parameter] public string? DriverInstanceId { get; set; }
|
||||||
|
|
||||||
|
// --- Create-mode inputs (ignored on edit) ---
|
||||||
|
/// <summary>The owning cluster (required for create).</summary>
|
||||||
|
[Parameter] public string? ClusterId { get; set; }
|
||||||
|
/// <summary>The containing folder, or null for a cluster-root driver (create).</summary>
|
||||||
|
[Parameter] public string? FolderId { get; set; }
|
||||||
|
/// <summary>The driver type to create (required for create; immutable on edit).</summary>
|
||||||
|
[Parameter] public string? DriverType { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Raised after a successful create/update so the tree can refresh.</summary>
|
||||||
|
[Parameter] public EventCallback OnSaved { get; set; }
|
||||||
|
|
||||||
|
private bool _isNew;
|
||||||
|
private string _driverType = "";
|
||||||
|
private string _name = "";
|
||||||
|
private string _driverConfigJson = "{}";
|
||||||
|
private string? _resilienceConfig;
|
||||||
|
private byte[] _rowVersion = [];
|
||||||
|
|
||||||
|
private bool _busy;
|
||||||
|
private string? _loadError;
|
||||||
|
private string? _saveError;
|
||||||
|
private bool _openHandled;
|
||||||
|
|
||||||
|
protected override async Task OnParametersSetAsync()
|
||||||
|
{
|
||||||
|
if (Visible && !_openHandled)
|
||||||
|
{
|
||||||
|
_openHandled = true;
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
else if (!Visible)
|
||||||
|
{
|
||||||
|
_openHandled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadAsync()
|
||||||
|
{
|
||||||
|
_busy = false; _loadError = null; _saveError = null;
|
||||||
|
_isNew = string.IsNullOrEmpty(DriverInstanceId);
|
||||||
|
|
||||||
|
if (_isNew)
|
||||||
|
{
|
||||||
|
_driverType = DriverType ?? "";
|
||||||
|
_name = "";
|
||||||
|
_driverConfigJson = "{}";
|
||||||
|
_resilienceConfig = null;
|
||||||
|
_rowVersion = [];
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var dto = await Svc.LoadDriverForEditAsync(DriverInstanceId!);
|
||||||
|
if (dto is null) { _loadError = $"Driver '{DriverInstanceId}' was not found."; return; }
|
||||||
|
_driverType = dto.DriverType;
|
||||||
|
_name = dto.Name;
|
||||||
|
_driverConfigJson = string.IsNullOrWhiteSpace(dto.DriverConfig) ? "{}" : dto.DriverConfig;
|
||||||
|
_resilienceConfig = dto.ResilienceConfig;
|
||||||
|
_rowVersion = dto.RowVersion;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveAsync()
|
||||||
|
{
|
||||||
|
_busy = true; _saveError = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
UnsMutationResult result;
|
||||||
|
if (_isNew)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(ClusterId))
|
||||||
|
{
|
||||||
|
_saveError = "No owning cluster was supplied.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
result = await Svc.CreateDriverAsync(ClusterId, FolderId, _name, _driverType, _driverConfigJson);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
result = await Svc.UpdateDriverAsync(DriverInstanceId!, _name, _driverConfigJson, _resilienceConfig, _rowVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.Ok)
|
||||||
|
{
|
||||||
|
_saveError = result.Error ?? "Save failed.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await OnSaved.InvokeAsync();
|
||||||
|
await SetVisibleAsync(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex) { _saveError = ex.Message; }
|
||||||
|
finally { _busy = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task CloseAsync() => SetVisibleAsync(false);
|
||||||
|
|
||||||
|
private async Task SetVisibleAsync(bool value)
|
||||||
|
{
|
||||||
|
Visible = value;
|
||||||
|
_openHandled = value;
|
||||||
|
await VisibleChanged.InvokeAsync(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
+215
@@ -0,0 +1,215 @@
|
|||||||
|
@* Embeddable AB CIP driver (protocol/operation) config form body. The Devices collection moved out to
|
||||||
|
AbCipDeviceForm in v3 (its devices are separate entities); this form preserves the driver's existing
|
||||||
|
Devices across a load→save and the merge reconstructs the array from the device rows at deploy/probe
|
||||||
|
time. Hosted by the routed AbCipDriverPage and by DriverConfigModal. Exposes GetConfigJson() for the
|
||||||
|
parent to pull at save + Test-connect time, and fires DriverConfigJsonChanged for @bind support. *@
|
||||||
|
@using System.Text.Json
|
||||||
|
@using System.Text.Json.Serialization
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Driver.AbCip
|
||||||
|
|
||||||
|
@* Operation timeout *@
|
||||||
|
<section class="panel rise mt-3" style="animation-delay:.06s">
|
||||||
|
<div class="panel-head">Operation settings</div>
|
||||||
|
<div style="padding:1rem">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Timeout (seconds)</label>
|
||||||
|
<InputNumber @bind-Value="_form.TimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||||
|
<div class="form-text">Default libplctag call timeout. Default 2 s.</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="form-check form-switch mt-4">
|
||||||
|
<InputCheckbox @bind-Value="_form.EnableControllerBrowse" @bind-Value:after="EmitAsync" class="form-check-input" id="enableBrowse" />
|
||||||
|
<label class="form-check-label" for="enableBrowse">Enable controller browse</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-text">Walk the Logix symbol table and surface globals under Discovered/.</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="form-check form-switch mt-4">
|
||||||
|
<InputCheckbox @bind-Value="_form.EnableDeclarationOnlyUdtGrouping" @bind-Value:after="EmitAsync" class="form-check-input" id="enableUdtGroup" />
|
||||||
|
<label class="form-check-label" for="enableUdtGroup">Enable declaration-only UDT grouping</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-text">Only enable when UDT member declaration order matches controller compiled layout.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@* Alarm projection *@
|
||||||
|
<section class="panel rise mt-3" style="animation-delay:.08s">
|
||||||
|
<div class="panel-head">Alarm projection</div>
|
||||||
|
<div style="padding:1rem">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="form-check form-switch mt-4">
|
||||||
|
<InputCheckbox @bind-Value="_form.EnableAlarmProjection" @bind-Value:after="EmitAsync" class="form-check-input" id="enableAlarm" />
|
||||||
|
<label class="form-check-label" for="enableAlarm">Enable ALMD alarm projection</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-text">Surfaces ALMD tags as alarm conditions via IAlarmSource. Default off.</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Alarm poll interval (seconds)</label>
|
||||||
|
<InputNumber @bind-Value="_form.AlarmPollIntervalSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||||
|
<div class="form-text">Default 1 s.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@* Connectivity probe *@
|
||||||
|
<section class="panel rise mt-3" style="animation-delay:.10s">
|
||||||
|
<div class="panel-head">Connectivity probe</div>
|
||||||
|
<div style="padding:1rem">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="form-check form-switch mt-4">
|
||||||
|
<InputCheckbox @bind-Value="_form.ProbeEnabled" @bind-Value:after="EmitAsync" class="form-check-input" id="probeEnabled" />
|
||||||
|
<label class="form-check-label" for="probeEnabled">Enabled</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Interval (seconds)</label>
|
||||||
|
<InputNumber @bind-Value="_form.ProbeIntervalSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||||
|
<div class="form-text">Default 5 s.</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Timeout (seconds)</label>
|
||||||
|
<InputNumber @bind-Value="_form.ProbeTimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||||
|
<div class="form-text">Default 2 s.</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Probe tag path</label>
|
||||||
|
<InputText @bind-Value="_form.ProbeTagPath" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono"
|
||||||
|
placeholder="e.g. Program:Main.SomeTag" />
|
||||||
|
<div class="form-text">Required when probe is enabled. Leave blank and an operator warning is logged.</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Admin UI probe timeout (seconds)</label>
|
||||||
|
<InputNumber @bind-Value="_form.AdminProbeTimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||||
|
<div class="form-text">Max 60. Used by Test Connect. Default 5.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<DriverResilienceSection ResilienceConfig="@ResilienceConfig" ResilienceConfigChanged="OnResilienceChanged" />
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <summary>The driver-level DriverConfig JSON (protocol/operation; endpoints live on the devices).</summary>
|
||||||
|
[Parameter] public string DriverConfigJson { get; set; } = "{}";
|
||||||
|
/// <summary>Fired (camelCase-serialized) whenever a field changes — enables @bind-DriverConfigJson.</summary>
|
||||||
|
[Parameter] public EventCallback<string> DriverConfigJsonChanged { get; set; }
|
||||||
|
/// <summary>The per-instance resilience-pipeline overrides JSON, or null.</summary>
|
||||||
|
[Parameter] public string? ResilienceConfig { get; set; }
|
||||||
|
/// <summary>Fired when the resilience overrides change.</summary>
|
||||||
|
[Parameter] public EventCallback<string?> ResilienceConfigChanged { get; set; }
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions _jsonOpts = new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
||||||
|
WriteIndented = false,
|
||||||
|
Converters = { new JsonStringEnumConverter() },
|
||||||
|
};
|
||||||
|
|
||||||
|
private FormModel _form = new();
|
||||||
|
private string? _lastParsed;
|
||||||
|
|
||||||
|
// Preserve the driver's existing Devices across a load→save (the device form authors the real endpoints;
|
||||||
|
// the merge reconstructs the Devices array from the device rows at deploy/probe time).
|
||||||
|
private IReadOnlyList<AbCipDeviceOptions> _preservedDevices = [];
|
||||||
|
|
||||||
|
protected override void OnParametersSet()
|
||||||
|
{
|
||||||
|
// Re-parse only when the inbound value actually changed (don't clobber in-progress edits).
|
||||||
|
if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
var opts = TryDeserialize(DriverConfigJson) ?? new AbCipDriverOptions();
|
||||||
|
_form = FormModel.FromOptions(opts);
|
||||||
|
_preservedDevices = opts.Devices;
|
||||||
|
_lastParsed = DriverConfigJson;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Serializes the current protocol/operation config to camelCase JSON, carrying the preserved
|
||||||
|
/// Devices array forward (the device form authors the real endpoints; the merge reconstructs the array
|
||||||
|
/// from the device rows at deploy/probe time).</summary>
|
||||||
|
public string GetConfigJson()
|
||||||
|
=> JsonSerializer.Serialize(_form.ToOptions(_preservedDevices), _jsonOpts);
|
||||||
|
|
||||||
|
private async Task EmitAsync()
|
||||||
|
{
|
||||||
|
var json = GetConfigJson();
|
||||||
|
_lastParsed = json;
|
||||||
|
await DriverConfigJsonChanged.InvokeAsync(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnResilienceChanged(string? r)
|
||||||
|
{
|
||||||
|
ResilienceConfig = r;
|
||||||
|
await ResilienceConfigChanged.InvokeAsync(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AbCipDriverOptions? TryDeserialize(string json)
|
||||||
|
{
|
||||||
|
try { return JsonSerializer.Deserialize<AbCipDriverOptions>(json, _jsonOpts); }
|
||||||
|
catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flat mutable model — all scalar properties settable for Blazor @bind-Value.
|
||||||
|
// Collections (Devices) are preserved on the component and passed in on ToOptions().
|
||||||
|
public sealed class FormModel
|
||||||
|
{
|
||||||
|
// Operation
|
||||||
|
public int TimeoutSeconds { get; set; } = 2;
|
||||||
|
public bool EnableControllerBrowse { get; set; } = false;
|
||||||
|
public bool EnableDeclarationOnlyUdtGrouping { get; set; } = false;
|
||||||
|
|
||||||
|
// Alarm projection
|
||||||
|
public bool EnableAlarmProjection { get; set; } = false;
|
||||||
|
public int AlarmPollIntervalSeconds { get; set; } = 1;
|
||||||
|
|
||||||
|
// Probe
|
||||||
|
public bool ProbeEnabled { get; set; } = true;
|
||||||
|
public int ProbeIntervalSeconds { get; set; } = 5;
|
||||||
|
public int ProbeTimeoutSeconds { get; set; } = 2;
|
||||||
|
public string? ProbeTagPath { get; set; }
|
||||||
|
|
||||||
|
// Admin UI probe timeout
|
||||||
|
public int AdminProbeTimeoutSeconds { get; set; } = 5;
|
||||||
|
|
||||||
|
public static FormModel FromOptions(AbCipDriverOptions o) => new()
|
||||||
|
{
|
||||||
|
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
|
||||||
|
EnableControllerBrowse = o.EnableControllerBrowse,
|
||||||
|
EnableDeclarationOnlyUdtGrouping = o.EnableDeclarationOnlyUdtGrouping,
|
||||||
|
EnableAlarmProjection = o.EnableAlarmProjection,
|
||||||
|
AlarmPollIntervalSeconds = (int)o.AlarmPollInterval.TotalSeconds,
|
||||||
|
ProbeEnabled = o.Probe.Enabled,
|
||||||
|
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
|
||||||
|
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
|
||||||
|
ProbeTagPath = o.Probe.ProbeTagPath,
|
||||||
|
AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
|
||||||
|
};
|
||||||
|
|
||||||
|
public AbCipDriverOptions ToOptions(
|
||||||
|
IReadOnlyList<AbCipDeviceOptions> devices) => new()
|
||||||
|
{
|
||||||
|
Devices = devices,
|
||||||
|
Probe = new AbCipProbeOptions
|
||||||
|
{
|
||||||
|
Enabled = ProbeEnabled,
|
||||||
|
Interval = TimeSpan.FromSeconds(ProbeIntervalSeconds),
|
||||||
|
Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds),
|
||||||
|
ProbeTagPath = string.IsNullOrWhiteSpace(ProbeTagPath) ? null : ProbeTagPath,
|
||||||
|
},
|
||||||
|
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
|
||||||
|
EnableControllerBrowse = EnableControllerBrowse,
|
||||||
|
EnableAlarmProjection = EnableAlarmProjection,
|
||||||
|
AlarmPollInterval = TimeSpan.FromSeconds(AlarmPollIntervalSeconds),
|
||||||
|
EnableDeclarationOnlyUdtGrouping = EnableDeclarationOnlyUdtGrouping,
|
||||||
|
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+170
@@ -0,0 +1,170 @@
|
|||||||
|
@* Embeddable AB Legacy driver (channel/protocol) config form body. The Devices collection is authored
|
||||||
|
as separate v3 entities (the merge rebuilds the Devices array at deploy/probe time), so this form does
|
||||||
|
NOT edit it — the driver's existing devices are preserved verbatim. Hosted by the routed
|
||||||
|
AbLegacyDriverPage. Exposes GetConfigJson() for the parent to pull at save + Test-connect time, and
|
||||||
|
fires DriverConfigJsonChanged for @bind support. *@
|
||||||
|
@using System.Text.Json
|
||||||
|
@using System.Text.Json.Serialization
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies
|
||||||
|
|
||||||
|
@* Operation settings *@
|
||||||
|
<section class="panel rise mt-3" style="animation-delay:.06s">
|
||||||
|
<div class="panel-head">Operation settings</div>
|
||||||
|
<div style="padding:1rem">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Timeout (seconds)</label>
|
||||||
|
<InputNumber @bind-Value="_form.TimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||||
|
<div class="form-text">Default read/write timeout. Default 2 s.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@* Connectivity probe *@
|
||||||
|
<section class="panel rise mt-3" style="animation-delay:.08s">
|
||||||
|
<div class="panel-head">Connectivity probe</div>
|
||||||
|
<div style="padding:1rem">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="form-check form-switch mt-4">
|
||||||
|
<InputCheckbox @bind-Value="_form.ProbeEnabled" @bind-Value:after="EmitAsync" class="form-check-input" id="probeEnabled" />
|
||||||
|
<label class="form-check-label" for="probeEnabled">Enabled</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Interval (seconds)</label>
|
||||||
|
<InputNumber @bind-Value="_form.ProbeIntervalSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||||
|
<div class="form-text">Default 5 s.</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Timeout (seconds)</label>
|
||||||
|
<InputNumber @bind-Value="_form.ProbeTimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||||
|
<div class="form-text">Default 2 s.</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Probe address</label>
|
||||||
|
<InputText @bind-Value="_form.ProbeAddress" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono"
|
||||||
|
placeholder="S:0" />
|
||||||
|
<div class="form-text">PCCC file address to read for probe. Default S:0 (status file word 0).</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Admin UI probe timeout (seconds)</label>
|
||||||
|
<InputNumber @bind-Value="_form.AdminProbeTimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||||
|
<div class="form-text">Max 60. Used by Test Connect. Default 5.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<DriverResilienceSection ResilienceConfig="@ResilienceConfig" ResilienceConfigChanged="OnResilienceChanged" />
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <summary>The driver-level DriverConfig JSON (protocol/channel; devices live on separate v3 entities).</summary>
|
||||||
|
[Parameter] public string DriverConfigJson { get; set; } = "{}";
|
||||||
|
/// <summary>Fired (camelCase-serialized) whenever a channel field changes — enables @bind-DriverConfigJson.</summary>
|
||||||
|
[Parameter] public EventCallback<string> DriverConfigJsonChanged { get; set; }
|
||||||
|
/// <summary>The per-instance resilience-pipeline overrides JSON, or null.</summary>
|
||||||
|
[Parameter] public string? ResilienceConfig { get; set; }
|
||||||
|
/// <summary>Fired when the resilience overrides change.</summary>
|
||||||
|
[Parameter] public EventCallback<string?> ResilienceConfigChanged { get; set; }
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions _jsonOpts = new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
||||||
|
WriteIndented = false,
|
||||||
|
Converters = { new JsonStringEnumConverter() },
|
||||||
|
};
|
||||||
|
|
||||||
|
private FormModel _form = new();
|
||||||
|
private string? _lastParsed;
|
||||||
|
|
||||||
|
// Devices are separate v3 entities — this form doesn't edit them, but preserves the driver's existing
|
||||||
|
// Devices array verbatim across a load→save (the merge rebuilds it at deploy/probe time).
|
||||||
|
private IReadOnlyList<AbLegacyDeviceOptions> _preservedDevices = [];
|
||||||
|
|
||||||
|
protected override void OnParametersSet()
|
||||||
|
{
|
||||||
|
// Re-parse only when the inbound value actually changed (don't clobber in-progress edits).
|
||||||
|
if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
var opts = TryDeserialize(DriverConfigJson) ?? new AbLegacyDriverOptions();
|
||||||
|
_form = FormModel.FromOptions(opts);
|
||||||
|
_preservedDevices = opts.Devices;
|
||||||
|
_lastParsed = DriverConfigJson;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Serializes the current channel/protocol config to camelCase JSON, preserving the driver's
|
||||||
|
/// existing Devices array (authored as separate v3 entities).</summary>
|
||||||
|
public string GetConfigJson()
|
||||||
|
=> JsonSerializer.Serialize(_form.ToOptions(_preservedDevices), _jsonOpts);
|
||||||
|
|
||||||
|
private async Task EmitAsync()
|
||||||
|
{
|
||||||
|
var json = GetConfigJson();
|
||||||
|
_lastParsed = json;
|
||||||
|
await DriverConfigJsonChanged.InvokeAsync(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnResilienceChanged(string? r)
|
||||||
|
{
|
||||||
|
ResilienceConfig = r;
|
||||||
|
await ResilienceConfigChanged.InvokeAsync(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AbLegacyDriverOptions? TryDeserialize(string json)
|
||||||
|
{
|
||||||
|
try { return JsonSerializer.Deserialize<AbLegacyDriverOptions>(json, _jsonOpts); }
|
||||||
|
catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flat mutable model — all scalar properties settable for Blazor @bind-Value.
|
||||||
|
// Collections (Devices, Tags) are kept on the component and passed in on ToOptions().
|
||||||
|
public sealed class FormModel
|
||||||
|
{
|
||||||
|
// Operation
|
||||||
|
public int TimeoutSeconds { get; set; } = 2;
|
||||||
|
|
||||||
|
// Probe
|
||||||
|
public bool ProbeEnabled { get; set; } = true;
|
||||||
|
public int ProbeIntervalSeconds { get; set; } = 5;
|
||||||
|
public int ProbeTimeoutSeconds { get; set; } = 2;
|
||||||
|
public string? ProbeAddress { get; set; } = "S:0";
|
||||||
|
|
||||||
|
// Admin UI probe timeout
|
||||||
|
public int AdminProbeTimeoutSeconds { get; set; } = 5;
|
||||||
|
|
||||||
|
// Persistence
|
||||||
|
public string? ResilienceConfig { get; set; }
|
||||||
|
public byte[] RowVersion { get; set; } = [];
|
||||||
|
|
||||||
|
public static FormModel FromOptions(AbLegacyDriverOptions o) => new()
|
||||||
|
{
|
||||||
|
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
|
||||||
|
ProbeEnabled = o.Probe.Enabled,
|
||||||
|
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
|
||||||
|
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
|
||||||
|
ProbeAddress = o.Probe.ProbeAddress,
|
||||||
|
AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
|
||||||
|
};
|
||||||
|
|
||||||
|
public AbLegacyDriverOptions ToOptions(
|
||||||
|
IReadOnlyList<AbLegacyDeviceOptions> devices) => new()
|
||||||
|
{
|
||||||
|
Devices = devices,
|
||||||
|
Probe = new AbLegacyProbeOptions
|
||||||
|
{
|
||||||
|
Enabled = ProbeEnabled,
|
||||||
|
Interval = TimeSpan.FromSeconds(ProbeIntervalSeconds),
|
||||||
|
Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds),
|
||||||
|
ProbeAddress = string.IsNullOrWhiteSpace(ProbeAddress) ? null : ProbeAddress,
|
||||||
|
},
|
||||||
|
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
|
||||||
|
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+282
@@ -0,0 +1,282 @@
|
|||||||
|
@* Embeddable Fanuc FOCAS driver (channel/protocol) config form body. Endpoint fields (per-device
|
||||||
|
HostAddress/Series) moved to FocasDeviceForm in v3 (they live in the Device's DeviceConfig / the
|
||||||
|
multi-device Devices collection). Hosted by the routed FocasDriverPage and by DriverConfigModal.
|
||||||
|
Exposes GetConfigJson() for the parent to pull at save + Test-connect time, and fires
|
||||||
|
DriverConfigJsonChanged for @bind support. *@
|
||||||
|
@using System.Text.Json
|
||||||
|
@using System.Text.Json.Serialization
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Driver.FOCAS
|
||||||
|
|
||||||
|
@* Connection *@
|
||||||
|
<section class="panel rise mt-3" style="animation-delay:.05s">
|
||||||
|
<div class="panel-head">Connection</div>
|
||||||
|
<div style="padding:1rem">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label class="form-label" for="focasTimeoutSec">Timeout (seconds)</label>
|
||||||
|
<InputNumber id="focasTimeoutSec" @bind-Value="_form.TimeoutSeconds" @bind-Value:after="EmitAsync"
|
||||||
|
class="form-control form-control-sm" />
|
||||||
|
<div class="form-text">Per-operation timeout. Default 2 s.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@* Probe *@
|
||||||
|
<section class="panel rise mt-3" style="animation-delay:.08s">
|
||||||
|
<div class="panel-head">Connectivity probe</div>
|
||||||
|
<div style="padding:1rem">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<div class="form-check form-switch mt-2">
|
||||||
|
<InputCheckbox id="focasProbeEnabled" @bind-Value="_form.ProbeEnabled" @bind-Value:after="EmitAsync"
|
||||||
|
class="form-check-input" />
|
||||||
|
<label class="form-check-label" for="focasProbeEnabled">Probe enabled</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label class="form-label" for="focasProbeInterval">Probe interval (s)</label>
|
||||||
|
<InputNumber id="focasProbeInterval" @bind-Value="_form.ProbeIntervalSeconds" @bind-Value:after="EmitAsync"
|
||||||
|
class="form-control form-control-sm" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label class="form-label" for="focasProbeTimeout">Probe timeout (s)</label>
|
||||||
|
<InputNumber id="focasProbeTimeout" @bind-Value="_form.ProbeTimeoutSeconds" @bind-Value:after="EmitAsync"
|
||||||
|
class="form-control form-control-sm" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label class="form-label" for="focasAdminProbe">Admin probe timeout (s)</label>
|
||||||
|
<InputNumber id="focasAdminProbe" @bind-Value="_form.AdminProbeTimeoutSeconds" @bind-Value:after="EmitAsync"
|
||||||
|
class="form-control form-control-sm" />
|
||||||
|
<div class="form-text">Test Connect timeout (1–60 s).</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@* Alarm projection *@
|
||||||
|
<section class="panel rise mt-3" style="animation-delay:.11s">
|
||||||
|
<div class="panel-head">Alarm projection</div>
|
||||||
|
<div style="padding:1rem">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<div class="form-check form-switch mt-2">
|
||||||
|
<InputCheckbox id="focasAlarmEnabled" @bind-Value="_form.AlarmProjectionEnabled" @bind-Value:after="EmitAsync"
|
||||||
|
class="form-check-input" />
|
||||||
|
<label class="form-check-label" for="focasAlarmEnabled">Alarm projection enabled</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-text">Surfaces FOCAS alarms via IAlarmSource.</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label class="form-label" for="focasAlarmPoll">Alarm poll interval (s)</label>
|
||||||
|
<InputNumber id="focasAlarmPoll" @bind-Value="_form.AlarmProjectionPollIntervalSeconds" @bind-Value:after="EmitAsync"
|
||||||
|
class="form-control form-control-sm" />
|
||||||
|
<div class="form-text">One cnc_rdalmmsg2 call per device per tick. Default 2 s.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@* Handle recycle *@
|
||||||
|
<section class="panel rise mt-3" style="animation-delay:.14s">
|
||||||
|
<div class="panel-head">Handle recycle</div>
|
||||||
|
<div style="padding:1rem">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<div class="form-check form-switch mt-2">
|
||||||
|
<InputCheckbox id="focasRecycleEnabled" @bind-Value="_form.HandleRecycleEnabled" @bind-Value:after="EmitAsync"
|
||||||
|
class="form-check-input" />
|
||||||
|
<label class="form-check-label" for="focasRecycleEnabled">Handle recycle enabled</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-text">Proactive FWLIB session recycle to prevent handle pool exhaustion. Default off.</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label class="form-label" for="focasRecycleInterval">Recycle interval (minutes)</label>
|
||||||
|
<InputNumber id="focasRecycleInterval" @bind-Value="_form.HandleRecycleIntervalMinutes" @bind-Value:after="EmitAsync"
|
||||||
|
class="form-control form-control-sm" />
|
||||||
|
<div class="form-text">Typical: 30 min (shared pool) or 360 min (single client).</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@* Fixed tree *@
|
||||||
|
<section class="panel rise mt-3" style="animation-delay:.17s">
|
||||||
|
<div class="panel-head">Fixed-node tree</div>
|
||||||
|
<div style="padding:1rem">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<div class="form-check form-switch mt-2">
|
||||||
|
<InputCheckbox id="focasFixedTree" @bind-Value="_form.FixedTreeEnabled" @bind-Value:after="EmitAsync"
|
||||||
|
class="form-check-input" />
|
||||||
|
<label class="form-check-label" for="focasFixedTree">Fixed tree enabled</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-text">Exposes Identity/, Axes/, etc. from cnc_sysinfo/cnc_rdaxisname/cnc_rddynamic2. Default off.</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label class="form-label" for="focasAxisPoll">Axis poll interval (ms)</label>
|
||||||
|
<InputNumber id="focasAxisPoll" @bind-Value="_form.FixedTreePollIntervalMs" @bind-Value:after="EmitAsync"
|
||||||
|
class="form-control form-control-sm" />
|
||||||
|
<div class="form-text">cnc_rddynamic2 cadence per axis. Default 250 ms.</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label class="form-label" for="focasProgramPoll">Program poll interval (s)</label>
|
||||||
|
<InputNumber id="focasProgramPoll" @bind-Value="_form.FixedTreeProgramPollIntervalSeconds" @bind-Value:after="EmitAsync"
|
||||||
|
class="form-control form-control-sm" />
|
||||||
|
<div class="form-text">Program/mode info cadence. 0 = disabled. Default 1 s.</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label class="form-label" for="focasTimerPoll">Timer poll interval (s)</label>
|
||||||
|
<InputNumber id="focasTimerPoll" @bind-Value="_form.FixedTreeTimerPollIntervalSeconds" @bind-Value:after="EmitAsync"
|
||||||
|
class="form-control form-control-sm" />
|
||||||
|
<div class="form-text">Power-on/cutting/cycle timer cadence. 0 = disabled. Default 30 s.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<DriverResilienceSection ResilienceConfig="@ResilienceConfig" ResilienceConfigChanged="OnResilienceChanged" />
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <summary>The driver-level DriverConfig JSON (protocol/channel; per-device endpoint lives in the device).</summary>
|
||||||
|
[Parameter] public string DriverConfigJson { get; set; } = "{}";
|
||||||
|
/// <summary>Fired (camelCase-serialized) whenever a channel field changes — enables @bind-DriverConfigJson.</summary>
|
||||||
|
[Parameter] public EventCallback<string> DriverConfigJsonChanged { get; set; }
|
||||||
|
/// <summary>The per-instance resilience-pipeline overrides JSON, or null.</summary>
|
||||||
|
[Parameter] public string? ResilienceConfig { get; set; }
|
||||||
|
/// <summary>Fired when the resilience overrides change.</summary>
|
||||||
|
[Parameter] public EventCallback<string?> ResilienceConfigChanged { get; set; }
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions _jsonOpts = new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
||||||
|
WriteIndented = false,
|
||||||
|
Converters = { new JsonStringEnumConverter() },
|
||||||
|
};
|
||||||
|
|
||||||
|
private FormModel _form = new();
|
||||||
|
private string? _lastParsed;
|
||||||
|
|
||||||
|
// FOCAS is multi-device; the Devices collection is authored on the Raw tree, not on this form.
|
||||||
|
// Preserve whatever devices the persisted config carried so a driver-form load→save round-trip
|
||||||
|
// never drops them.
|
||||||
|
private IReadOnlyList<FocasDeviceOptions> _preservedDevices = [];
|
||||||
|
|
||||||
|
protected override void OnParametersSet()
|
||||||
|
{
|
||||||
|
// Re-parse only when the inbound value actually changed (don't clobber in-progress edits).
|
||||||
|
if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
var opts = TryDeserialize(DriverConfigJson) ?? new FocasDriverOptions();
|
||||||
|
_form = FormModel.FromOptions(opts);
|
||||||
|
_preservedDevices = opts.Devices;
|
||||||
|
_lastParsed = DriverConfigJson;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Serializes the current channel/protocol config to camelCase JSON, re-attaching the
|
||||||
|
/// preserved multi-device collection (authored on the Raw tree).</summary>
|
||||||
|
public string GetConfigJson()
|
||||||
|
=> JsonSerializer.Serialize(_form.ToOptions(_preservedDevices), _jsonOpts);
|
||||||
|
|
||||||
|
private async Task EmitAsync()
|
||||||
|
{
|
||||||
|
var json = GetConfigJson();
|
||||||
|
_lastParsed = json;
|
||||||
|
await DriverConfigJsonChanged.InvokeAsync(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnResilienceChanged(string? r)
|
||||||
|
{
|
||||||
|
ResilienceConfig = r;
|
||||||
|
await ResilienceConfigChanged.InvokeAsync(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FocasDriverOptions? TryDeserialize(string json)
|
||||||
|
{
|
||||||
|
try { return JsonSerializer.Deserialize<FocasDriverOptions>(json, _jsonOpts); }
|
||||||
|
catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flat mutable model — all scalar properties settable for Blazor @bind-Value.
|
||||||
|
// Collections (Devices, Tags) are kept on the component and passed in on ToOptions().
|
||||||
|
public sealed class FormModel
|
||||||
|
{
|
||||||
|
// Connection
|
||||||
|
public int TimeoutSeconds { get; set; } = 2;
|
||||||
|
|
||||||
|
// Probe
|
||||||
|
public bool ProbeEnabled { get; set; } = true;
|
||||||
|
public int ProbeIntervalSeconds { get; set; } = 5;
|
||||||
|
public int ProbeTimeoutSeconds { get; set; } = 2;
|
||||||
|
public int AdminProbeTimeoutSeconds { get; set; } = 10;
|
||||||
|
|
||||||
|
// Alarm projection
|
||||||
|
public bool AlarmProjectionEnabled { get; set; } = false;
|
||||||
|
public int AlarmProjectionPollIntervalSeconds { get; set; } = 2;
|
||||||
|
|
||||||
|
// Handle recycle
|
||||||
|
public bool HandleRecycleEnabled { get; set; } = false;
|
||||||
|
public int HandleRecycleIntervalMinutes { get; set; } = 60;
|
||||||
|
|
||||||
|
// Fixed tree
|
||||||
|
public bool FixedTreeEnabled { get; set; } = false;
|
||||||
|
public int FixedTreePollIntervalMs { get; set; } = 250;
|
||||||
|
public int FixedTreeProgramPollIntervalSeconds { get; set; } = 1;
|
||||||
|
public int FixedTreeTimerPollIntervalSeconds { get; set; } = 30;
|
||||||
|
|
||||||
|
// Common
|
||||||
|
public string? ResilienceConfig { get; set; }
|
||||||
|
public byte[] RowVersion { get; set; } = [];
|
||||||
|
|
||||||
|
public static FormModel FromOptions(FocasDriverOptions o) => new()
|
||||||
|
{
|
||||||
|
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
|
||||||
|
ProbeEnabled = o.Probe.Enabled,
|
||||||
|
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
|
||||||
|
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
|
||||||
|
AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
|
||||||
|
AlarmProjectionEnabled = o.AlarmProjection.Enabled,
|
||||||
|
AlarmProjectionPollIntervalSeconds = (int)o.AlarmProjection.PollInterval.TotalSeconds,
|
||||||
|
HandleRecycleEnabled = o.HandleRecycle.Enabled,
|
||||||
|
HandleRecycleIntervalMinutes = (int)o.HandleRecycle.Interval.TotalMinutes,
|
||||||
|
FixedTreeEnabled = o.FixedTree.Enabled,
|
||||||
|
FixedTreePollIntervalMs = (int)o.FixedTree.PollInterval.TotalMilliseconds,
|
||||||
|
FixedTreeProgramPollIntervalSeconds = (int)o.FixedTree.ProgramPollInterval.TotalSeconds,
|
||||||
|
FixedTreeTimerPollIntervalSeconds = (int)o.FixedTree.TimerPollInterval.TotalSeconds,
|
||||||
|
};
|
||||||
|
|
||||||
|
public FocasDriverOptions ToOptions(
|
||||||
|
IReadOnlyList<FocasDeviceOptions> devices) => new()
|
||||||
|
{
|
||||||
|
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
|
||||||
|
Probe = new FocasProbeOptions
|
||||||
|
{
|
||||||
|
Enabled = ProbeEnabled,
|
||||||
|
Interval = TimeSpan.FromSeconds(ProbeIntervalSeconds),
|
||||||
|
Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds),
|
||||||
|
},
|
||||||
|
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
|
||||||
|
AlarmProjection = new FocasAlarmProjectionOptions
|
||||||
|
{
|
||||||
|
Enabled = AlarmProjectionEnabled,
|
||||||
|
PollInterval = TimeSpan.FromSeconds(AlarmProjectionPollIntervalSeconds),
|
||||||
|
},
|
||||||
|
HandleRecycle = new FocasHandleRecycleOptions
|
||||||
|
{
|
||||||
|
Enabled = HandleRecycleEnabled,
|
||||||
|
Interval = TimeSpan.FromMinutes(HandleRecycleIntervalMinutes),
|
||||||
|
},
|
||||||
|
FixedTree = new FocasFixedTreeOptions
|
||||||
|
{
|
||||||
|
Enabled = FixedTreeEnabled,
|
||||||
|
PollInterval = TimeSpan.FromMilliseconds(FixedTreePollIntervalMs),
|
||||||
|
ProgramPollInterval = TimeSpan.FromSeconds(FixedTreeProgramPollIntervalSeconds),
|
||||||
|
TimerPollInterval = TimeSpan.FromSeconds(FixedTreeTimerPollIntervalSeconds),
|
||||||
|
},
|
||||||
|
Devices = devices,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+293
@@ -0,0 +1,293 @@
|
|||||||
|
@* Embeddable AVEVA Galaxy driver config form body. Galaxy connects to a SINGLE mxaccessgw gateway
|
||||||
|
(nested Gateway/MxAccess/Repository/Reconnect records) — there is no flat per-device endpoint to split
|
||||||
|
out, so the whole connection stays here (GalaxyDeviceForm is informational only). Hosted by the routed
|
||||||
|
page + DriverConfigModal. *@
|
||||||
|
@using System.Text.Json
|
||||||
|
@using System.Text.Json.Serialization
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config
|
||||||
|
|
||||||
|
@* mxaccessgw connection *@
|
||||||
|
<section class="panel rise mt-3" style="animation-delay:.06s">
|
||||||
|
<div class="panel-head">mxaccessgw connection</div>
|
||||||
|
<div style="padding:1rem">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Gateway endpoint</label>
|
||||||
|
<InputText @bind-Value="_form.Galaxy.GatewayEndpoint" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono"
|
||||||
|
placeholder="https://localhost:5001" />
|
||||||
|
<div class="form-text">gRPC endpoint of the mxaccessgw process.</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">API key secret ref</label>
|
||||||
|
<InputText @bind-Value="_form.Galaxy.GatewayApiKeySecretRef" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono"
|
||||||
|
placeholder="env:MX_API_KEY" />
|
||||||
|
<div class="form-text">Forms: <code>env:NAME</code>, <code>file:PATH</code>, <code>dev:KEY</code>. Cleartext is accepted but triggers a startup warning.</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="form-check form-switch mt-4">
|
||||||
|
<InputCheckbox @bind-Value="_form.Galaxy.GatewayUseTls" @bind-Value:after="EmitAsync" class="form-check-input" id="gwTls" />
|
||||||
|
<label class="form-check-label" for="gwTls">Use TLS</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-5">
|
||||||
|
<label class="form-label">CA certificate path (optional)</label>
|
||||||
|
<InputText @bind-Value="_form.Galaxy.GatewayCaCertificatePath" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono"
|
||||||
|
placeholder="C:\certs\ca.pem" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label">Connect timeout (s)</label>
|
||||||
|
<InputNumber @bind-Value="_form.Galaxy.GatewayConnectTimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||||
|
<div class="form-text">Default 10 s.</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label">Call timeout (s)</label>
|
||||||
|
<InputNumber @bind-Value="_form.Galaxy.GatewayDefaultCallTimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||||
|
<div class="form-text">Default 30 s.</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label">Stream timeout (s, 0 = unlimited)</label>
|
||||||
|
<InputNumber @bind-Value="_form.Galaxy.GatewayStreamTimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||||
|
<div class="form-text">Default 0 (lifetime of driver).</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@* MXAccess *@
|
||||||
|
<section class="panel rise mt-3" style="animation-delay:.08s">
|
||||||
|
<div class="panel-head">MXAccess</div>
|
||||||
|
<div style="padding:1rem">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Client name</label>
|
||||||
|
<InputText @bind-Value="_form.Galaxy.MxClientName" @bind-Value:after="EmitAsync" class="form-control form-control-sm"
|
||||||
|
placeholder="OtOpcUa-Primary" />
|
||||||
|
<div class="form-text">Must be unique per OtOpcUa instance — redundancy pairs each get a distinct name.</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Publishing interval (ms)</label>
|
||||||
|
<InputNumber @bind-Value="_form.Galaxy.MxPublishingIntervalMs" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||||
|
<div class="form-text">Default 1000 ms.</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label">Write user ID</label>
|
||||||
|
<InputNumber @bind-Value="_form.Galaxy.MxWriteUserId" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||||
|
<div class="form-text">0 = anonymous.</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Event pump channel capacity</label>
|
||||||
|
<InputNumber @bind-Value="_form.Galaxy.MxEventPumpChannelCapacity" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||||
|
<div class="form-text">Default 50000. Raise if events.dropped appears.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@* Repository *@
|
||||||
|
<section class="panel rise mt-3" style="animation-delay:.10s">
|
||||||
|
<div class="panel-head">Galaxy repository</div>
|
||||||
|
<div style="padding:1rem">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Discover page size</label>
|
||||||
|
<InputNumber @bind-Value="_form.Galaxy.RepositoryDiscoverPageSize" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||||
|
<div class="form-text">Default 5000 objects per page.</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="form-check form-switch mt-4">
|
||||||
|
<InputCheckbox @bind-Value="_form.Galaxy.RepositoryWatchDeployEvents" @bind-Value:after="EmitAsync" class="form-check-input" id="watchDeploy" />
|
||||||
|
<label class="form-check-label" for="watchDeploy">Watch deploy events</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-text mt-0">Triggers address-space rebuild on Galaxy re-deploy.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@* Reconnect *@
|
||||||
|
<section class="panel rise mt-3" style="animation-delay:.12s">
|
||||||
|
<div class="panel-head">Reconnect backoff</div>
|
||||||
|
<div style="padding:1rem">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Initial backoff (ms)</label>
|
||||||
|
<InputNumber @bind-Value="_form.Galaxy.ReconnectInitialBackoffMs" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||||
|
<div class="form-text">Default 500 ms.</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Max backoff (ms)</label>
|
||||||
|
<InputNumber @bind-Value="_form.Galaxy.ReconnectMaxBackoffMs" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||||
|
<div class="form-text">Default 30000 ms.</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="form-check form-switch mt-4">
|
||||||
|
<InputCheckbox @bind-Value="_form.Galaxy.ReconnectReplayOnSessionLost" @bind-Value:after="EmitAsync" class="form-check-input" id="replayOnLost" />
|
||||||
|
<label class="form-check-label" for="replayOnLost">Replay subscriptions on session lost</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@* Diagnostics *@
|
||||||
|
<section class="panel rise mt-3" style="animation-delay:.14s">
|
||||||
|
<div class="panel-head">Diagnostics</div>
|
||||||
|
<div style="padding:1rem">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Admin UI probe timeout (seconds)</label>
|
||||||
|
<InputNumber @bind-Value="_form.Galaxy.ProbeTimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||||
|
<div class="form-text">Max 60. Used by Test Connect. Default 30.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<DriverResilienceSection ResilienceConfig="@ResilienceConfig" ResilienceConfigChanged="OnResilienceChanged" />
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public string DriverConfigJson { get; set; } = "{}";
|
||||||
|
[Parameter] public EventCallback<string> DriverConfigJsonChanged { get; set; }
|
||||||
|
[Parameter] public string? ResilienceConfig { get; set; }
|
||||||
|
[Parameter] public EventCallback<string?> ResilienceConfigChanged { get; set; }
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions _jsonOpts = new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
||||||
|
WriteIndented = false,
|
||||||
|
Converters = { new JsonStringEnumConverter() },
|
||||||
|
};
|
||||||
|
|
||||||
|
private FormModel _form = new();
|
||||||
|
private string? _lastParsed;
|
||||||
|
|
||||||
|
protected override void OnParametersSet()
|
||||||
|
{
|
||||||
|
if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
var opts = TryDeserialize(DriverConfigJson) ?? CreateDefaultOptions();
|
||||||
|
_form = new FormModel { Galaxy = GalaxyFormModel.FromRecord(opts) };
|
||||||
|
_lastParsed = DriverConfigJson;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string GetConfigJson()
|
||||||
|
=> JsonSerializer.Serialize(_form.Galaxy.ToRecord(), _jsonOpts);
|
||||||
|
|
||||||
|
private async Task EmitAsync()
|
||||||
|
{
|
||||||
|
var json = GetConfigJson();
|
||||||
|
_lastParsed = json;
|
||||||
|
await DriverConfigJsonChanged.InvokeAsync(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnResilienceChanged(string? r)
|
||||||
|
{
|
||||||
|
ResilienceConfig = r;
|
||||||
|
await ResilienceConfigChanged.InvokeAsync(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GalaxyDriverOptions CreateDefaultOptions() => new(
|
||||||
|
Gateway: new GalaxyGatewayOptions("https://localhost:5001", "env:MX_API_KEY"),
|
||||||
|
MxAccess: new GalaxyMxAccessOptions("OtOpcUa"),
|
||||||
|
Repository: new GalaxyRepositoryOptions(),
|
||||||
|
Reconnect: new GalaxyReconnectOptions());
|
||||||
|
|
||||||
|
private static GalaxyDriverOptions? TryDeserialize(string json)
|
||||||
|
{
|
||||||
|
try { return JsonSerializer.Deserialize<GalaxyDriverOptions>(json, _jsonOpts); }
|
||||||
|
catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class FormModel
|
||||||
|
{
|
||||||
|
public GalaxyFormModel Galaxy { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Mutable flat mirror of <see cref="GalaxyDriverOptions"/> and its nested records.</summary>
|
||||||
|
public sealed class GalaxyFormModel
|
||||||
|
{
|
||||||
|
// GalaxyGatewayOptions
|
||||||
|
public string GatewayEndpoint { get; set; } = "https://localhost:5001";
|
||||||
|
public string GatewayApiKeySecretRef { get; set; } = "env:MX_API_KEY";
|
||||||
|
public bool GatewayUseTls { get; set; } = true;
|
||||||
|
public string? GatewayCaCertificatePath { get; set; }
|
||||||
|
public int GatewayConnectTimeoutSeconds { get; set; } = 10;
|
||||||
|
public int GatewayDefaultCallTimeoutSeconds { get; set; } = 30;
|
||||||
|
public int GatewayStreamTimeoutSeconds { get; set; } = 0;
|
||||||
|
|
||||||
|
// GalaxyMxAccessOptions
|
||||||
|
public string MxClientName { get; set; } = "OtOpcUa";
|
||||||
|
public int MxPublishingIntervalMs { get; set; } = 1000;
|
||||||
|
public int MxWriteUserId { get; set; } = 0;
|
||||||
|
public int MxEventPumpChannelCapacity { get; set; } = 50_000;
|
||||||
|
|
||||||
|
// GalaxyRepositoryOptions
|
||||||
|
public int RepositoryDiscoverPageSize { get; set; } = 5000;
|
||||||
|
public bool RepositoryWatchDeployEvents { get; set; } = true;
|
||||||
|
|
||||||
|
// GalaxyReconnectOptions
|
||||||
|
public int ReconnectInitialBackoffMs { get; set; } = 500;
|
||||||
|
public int ReconnectMaxBackoffMs { get; set; } = 30_000;
|
||||||
|
public bool ReconnectReplayOnSessionLost { get; set; } = true;
|
||||||
|
|
||||||
|
// GalaxyDriverOptions top-level
|
||||||
|
public int ProbeTimeoutSeconds { get; set; } = 30;
|
||||||
|
|
||||||
|
public static GalaxyFormModel FromRecord(GalaxyDriverOptions r)
|
||||||
|
{
|
||||||
|
var gw = r.Gateway ?? new GalaxyGatewayOptions("https://localhost:5001", "env:MX_API_KEY");
|
||||||
|
var mx = r.MxAccess ?? new GalaxyMxAccessOptions("OtOpcUa");
|
||||||
|
var repo = r.Repository ?? new GalaxyRepositoryOptions();
|
||||||
|
var rc = r.Reconnect ?? new GalaxyReconnectOptions();
|
||||||
|
return new()
|
||||||
|
{
|
||||||
|
GatewayEndpoint = gw.Endpoint,
|
||||||
|
GatewayApiKeySecretRef = gw.ApiKeySecretRef,
|
||||||
|
GatewayUseTls = gw.UseTls,
|
||||||
|
GatewayCaCertificatePath = gw.CaCertificatePath,
|
||||||
|
GatewayConnectTimeoutSeconds = gw.ConnectTimeoutSeconds,
|
||||||
|
GatewayDefaultCallTimeoutSeconds = gw.DefaultCallTimeoutSeconds,
|
||||||
|
GatewayStreamTimeoutSeconds = gw.StreamTimeoutSeconds,
|
||||||
|
MxClientName = mx.ClientName,
|
||||||
|
MxPublishingIntervalMs = mx.PublishingIntervalMs,
|
||||||
|
MxWriteUserId = mx.WriteUserId,
|
||||||
|
MxEventPumpChannelCapacity = mx.EventPumpChannelCapacity,
|
||||||
|
RepositoryDiscoverPageSize = repo.DiscoverPageSize,
|
||||||
|
RepositoryWatchDeployEvents = repo.WatchDeployEvents,
|
||||||
|
ReconnectInitialBackoffMs = rc.InitialBackoffMs,
|
||||||
|
ReconnectMaxBackoffMs = rc.MaxBackoffMs,
|
||||||
|
ReconnectReplayOnSessionLost = rc.ReplayOnSessionLost,
|
||||||
|
ProbeTimeoutSeconds = r.ProbeTimeoutSeconds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public GalaxyDriverOptions ToRecord() => new(
|
||||||
|
Gateway: new GalaxyGatewayOptions(
|
||||||
|
Endpoint: GatewayEndpoint,
|
||||||
|
ApiKeySecretRef: GatewayApiKeySecretRef,
|
||||||
|
UseTls: GatewayUseTls,
|
||||||
|
CaCertificatePath: string.IsNullOrWhiteSpace(GatewayCaCertificatePath) ? null : GatewayCaCertificatePath,
|
||||||
|
ConnectTimeoutSeconds: GatewayConnectTimeoutSeconds,
|
||||||
|
DefaultCallTimeoutSeconds: GatewayDefaultCallTimeoutSeconds,
|
||||||
|
StreamTimeoutSeconds: GatewayStreamTimeoutSeconds),
|
||||||
|
MxAccess: new GalaxyMxAccessOptions(
|
||||||
|
ClientName: MxClientName,
|
||||||
|
PublishingIntervalMs: MxPublishingIntervalMs,
|
||||||
|
WriteUserId: MxWriteUserId,
|
||||||
|
EventPumpChannelCapacity: MxEventPumpChannelCapacity),
|
||||||
|
Repository: new GalaxyRepositoryOptions(
|
||||||
|
DiscoverPageSize: RepositoryDiscoverPageSize,
|
||||||
|
WatchDeployEvents: RepositoryWatchDeployEvents),
|
||||||
|
Reconnect: new GalaxyReconnectOptions(
|
||||||
|
InitialBackoffMs: ReconnectInitialBackoffMs,
|
||||||
|
MaxBackoffMs: ReconnectMaxBackoffMs,
|
||||||
|
ReplayOnSessionLost: ReconnectReplayOnSessionLost))
|
||||||
|
{
|
||||||
|
ProbeTimeoutSeconds = ProbeTimeoutSeconds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user