Compare commits
51 Commits
ac1e1dfd12
...
bc9843d2bd
| Author | SHA1 | Date | |
|---|---|---|---|
| bc9843d2bd | |||
| 87e433871e | |||
| d860b0d8a6 | |||
| 370a2b7b48 | |||
| f742050ebd | |||
| 1d7e2a0f8b | |||
| 004558c241 | |||
| 4f7999eac2 | |||
| 1784eedd3f | |||
| 63289d377c | |||
| ac5db0a9f8 | |||
| 4b38a9d8c8 | |||
| 1f172e55f7 | |||
| a6fed85ac9 | |||
| 2b890fa716 | |||
| 74e1abdf32 | |||
| 2ad1dbc894 | |||
| 82fec753c8 | |||
| caeaae21f9 | |||
| a27e82c8d1 | |||
| a92ba6a10b | |||
| bc31b6a4de | |||
| 789176738f | |||
| 43e8a37ded | |||
| 4c417f7fb8 | |||
| 295bb55dc6 | |||
| ab5d0752d8 | |||
| 4eb1d65e2b | |||
| b31d7cb03f | |||
| 60d48a2a0a | |||
| 4217b213b0 | |||
| a8640a9331 | |||
| fc0d43a3dc | |||
| 5256761368 | |||
| dafaf2faec | |||
| 3b418a54f1 | |||
| c9590c03d0 | |||
| 8e8ca9efe8 | |||
| 55101baaa4 | |||
| b28c6bdb62 | |||
| 1c96fe0be0 | |||
| 945ccd0b85 | |||
| b5748288df | |||
| c42a056537 | |||
| 788bb68d1d | |||
| bd2dd05a0c | |||
| bf86b3def6 | |||
| 73014258ef | |||
| 14fe88fc80 | |||
| 12423899aa | |||
| df4c265753 |
@@ -171,3 +171,7 @@ exactly. The backend lives in
|
||||
`src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/` (six minimal-API
|
||||
endpoints under `/api/script-analysis/*`, gated by the `FleetAdmin` policy).
|
||||
See `docs/ScriptEditor.md` for the full guide. Scripts may use the `{{equip}}` token for equipment-relative tag paths that resolve per-equipment at deploy time — see the "Equipment-relative tag paths" section in `docs/ScriptEditor.md`.
|
||||
|
||||
## Scripted Alarm Ack/Shelve
|
||||
|
||||
Inbound operator acknowledge/shelve for scripted alarms is fully implemented. Two surfaces converge on the `alarm-commands` DPS topic: (1) OPC UA Part 9 condition methods (Acknowledge / Confirm / AddComment / OneShotShelve / TimedShelve / Unshelve) wired in `OtOpcUaNodeManager`, gated on the `AlarmAck` LDAP role via `RoleCarryingUserIdentity`; (2) AdminUI `/alerts` per-row buttons routed through the `AdminOperationsActor` singleton (gated by `DriverOperator` policy). `ScriptedAlarmHostActor` dispatches from the topic to the engine. Client.CLI supports `ack`, `confirm`, `shelve` commands. See `docs/ScriptedAlarms.md` §"Inbound operator ack/shelve" and `docs/AlarmTracking.md`.
|
||||
|
||||
@@ -159,6 +159,7 @@ services:
|
||||
Security__Ldap__ServiceAccountDn: "cn=serviceaccount,dc=zb,dc=local"
|
||||
Security__Ldap__ServiceAccountPassword: "serviceaccount123"
|
||||
Security__DeployApiKey: "docker-dev-deploy-key"
|
||||
Security__Auth__DisableLogin: "true"
|
||||
# Pin EF Core + ASP.NET Core to Warning so the per-poll Deployment SELECT /
|
||||
# "Executed DbCommand" Information|Debug lines stop flooding the Serilog
|
||||
# pipeline and starving the Akka cluster heartbeat thread. The host logs via
|
||||
@@ -199,6 +200,7 @@ services:
|
||||
Security__Ldap__ServiceAccountDn: "cn=serviceaccount,dc=zb,dc=local"
|
||||
Security__Ldap__ServiceAccountPassword: "serviceaccount123"
|
||||
Security__DeployApiKey: "docker-dev-deploy-key"
|
||||
Security__Auth__DisableLogin: "true"
|
||||
# Quiet EF/AspNetCore SQL flood — see central-1 (Serilog override). mem_limit/
|
||||
# mem_reservation are inherited from the *otopcua-host anchor.
|
||||
Serilog__MinimumLevel__Override__Microsoft.EntityFrameworkCore: "Warning"
|
||||
|
||||
+55
-1
@@ -77,7 +77,7 @@ comment + original raise time) and arrive lower-latency (no
|
||||
publishing-interval delay on the sub-attribute reads), so they win
|
||||
the dedup.
|
||||
|
||||
## Acknowledge routing
|
||||
## Acknowledge routing — Galaxy / driver alarms
|
||||
|
||||
`DriverNodeManager` picks the acknowledger when registering each
|
||||
condition (PR B.3 logic):
|
||||
@@ -99,6 +99,60 @@ already validates the session's `AlarmAck` role before dispatching,
|
||||
so the gateway-side ack RPC only sees authenticated, authorised
|
||||
calls.
|
||||
|
||||
## Inbound operator ack/shelve — scripted alarms
|
||||
|
||||
Scripted alarms use a separate inbound path that converges on the
|
||||
`alarm-commands` DPS topic. Two surfaces route onto this topic:
|
||||
|
||||
### OPC UA Part 9 method path (external OPC UA clients)
|
||||
|
||||
`OtOpcUaNodeManager` wires the Part 9 condition methods (Acknowledge /
|
||||
Confirm / AddComment / OneShotShelve / TimedShelve / Unshelve) on each
|
||||
scripted-alarm `AlarmConditionState` node. Every call is **gated on the
|
||||
`AlarmAck` LDAP role** — fail-closed: sessions with no role or without
|
||||
`AlarmAck` group membership receive `BadUserAccessDenied` immediately.
|
||||
The LDAP-resolved role set is carried past `OpcUaApplicationHost` by
|
||||
`RoleCarryingUserIdentity` (a `UserIdentity` subclass), making it
|
||||
readable inside the method handler at dispatch time.
|
||||
|
||||
On allow, the handler publishes a `Commons.OpcUa.AlarmCommand` onto the
|
||||
`alarm-commands` DPS topic. The node manager is Akka-free; the dispatch
|
||||
action is a settable `Action<AlarmCommand>` injected at boot by the
|
||||
hosted service.
|
||||
|
||||
`OnTimedUnshelve` (the SDK's automatic unshelve timer) bypasses the
|
||||
operator gate — it is system-initiated.
|
||||
|
||||
`WriteAlarmCondition` fires the Part 9 condition event only when the
|
||||
incoming state differs from the node's current live state (delta-gate),
|
||||
preventing the double-emit that would otherwise occur when the SDK
|
||||
auto-applies the acked state and the engine re-projection fires a
|
||||
duplicate event immediately after.
|
||||
|
||||
### AdminUI path
|
||||
|
||||
The `/alerts` page shows per-row **Acknowledge / Shelve / Unshelve**
|
||||
buttons gated by the `DriverOperator` AdminUI policy. These route
|
||||
through the `AdminOperationsActor` cluster singleton
|
||||
(`AcknowledgeAlarmCommand` / `ShelveAlarmCommand`), which publishes onto
|
||||
the same `alarm-commands` topic. The singleton handles cross-node
|
||||
routing — the command always reaches the driver-role node owning the
|
||||
engine regardless of which AdminUI instance the operator is on.
|
||||
|
||||
### ScriptedAlarmHostActor dispatch
|
||||
|
||||
`ScriptedAlarmHostActor` subscribes to the `alarm-commands` topic,
|
||||
ownership-filters each command (each node only acts on its own alarms),
|
||||
and dispatches to the matching `ScriptedAlarmEngine` operation
|
||||
(`AcknowledgeAsync` / `ConfirmAsync` / `OneShotShelveAsync` /
|
||||
`TimedShelveAsync` / `UnshelveAsync` / `EnableAsync` / `DisableAsync` /
|
||||
`AddCommentAsync`). The engine's existing `OnEvent` callback handles
|
||||
the OPC UA node update — no explicit re-projection is required.
|
||||
|
||||
The AdminUI `/alerts` Shelve flow was live-verified on docker-dev
|
||||
2026-06-11: singleton → topic → host actor → engine → "Shelved" status
|
||||
reflected on `/alerts` with the operator identity threaded through.
|
||||
|
||||
## Historian write-back (non-Galaxy alarms)
|
||||
|
||||
Scripted alarms (and any future non-Galaxy `IAlarmSource` like
|
||||
|
||||
+37
-1
@@ -105,7 +105,43 @@ Every mutation the state machine produces is immediately persisted inside the en
|
||||
Two mapping notes specific to this adapter:
|
||||
|
||||
- `SubscribeAlarmsAsync` accepts a list of source-node-id filters, interpreted as Equipment-path prefixes. Empty list matches every alarm. Each emission is matched against every live subscription — the adapter keeps no per-subscription cursor.
|
||||
- `IAlarmSource.AcknowledgeAsync` does not carry a user identity. The adapter defaults the audit user to `"opcua-client"` so callers using the base interface still produce an audit entry. The server's Part 9 method handlers (Stream G) call the engine's richer `AcknowledgeAsync` / `ConfirmAsync` / `OneShotShelveAsync` / `TimedShelveAsync` / `UnshelveAsync` / `AddCommentAsync` directly with the authenticated principal instead.
|
||||
- `IAlarmSource.AcknowledgeAsync` does not carry a user identity. The adapter defaults the audit user to `"opcua-client"` so callers using the base interface still produce an audit entry. The server's Part 9 method handlers call the engine's richer `AcknowledgeAsync` / `ConfirmAsync` / `OneShotShelveAsync` / `TimedShelveAsync` / `UnshelveAsync` / `AddCommentAsync` directly with the authenticated principal instead.
|
||||
|
||||
## Inbound operator ack/shelve
|
||||
|
||||
Operators interact with active scripted alarms through two surfaces — both converge on the same `alarm-commands` DPS topic consumed by `ScriptedAlarmHostActor`.
|
||||
|
||||
### AlarmAck gate (OPC UA method path)
|
||||
|
||||
`OtOpcUaNodeManager` wires the OPC UA Part 9 condition methods (Acknowledge / Confirm / AddComment / OneShotShelve / TimedShelve / Unshelve) on each `AlarmConditionState` node. Every method call is gated on the `AlarmAck` LDAP role — fail-closed: a session with no resolved roles or no `AlarmAck` group membership receives `BadUserAccessDenied` immediately without reaching the engine. The role is carried on the session by `RoleCarryingUserIdentity` (a `UserIdentity` subclass that preserves the LDAP-resolved role set past `OpcUaApplicationHost`).
|
||||
|
||||
On allow, the handler publishes a `Commons.OpcUa.AlarmCommand` (containing command kind, condition id, comment, and operator principal) onto the `alarm-commands` DPS topic. The node manager itself stays Akka-free: the dispatch action is a settable `Action<AlarmCommand>` injected at boot by the hosted service.
|
||||
|
||||
`OnTimedUnshelve` (the SDK's internal auto-unshelve timer) bypasses the client gate — it is system-initiated and not subject to operator role checks.
|
||||
|
||||
### Delta-gate de-duplication
|
||||
|
||||
`WriteAlarmCondition` fires a Part 9 condition event only when the incoming state differs from the node's current live state. This suppresses the double-emit that would otherwise occur when the OPC UA SDK auto-applies the acked state on the node and the engine's re-projection then attempts to fire a duplicate event.
|
||||
|
||||
### AdminUI path
|
||||
|
||||
The AdminUI `/alerts` page (`Alerts.razor`) shows per-row **Acknowledge / Shelve / Unshelve** buttons. These are gated by the `DriverOperator` AdminUI policy and routed through the `AdminOperationsActor` cluster singleton (`AcknowledgeAlarmCommand` / `ShelveAlarmCommand`), which publishes onto the same `alarm-commands` topic. Cross-node routing is handled by the cluster singleton — the command always reaches the driver-role node hosting the engine that owns the alarm regardless of which AdminUI instance the operator is connected to.
|
||||
|
||||
### Client.CLI path
|
||||
|
||||
The Client.CLI supports `ack`, `confirm`, and `shelve` commands that call the Part 9 condition methods directly on the OPC UA server:
|
||||
|
||||
```bash
|
||||
dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- ack -u opc.tcp://localhost:4840 -c "ns=2;s=Plant/Line1/Oven::OverTemp" -m "Acknowledged by ops"
|
||||
dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- confirm -u opc.tcp://localhost:4840 -c "ns=2;s=Plant/Line1/Oven::OverTemp" -m "Confirmed"
|
||||
dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- shelve -u opc.tcp://localhost:4840 -c "ns=2;s=Plant/Line1/Oven::OverTemp" --timed 300000
|
||||
```
|
||||
|
||||
TimedShelve duration is sent as OPC UA `Duration` (milliseconds). The CLI uses `IOpcUaClientService.ConfirmAlarmAsync` / `ShelveAlarmAsync`.
|
||||
|
||||
### ScriptedAlarmHostActor dispatch
|
||||
|
||||
`ScriptedAlarmHostActor` subscribes to the `alarm-commands` DPS topic, ownership-filters each command (each node only acts on the alarms it owns), and dispatches to the matching `ScriptedAlarmEngine` operation (`AcknowledgeAsync` / `ConfirmAsync` / `OneShotShelveAsync` / `TimedShelveAsync` / `UnshelveAsync` / `EnableAsync` / `DisableAsync` / `AddCommentAsync`). No explicit re-projection is needed — the engine's existing `OnEvent` callback updates the OPC UA node after the transition.
|
||||
|
||||
Emissions map into `AlarmEventArgs` as `AlarmType = Kind.ToString()`, `SourceNodeId = EquipmentPath`, `ConditionId = AlarmId`, `Message = resolved template string`, `Severity` carried verbatim, `SourceTimestampUtc = emission time`.
|
||||
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
# Script-log Engine Emit + Scripted-Alarm Runtime — Design
|
||||
|
||||
> **Status:** approved 2026-06-10 (brainstorming). Next: implementation plan
|
||||
> (`writing-plans`). Companion gap analysis: `pending.md` (repo root, verified
|
||||
> against `master @ ac1e1dfd`).
|
||||
|
||||
## Goal
|
||||
|
||||
Make the Script-log page show real script output, and stand up scripted alarms
|
||||
end-to-end — including real OPC UA Part 9 condition nodes and client-initiated
|
||||
acknowledge/shelve. Delivered in three sequenced layers.
|
||||
|
||||
## Problem / current state (verified)
|
||||
|
||||
- The Script-log page (`ScriptLog.razor`) + transport (`IInProcessBroadcaster` →
|
||||
`ScriptLogSignalRBridge` → `script-logs` DPS topic) are fully built, but a healthy
|
||||
script's `ctx.Logger.*` output never reaches them — the Roslyn evaluators inject a
|
||||
**static** `SerilogLog.ForContext<…>()` into the script context. Only eval
|
||||
*failures* publish a `ScriptLogEntry` (from `VirtualTagActor`). Hence the page note
|
||||
*"No script-log entries yet. Engine emit (F8/F9) is pending."*
|
||||
- `ScriptLoggerFactory` / `ScriptLogCompanionSink` / the `scripts-*.log` sink exist
|
||||
but are **not wired into the Host** — the evaluators bypass them.
|
||||
- **Scripted alarms do not run at all.** The Part-9-complete `ScriptedAlarmEngine` +
|
||||
`ScriptedAlarmSource` are orphaned (never constructed); `DriverHostActor` wires only
|
||||
the virtual-tag host. The new runtime materialises a placeholder `[active,
|
||||
acknowledged]` variable per alarm; real Part 9 nodes are the unstarted **F14b /
|
||||
#85** workstream.
|
||||
- `phase-7-status.md` marks the alarm runtime "Done", but it describes a **superseded
|
||||
architecture** (`OpcUaApplicationHost` / `Phase7EngineComposer` etc., all 0 hits
|
||||
today). Trust the code, not that doc. Full detail + audit table in `pending.md`.
|
||||
|
||||
## Decisions (from brainstorming)
|
||||
|
||||
| # | Decision |
|
||||
|---|---|
|
||||
| D1 | **Full scope: all three layers** (shared emit · F9 engine runtime · F14b real Part 9 + client ack). |
|
||||
| D2 | Build F9 on the **heavyweight `ScriptedAlarmEngine`** (Part-9-complete, tested), **not** the lightweight `ScriptedAlarmActor`. Retire `ScriptedAlarmActor` + `RoslynScriptedAlarmEvaluator`. |
|
||||
| D3 | A **root script logger** (file + companion + new topic sink) is the **shared emit seam** for F8 *and* F9. Build once at Host startup, inject into both evaluators and the engine's `ScriptLoggerFactory`. |
|
||||
| D4 | `ScriptLogTopicSink` gates at a **configurable minimum level, default `Information`** (Debug/Trace stay in the file, off the wire). |
|
||||
| D5 | Composition carries the alarm predicate **source + dependency refs** via a new `EquipmentScriptedAlarmPlan` (parallel to `EquipmentVirtualTagPlan`), built byte-parity in **both** compose seams (`Phase7Composer` + `DeploymentArtifact`). |
|
||||
| D6 | **Ack plumbing is grouped into Layer 2** — AdminUI ack/shelve and OPC-UA-client ack both route to the same `engine.AcknowledgeAsync(...)`. Layer 1 stays "alarms run + persist + historize + emit". |
|
||||
|
||||
**No Configuration entity / EF migration change** is required: the `ScriptedAlarmState`
|
||||
table already exists; the EF store reads/writes it, and the composition enrichment is
|
||||
in-memory plan types only.
|
||||
|
||||
## Architecture
|
||||
|
||||
The unifying seam is the **root script logger**:
|
||||
|
||||
```
|
||||
root script logger ─┬→ scripts-*.log (rolling, all levels)
|
||||
├→ ScriptLogCompanionSink (Error+ → main opcua-*.log)
|
||||
└→ ScriptLogTopicSink (≥ min level) → IScriptLogPublisher
|
||||
→ DPS "script-logs" → ScriptLogSignalRBridge
|
||||
→ IInProcessBroadcaster → ScriptLog.razor
|
||||
```
|
||||
|
||||
Inject it into both Roslyn evaluators (F8) and the `ScriptedAlarmEngine`'s
|
||||
`ScriptLoggerFactory` (F9); every layer's script logging lights up the page for free.
|
||||
|
||||
```
|
||||
Layer 0 Shared emit ScriptLogTopicSink + root logger + per-script ForContext [foundation]
|
||||
Layer 1 F9 engine runtime ScriptedAlarmHostActor wraps ScriptedAlarmEngine [the payoff]
|
||||
Layer 2 F14b real Part 9 real AlarmConditionState nodes + events + inbound ack [SDK epic]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Layer 0 — Shared script-log emit + F8 live
|
||||
|
||||
**Outcome:** a healthy virtual-tag script's `ctx.Logger.Information(...)` shows on the
|
||||
page in ~½s.
|
||||
|
||||
**New:**
|
||||
- `ScriptLogTopicSink : Serilog.Core.ILogEventSink` (`Core.Scripting`) — reads
|
||||
`ScriptId`/`VirtualTagId`/`AlarmId`/`EquipmentId` off the `LogEvent`, builds a
|
||||
`ScriptLogEntry`, hands it to an injected `IScriptLogPublisher`. Min-level gate (D4).
|
||||
- `IScriptLogPublisher` (`Core.Scripting`) — `void Publish(ScriptLogEntry entry)`.
|
||||
Keeps Akka out of Core.
|
||||
- `DpsScriptLogPublisher : IScriptLogPublisher` (Runtime/Host) — holds the
|
||||
`ActorSystem` mediator; `Publish` → `Mediator.Tell(new Publish("script-logs", entry))`.
|
||||
|
||||
**Changed:**
|
||||
- Host startup builds the **root script logger** (file + companion + topic sink),
|
||||
registers it in DI.
|
||||
- `RoslynVirtualTagEvaluator` + `RoslynScriptedAlarmEvaluator` take the root logger
|
||||
instead of the static field; per evaluation `ForContext` the identity
|
||||
(`ScriptId`/`VirtualTagId`/`EquipmentId`) and inject into the script context.
|
||||
Requires threading `scriptId`+`equipmentId` to the evaluator per call (small
|
||||
extension; `VirtualTagId` already present — in the live path `scriptId ==
|
||||
virtualTagId` today).
|
||||
- `ScriptLoggerFactory` gains a binding for the standard property set (so the engine's
|
||||
per-alarm logger carries `AlarmId`/`EquipmentId` the topic sink understands).
|
||||
|
||||
**No regression:** `VirtualTagActor`'s existing failure `PublishLog` stays (catches
|
||||
compile errors/timeouts the script can't log) — distinct messages, no double-emit.
|
||||
|
||||
**Tests (xUnit+Shouldly):** sink props→entry mapping + min-level gate + null props;
|
||||
root-logger fan-out (Error→all three sinks, Debug→file only); evaluator emits via a
|
||||
fake `IScriptLogPublisher` when a script logs. Live-verify: author a logging vtag in
|
||||
docker-dev.
|
||||
|
||||
---
|
||||
|
||||
## Layer 1 — F9 engine runtime
|
||||
|
||||
**Outcome:** scripted alarms run — predicates evaluate against live tags, state
|
||||
persists, transitions drive the alarm node + historian + Alerts page, predicate logs
|
||||
hit the page (via Layer 0).
|
||||
|
||||
**Host:** `ScriptedAlarmHostActor` (new, Runtime) — child of `DriverHostActor`, spawned
|
||||
where `VirtualTagHostActor` is. Owns one `ScriptedAlarmEngine`; on `ApplyScriptedAlarms`
|
||||
calls `engine.LoadAsync(defs)`; disposes engine on stop.
|
||||
|
||||
**Supporting pieces:**
|
||||
1. `DependencyMuxTagUpstreamSource : ITagUpstreamSource` (new) — host registers
|
||||
interest with `DependencyMuxActor` for the union of alarm dep refs; each
|
||||
`DependencyValueChanged` pushes into the adapter cache and fires the engine's
|
||||
`SubscribeTag` observers. `ReadTag` = cache lookup (Bad if absent). Values wrapped
|
||||
as `DataValueSnapshot` (Good) like the vtag path.
|
||||
2. `EfAlarmConditionStateStore : IAlarmStateStore` (new) — persists `AlarmConditionState`
|
||||
↔ the existing `ScriptedAlarmState` table (enabled/acked/confirmed/shelving +
|
||||
`ShelvingExpiresUtc` + LastAck*/LastConfirm*/CommentsJson; **ActiveState re-derived,
|
||||
not stored**). Mirrors `EfAlarmActorStateStore`; uses `IDbContextFactory<OtOpcUaConfigDbContext>`.
|
||||
3. **Composition enrichment** (D5) — new `EquipmentScriptedAlarmPlan(ScriptedAlarmId,
|
||||
EquipmentId, Name, AlarmType, Severity, MessageTemplate, PredicateScriptId,
|
||||
PredicateSource, DependencyRefs, HistorizeToAveva, Retain, Enabled)`. `PredicateSource`
|
||||
resolved `PredicateScriptId → Script.SourceCode`; `DependencyRefs` =
|
||||
`DependencyExtractor.Extract(source).Reads` ∪ message-template token paths. Built in
|
||||
`Phase7Composer.Compose` (live DB) and `DeploymentArtifact` (artifact encode/decode),
|
||||
byte-parity. `DriverHostActor` → `ApplyScriptedAlarms(composition.EquipmentScriptedAlarms)`.
|
||||
4. **Engine→outputs bridge** (host `engine.OnEvent` handler): map `Condition` →
|
||||
`(active, acknowledged)` → `OpcUaPublishActor.WriteAlarmState(AlarmId, …)`; if
|
||||
`HistorizeToAveva` → `HistorianAdapterActor`; publish `AlarmTransitionEvent` on the
|
||||
`alerts` topic. **Script-log emit is automatic** — the host passes Layer 0's root
|
||||
logger into the engine's `ScriptLoggerFactory` (the Layer 0↔1 join).
|
||||
|
||||
**Retire:** `ScriptedAlarmActor`, `RoslynScriptedAlarmEvaluator`, the F9b DI
|
||||
registration in `Program.cs`.
|
||||
|
||||
```
|
||||
deploy → EquipmentScriptedAlarmPlan(source+deps) → ScriptedAlarmHostActor.LoadAsync
|
||||
tag change → DependencyMux → adapter → engine predicate eval → Part9StateMachine
|
||||
engine.OnEvent ─┬→ WriteAlarmState (active/ack node)
|
||||
├→ HistorianAdapterActor (if Historize)
|
||||
├→ alerts topic (Alerts page)
|
||||
└→ per-alarm logger → [Layer 0] → Script-log page
|
||||
```
|
||||
|
||||
**Tests:** EF store round-trip (in-memory EF); upstream adapter push→observer;
|
||||
composition enrichment + `Phase7Composer`↔`DeploymentArtifact` parity (same discipline
|
||||
as `{{equip}}`); host actor TestKit (apply → tag change → asserts WriteAlarmState /
|
||||
historian / alerts emitted). Engine internals already tested. Live-verify: author an
|
||||
alarm, drive its tag, watch the node flip + historian queue + predicate logs.
|
||||
|
||||
---
|
||||
|
||||
## Layer 2 — F14b real Part 9 + client ack
|
||||
|
||||
**Outcome:** alarms become real Part 9 conditions — clients see them in event
|
||||
subscriptions and can Acknowledge/Shelve/Confirm; the placeholder variable is retired.
|
||||
|
||||
**SDK-heavy epic (issue #85).** All SDK address-space work funnels through
|
||||
`OpcUaPublishActor` on the pinned dispatcher. **SDK specifics (creating
|
||||
`AlarmConditionState`, the event model, method-handler wiring) confirmed via the
|
||||
DeepWiki MCP (`OPCFoundation/UA-.NETStandard`) during planning**, not assumed here.
|
||||
|
||||
**Components:**
|
||||
1. **Real condition nodes** — materialise a proper `AlarmConditionState` (or the
|
||||
subtype per `AlarmType`: Limit/OffNormal/Discrete) under the equipment node with the
|
||||
standard sub-properties (ActiveState/AckedState/ConfirmedState/EnabledState/
|
||||
ShelvingState/Severity/Retain/Comment). Replaces `WriteAlarmState`'s flat variable.
|
||||
2. **State → condition** — the Layer 1 bridge now carries the full `AlarmConditionState`
|
||||
and sets it on the real node.
|
||||
3. **Event firing** — `condition.ReportEvent(...)` on each transition so subscribers get
|
||||
the alarm (AlarmsAndConditions).
|
||||
4. **Inbound method dispatch + ack plumbing (D6)** — wire
|
||||
`Acknowledge`/`Confirm`/`AddComment`/`OneShotShelve`/`TimedShelve`/`Unshelve` →
|
||||
`engine.<Op>Async(conditionId, principal, comment, ct)` with the **authenticated
|
||||
session principal**. Both the OPC UA client path and `ScriptedAlarms.razor` route to
|
||||
the same engine methods; engine transition → persist → emit → node update + event.
|
||||
5. **Permission gating** — method calls gate at the `AlarmAck` tier (LDAP-group → OPC-UA
|
||||
permission map); Confirm/Shelve/AddComment equivalently.
|
||||
|
||||
```
|
||||
OPC UA client ─┐ ┌→ engine updates AlarmConditionState
|
||||
AdminUI page ──┴→ engine.AcknowledgeAsync ┤ → OpcUaPublishActor → real condition node
|
||||
(principal, AlarmAck gate)└→ condition.ReportEvent → client event stream
|
||||
```
|
||||
|
||||
**Tests:** engine-state→condition-state mapping; method→engine routing (fake engine) +
|
||||
permission gating; SDK node/event behaviour proven by **Client.CLI** alarm subscribe +
|
||||
ack round-trip in docker-dev. Highest-risk layer; most live-verification dependent.
|
||||
|
||||
---
|
||||
|
||||
## Cross-cutting
|
||||
|
||||
**Hard rules (carry into the plan):**
|
||||
- Stage by explicit path — never `git add .`. Never stage `sql_login.txt` or
|
||||
`src/Server/ZB.MOM.WW.OtOpcUa.Host/pki/`. Never echo the gateway API key into a new
|
||||
tracked file. No force-push, no `--no-verify`.
|
||||
- **No Configuration entity / EF migration change** (the `ScriptedAlarmState` table
|
||||
already exists).
|
||||
- Agent must **not** sign in to the AdminUI for live verification — the user signs in.
|
||||
- Razor/JS proven only by live docker-dev `/run`; everything else unit-tested
|
||||
(xUnit + Shouldly, in-memory EF, Akka TestKit). No bUnit.
|
||||
- Build on a feature branch off `master`; planning docs (this design + the plan +
|
||||
`.tasks.json`) committed to `master` per established pattern.
|
||||
|
||||
**Touched code (indicative — plan nails exact files):**
|
||||
- Layer 0: `Core.Scripting/` (new sink + publisher iface + factory binding),
|
||||
`Host/Program.cs` (root logger wiring + retire static loggers), `Host/Engines/Roslyn*Evaluator.cs`,
|
||||
`Runtime` (DPS publisher).
|
||||
- Layer 1: `Runtime/ScriptedAlarms/` (host actor, upstream adapter, EF store),
|
||||
`OpcUaServer/Phase7Composer.cs` + `Runtime/Drivers/DeploymentArtifact.cs` (enriched plan),
|
||||
`Runtime/Drivers/DriverHostActor.cs` (spawn + apply), retire `ScriptedAlarmActor` +
|
||||
`RoslynScriptedAlarmEvaluator`.
|
||||
- Layer 2: `OpcUaServer/OtOpcUaNodeManager.cs` (real condition nodes + methods),
|
||||
`Runtime/OpcUa/OpcUaPublishActor.cs` (richer alarm message), security gate,
|
||||
`AdminUI/Components/Pages/ScriptedAlarms.razor` (ack/shelve control).
|
||||
|
||||
**Out of scope:** virtual-tag historization production sink (Gap 5 / B.6 — separate);
|
||||
Phase-7 Playwright E2E (F.7).
|
||||
|
||||
**Sequencing:** Layer 0 is the low-risk foundation and independently shippable. Layer 1
|
||||
is the self-contained "alarms work" payoff. Layer 2 is the SDK epic — largest, highest
|
||||
risk, most live-verification dependent.
|
||||
@@ -0,0 +1,669 @@
|
||||
# Script-log Engine Emit + Scripted-Alarm Runtime — Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: use superpowers-extended-cc:subagent-driven-development
|
||||
> (or executing-plans) to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Make the Script-log page tail real script output, and stand up scripted
|
||||
alarms end-to-end including real OPC UA Part 9 condition nodes + client ack.
|
||||
|
||||
**Architecture:** Three sequenced layers off one shared seam (a root script logger
|
||||
fanning to file + companion + a new DPS topic sink). Layer 0 = emit (F8 live). Layer 1
|
||||
= F9 engine runtime on the Akka equipment-namespace runtime. Layer 2 = F14b real Part 9
|
||||
nodes + events + inbound ack. Design: `docs/plans/2026-06-10-script-log-and-scripted-alarm-runtime-design.md`.
|
||||
Verified gap analysis: `pending.md`.
|
||||
|
||||
**Tech:** .NET 10, Akka.NET, EF Core (SQL prod / InMemory tests), Serilog, OPC
|
||||
Foundation UA .NET Standard, xUnit + Shouldly, Akka TestKit. No bUnit.
|
||||
|
||||
**Hard rules (every task):** stage by explicit path — never `git add .`; never stage
|
||||
`sql_login.txt` or `src/Server/ZB.MOM.WW.OtOpcUa.Host/pki/`; never echo the gateway API
|
||||
key into a new tracked file; no force-push, no `--no-verify`. **No Configuration entity
|
||||
/ EF migration change** (`ScriptedAlarmState` table already exists). Agent does **not**
|
||||
sign in to the AdminUI — the user drives live `/run`.
|
||||
|
||||
**Branch:** `feat/scriptlog-alarm-runtime` off `master @ df4c2657` (design committed there).
|
||||
|
||||
**Reference patterns to mirror:** `VirtualTagHostActor` (host actor shape),
|
||||
`EfAlarmActorStateStore` (EF store shape), the `{{equip}}` two-seam parity work
|
||||
(`Phase7Composer` ↔ `DeploymentArtifact`), `RoslynVirtualTagEvaluator` (evaluator).
|
||||
|
||||
---
|
||||
|
||||
# LAYER 0 — Shared script-log emit + F8 live
|
||||
|
||||
### Task 0: Branch + test-project check
|
||||
|
||||
**Classification:** small · **~2 min** · **Parallelizable with:** none
|
||||
|
||||
**Files:** none created (branch + verification only)
|
||||
|
||||
**Steps:**
|
||||
1. `git switch -c feat/scriptlog-alarm-runtime` (off `master @ df4c2657`).
|
||||
2. Confirm `tests/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/` exists and is in the
|
||||
`.slnx` (it does — `ScriptLoggerFactoryTests.cs` lives there). New Layer-0 tests land
|
||||
here. Confirm `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/` for Layer-1 tests.
|
||||
3. `dotnet build ZB.MOM.WW.OtOpcUa.slnx` — green baseline. Commit nothing.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `IScriptLogPublisher` + `ScriptLogTopicSink`
|
||||
|
||||
**Classification:** standard · **~4 min** · **Parallelizable with:** none
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/IScriptLogPublisher.cs`
|
||||
- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptLogTopicSink.cs`
|
||||
- Test: `tests/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/ScriptLogTopicSinkTests.cs`
|
||||
- Maybe modify: `Core.Scripting.csproj` (add ProjectReference to `Commons` for
|
||||
`ScriptLogEntry` if not already referenced — verify first).
|
||||
|
||||
**Step 1 — failing tests** (`ScriptLogTopicSinkTests`):
|
||||
- A `LogEvent` (Information) with properties `ScriptId="S1"`, `VirtualTagId="V1"`,
|
||||
`EquipmentId="EQ1"`, message `"hello"` → publisher receives one `ScriptLogEntry` with
|
||||
those fields, `Level=="Information"`, `Message=="hello"`.
|
||||
- `AlarmId` property maps to `ScriptLogEntry.AlarmId`; absent properties → null fields.
|
||||
- A `Debug` event with default `minLevel=Information` → publisher receives **nothing**.
|
||||
- Template message renders (`"v={V}"` + prop V=3 → `"v=3"`).
|
||||
Use a fake `IScriptLogPublisher` capturing entries.
|
||||
|
||||
**Step 2 — run, expect fail** (types don't exist).
|
||||
|
||||
**Step 3 — implement:**
|
||||
```csharp
|
||||
public interface IScriptLogPublisher { void Publish(ScriptLogEntry entry); }
|
||||
|
||||
public sealed class ScriptLogTopicSink : ILogEventSink
|
||||
{
|
||||
private readonly IScriptLogPublisher _publisher;
|
||||
private readonly LogEventLevel _min;
|
||||
public ScriptLogTopicSink(IScriptLogPublisher publisher,
|
||||
LogEventLevel min = LogEventLevel.Information) { _publisher = publisher; _min = min; }
|
||||
public void Emit(LogEvent e)
|
||||
{
|
||||
if (e is null || e.Level < _min) return;
|
||||
string? P(string k) => e.Properties.TryGetValue(k, out var v)
|
||||
&& v is ScalarValue { Value: string s } ? s : null;
|
||||
_publisher.Publish(new ScriptLogEntry(
|
||||
ScriptId: P("ScriptId") ?? P("ScriptName") ?? "unknown",
|
||||
Level: e.Level.ToString(),
|
||||
Message: e.RenderMessage(),
|
||||
TimestampUtc: e.Timestamp.UtcDateTime,
|
||||
VirtualTagId: P("VirtualTagId"), AlarmId: P("AlarmId"), EquipmentId: P("EquipmentId")));
|
||||
}
|
||||
}
|
||||
```
|
||||
(Property-name constants — reuse/extend `ScriptLoggerFactory`'s `ScriptNameProperty`;
|
||||
add `ScriptIdProperty`/`VirtualTagIdProperty`/`AlarmIdProperty`/`EquipmentIdProperty`.)
|
||||
|
||||
**Step 4 — run tests, expect pass. Step 5 — commit** (`git add` the 3 files by path).
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Root script logger + `DpsScriptLogPublisher` + Host wiring
|
||||
|
||||
**Classification:** standard · **~5 min** · **Parallelizable with:** none (depends T1)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Scripting/DpsScriptLogPublisher.cs`
|
||||
(or Host — wherever the `ActorSystem`/`Mediator` is reachable at construction).
|
||||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.Host/Logging/ScriptRootLoggerFactory.cs`
|
||||
(builds the root `ILogger`: rolling `scripts-*.log` + `ScriptLogCompanionSink` +
|
||||
`ScriptLogTopicSink`).
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs` (build + register root logger;
|
||||
register `IScriptLogPublisher`).
|
||||
- Test: `tests/.../Core.Scripting.Tests/ScriptRootLoggerFanoutTests.cs` (or Host.Tests).
|
||||
|
||||
**Steps (TDD):**
|
||||
1. Failing test: a logger built by `ScriptRootLoggerFactory` with a fake publisher +
|
||||
in-memory companion → an `Error` event reaches the companion mirror AND the topic
|
||||
publisher; a `Debug` event reaches neither topic nor companion (file only). (Assert
|
||||
via fakes; don't assert the physical file.)
|
||||
2. Implement `DpsScriptLogPublisher` — ctor takes the DPS mediator `IActorRef` (or
|
||||
`ActorSystem`); `Publish` → `mediator.Tell(new Publish("script-logs", entry))`
|
||||
(topic constant `VirtualTagActor.ScriptLogsTopic`).
|
||||
3. Implement `ScriptRootLoggerFactory.Build(IScriptLogPublisher, config)` →
|
||||
`LoggerConfiguration().WriteTo.File(...).WriteTo.Sink(new ScriptLogCompanionSink(Log.Logger))
|
||||
.WriteTo.Sink(new ScriptLogTopicSink(publisher, minLevel)).CreateLogger()`.
|
||||
4. `Program.cs`: resolve the mediator after the ActorSystem is up; register
|
||||
`IScriptLogPublisher` (singleton) + the root `ILogger` (keyed/named for scripts).
|
||||
Min-level from config (`Scripting:LogTopicMinLevel`, default `Information`).
|
||||
5. Run + commit by path.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Rewire evaluators to the root script logger
|
||||
|
||||
**Classification:** standard · **~5 min** · **Parallelizable with:** none (depends T1, T2)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynVirtualTagEvaluator.cs`
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynScriptedAlarmEvaluator.cs`
|
||||
- Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptLoggerFactory.cs` (bind the
|
||||
full property set, not just `ScriptName`).
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs` (inject root logger into the
|
||||
evaluators).
|
||||
- Test: `tests/.../Core.Scripting.Tests/` — evaluator emits via fake publisher.
|
||||
|
||||
**Steps:**
|
||||
1. Failing test: a `RoslynVirtualTagEvaluator` built with a root logger wired to a fake
|
||||
publisher; evaluate a script `ctx.Logger.Information("hi"); return 1;` → publisher
|
||||
gets one entry with `ScriptId`/`VirtualTagId` bound and `Message=="hi"`.
|
||||
2. Replace the static `ScriptLogger` field with a ctor-injected root `ILogger`. Per
|
||||
evaluation, `var log = _root.ForContext("ScriptId", id).ForContext("VirtualTagId", virtualTagId)`
|
||||
(+ `EquipmentId` when available) and pass into the `VirtualTagContext`. Same for the
|
||||
alarm evaluator (binds `AlarmId`).
|
||||
3. `ScriptLoggerFactory`: add a `Create(scriptId, virtualTagId?, alarmId?, equipmentId?)`
|
||||
overload binding the standard properties (keep the old `Create(scriptName)` for
|
||||
compatibility).
|
||||
4. `Program.cs`: pass the root logger to both evaluator registrations.
|
||||
5. Run + commit by path.
|
||||
|
||||
> Note: `IVirtualTagEvaluator.Evaluate` carries `virtualTagId`; in the live path
|
||||
> `scriptId == virtualTagId`, so Layer 0 binds both from it. Threading a distinct
|
||||
> `EquipmentId` (nice-to-have on the page) is optional here — if it requires an
|
||||
> interface change, defer it to a Layer-1 follow-up rather than expanding T3.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Live-verify Layer 0
|
||||
|
||||
**Classification:** verification · **Parallelizable with:** none (depends T2, T3)
|
||||
|
||||
**Steps:**
|
||||
1. Rebuild docker-dev central nodes (user-driven `/run`). Author a virtual tag whose
|
||||
script calls `ctx.Logger.Information(...)`.
|
||||
2. Open `/script-log`; drive the dependency so the script evaluates; confirm the line
|
||||
appears live with the right ScriptId/level. Confirm Debug stays off the page,
|
||||
Information+ shows.
|
||||
3. **Agent does not sign in** — user signs in and drives. Record outcome. No code unless
|
||||
a defect surfaces (→ new fix task).
|
||||
|
||||
---
|
||||
|
||||
# LAYER 1 — F9 engine runtime
|
||||
|
||||
### Task 5: `EquipmentScriptedAlarmPlan` + Phase7Composer enrichment
|
||||
|
||||
**Classification:** standard · **~5 min** · **Parallelizable with:** Task 7, Task 8
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs` (new record +
|
||||
build the enriched list from `ScriptedAlarm` + `Script` rows).
|
||||
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/Phase7/` (or wherever Phase7Composer
|
||||
is tested) — new `Phase7ComposerScriptedAlarmTests.cs`.
|
||||
|
||||
**Steps:**
|
||||
1. Failing test: compose two equipments each with a scripted alarm referencing a script;
|
||||
assert each `EquipmentScriptedAlarmPlan` carries the resolved `PredicateSource`,
|
||||
extracted `DependencyRefs` (via `DependencyExtractor`), `AlarmType`, `Severity`,
|
||||
`MessageTemplate`, `HistorizeToAveva`, `Retain`, `Enabled`, `Name`.
|
||||
2. Add `public sealed record EquipmentScriptedAlarmPlan(string ScriptedAlarmId, string
|
||||
EquipmentId, string Name, string AlarmType, int Severity, string MessageTemplate,
|
||||
string PredicateScriptId, string PredicateSource, IReadOnlyList<string> DependencyRefs,
|
||||
bool HistorizeToAveva, bool Retain, bool Enabled);`
|
||||
3. In `Compose`: join `ScriptedAlarm.PredicateScriptId → Script.SourceCode`; run
|
||||
`DependencyExtractor.Extract(source).Reads` (∪ `MessageTemplate` token paths) for
|
||||
`DependencyRefs`; project into the new list on the composition result. Skip
|
||||
`Enabled=false` alarms (or carry the flag — carry it; host decides). Drop alarms whose
|
||||
script is missing with a structured warning (don't throw the whole compose).
|
||||
4. Run + commit by path.
|
||||
|
||||
---
|
||||
|
||||
### Task 6: DeploymentArtifact parity for the alarm plan
|
||||
|
||||
**Classification:** standard · **~5 min** · **Parallelizable with:** Task 7, Task 8 (depends T5)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs`
|
||||
(encode/decode `EquipmentScriptedAlarmPlan`; add
|
||||
`Phase7CompositionResult.EquipmentScriptedAlarms`; filter-by-equipment like
|
||||
`EquipmentVirtualTags` at :263).
|
||||
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/` — artifact round-trip + parity
|
||||
with the Composer for the same input.
|
||||
|
||||
**Steps:**
|
||||
1. Failing test: build a composition via `Phase7Composer`, serialize to artifact, parse
|
||||
back → `EquipmentScriptedAlarms` is byte-identical (same discipline as the `{{equip}}`
|
||||
parity tests). Equipment-filter test (only alarms for resident equipment survive).
|
||||
2. Add the field to `Phase7CompositionResult`; mirror the `EquipmentVirtualTags`
|
||||
encode/decode/filter exactly (`:202`, `:263`).
|
||||
3. Run + commit by path.
|
||||
|
||||
---
|
||||
|
||||
### Task 7: `DependencyMuxTagUpstreamSource`
|
||||
|
||||
**Classification:** standard · **~4 min** · **Parallelizable with:** Task 5, Task 6, Task 8
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/DependencyMuxTagUpstreamSource.cs`
|
||||
(implements `Core.ScriptedAlarms`/`Core.VirtualTags` `ITagUpstreamSource`).
|
||||
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/DependencyMuxTagUpstreamSourceTests.cs`
|
||||
|
||||
**Steps:**
|
||||
1. Failing tests: `Push(path, snapshot)` updates cache so `ReadTag(path)` returns it;
|
||||
`SubscribeTag(path, obs)` → `obs` fires on the next `Push`; `ReadTag` for an unknown
|
||||
path returns a Bad-quality snapshot; dispose removes the observer.
|
||||
2. Implement: a thread-safe cache (`ConcurrentDictionary<string, DataValueSnapshot>`) +
|
||||
per-path observer list; `Push` updates cache then invokes observers; `ReadTag` reads
|
||||
cache (Bad if absent); `SubscribeTag` returns an `IDisposable` that deregisters. The
|
||||
host actor calls `Push` from its `DependencyValueChanged` handler. Value wrap:
|
||||
`new DataValueSnapshot(value, StatusCode:0, ts, ts)`.
|
||||
3. Run + commit by path.
|
||||
|
||||
---
|
||||
|
||||
### Task 8: `EfAlarmConditionStateStore : IAlarmStateStore`
|
||||
|
||||
**Classification:** standard · **~5 min** · **Parallelizable with:** Task 5, Task 6, Task 7
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/EfAlarmConditionStateStore.cs`
|
||||
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/EfAlarmConditionStateStoreTests.cs`
|
||||
(in-memory EF).
|
||||
|
||||
**Steps:**
|
||||
1. Failing tests (in-memory `OtOpcUaConfigDbContext`): `SaveAsync(state)` then
|
||||
`LoadAsync(alarmId)` round-trips Enabled/Acked/Confirmed/Shelving(+UnshelveAtUtc)/
|
||||
LastAck*/LastConfirm*/Comments; `LoadAsync` of an unknown id → null; `ActiveState`
|
||||
is **not** persisted (a saved state's Active is ignored on load — load returns the
|
||||
stored operator state, Active defaults). Comments JSON round-trips.
|
||||
2. Implement mapping `AlarmConditionState` ↔ `ScriptedAlarmState` entity (mirror
|
||||
`EfAlarmActorStateStore`'s `IDbContextFactory` upsert pattern; serialize
|
||||
`ImmutableList<AlarmComment>` ↔ `CommentsJson`). Map enum states ↔ the entity's string
|
||||
columns.
|
||||
3. Run + commit by path.
|
||||
|
||||
---
|
||||
|
||||
### Task 9: `ScriptedAlarmHostActor`
|
||||
|
||||
**Classification:** high-risk · **~5 min** · **Parallelizable with:** none (depends T6, T7, T8; needs Layer 0 T2/T3 root logger)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs`
|
||||
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmHostActorTests.cs`
|
||||
(Akka TestKit + a real engine with the fake upstream, or a fake engine seam).
|
||||
|
||||
**Design:** mirrors `VirtualTagHostActor`. Owns one `ScriptedAlarmEngine` (built with the
|
||||
`DependencyMuxTagUpstreamSource`, the `EfAlarmConditionStateStore`, a `ScriptLoggerFactory`
|
||||
wrapping the **Layer 0 root logger**, and the engine's root logger). Message
|
||||
`ApplyScriptedAlarms(IReadOnlyList<EquipmentScriptedAlarmPlan> Plans)`.
|
||||
|
||||
**Steps:**
|
||||
1. Failing TestKit tests:
|
||||
- `ApplyScriptedAlarms` with one alarm → engine loaded (assert via a probe/seam);
|
||||
registers interest with the (probe) mux for the alarm's dep refs.
|
||||
- A `DependencyValueChanged` that makes the predicate true → the host tells the
|
||||
(probe) `OpcUaPublishActor` a `WriteAlarmState(alarmId, active:true, …)`, tells the
|
||||
(probe) historian an `AlarmHistorianEvent` (when `HistorizeToAveva`), and publishes
|
||||
an `AlarmTransitionEvent` on `alerts`.
|
||||
- Re-`ApplyScriptedAlarms` with a different set reloads the engine (LoadAsync replace).
|
||||
2. Implement: on `ApplyScriptedAlarms`, build `ScriptedAlarmDefinition`s from the plans
|
||||
(map `AlarmType`→`AlarmKind`, `Severity`→`AlarmSeverity`, `EquipmentId`→`EquipmentPath`),
|
||||
`engine.LoadAsync`; register mux interest for `⋃ DependencyRefs`; on
|
||||
`DependencyValueChanged` → `_upstream.Push(...)`. Subscribe `engine.OnEvent` once →
|
||||
map `ScriptedAlarmEvent.Condition` to `(active, acknowledged)` →
|
||||
`OpcUaPublishActor.WriteAlarmState`; map → `AlarmHistorianEvent` → historian (if
|
||||
Historize); publish `AlarmTransitionEvent` on `alerts`. Dispose engine in `PostStop`.
|
||||
3. Run targeted tests (`dotnet test --filter ScriptedAlarmHostActor`). Commit by path.
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Spawn + apply in DriverHostActor
|
||||
|
||||
**Classification:** standard · **~4 min** · **Parallelizable with:** none (depends T9)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs`
|
||||
(spawn `ScriptedAlarmHostActor` next to `VirtualTagHostActor` ~:197; tell
|
||||
`ApplyScriptedAlarms(composition.EquipmentScriptedAlarms)` next to the vtag apply ~:532;
|
||||
add an override field for tests like `_virtualTagHostOverride`).
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs` if the
|
||||
host needs new injected deps (EF store factory, root logger, historian ref).
|
||||
- Test: extend `DriverHostActorTests` — apply pushes `ApplyScriptedAlarms`.
|
||||
|
||||
**Steps:** mirror the VirtualTag spawn/apply exactly; thread `_opcUaPublishActor`,
|
||||
`_dependencyMux`, the EF store, the root logger, the historian actor ref. Run + commit.
|
||||
|
||||
---
|
||||
|
||||
### Task 11: Retire the orphaned actor + F9b evaluator
|
||||
|
||||
**Classification:** small · **~3 min** · **Parallelizable with:** none (depends T9, T10)
|
||||
|
||||
**Files:**
|
||||
- Delete: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmActor.cs`
|
||||
(+ its tests) and `src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynScriptedAlarmEvaluator.cs`.
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs` (remove F9b DI registration,
|
||||
lines ~110-114) and any `IScriptedAlarmEvaluator` references. Keep
|
||||
`EfAlarmActorStateStore` only if nothing else uses it — otherwise delete with the actor.
|
||||
|
||||
**Steps:** delete, fix build, run full `dotnet test` for the touched projects, commit by
|
||||
path. (If something unexpectedly depends on these, stop and surface — don't expand scope.)
|
||||
|
||||
---
|
||||
|
||||
### Task 12: Live-verify Layer 1
|
||||
|
||||
**Classification:** verification · **Parallelizable with:** none (depends T10, T11)
|
||||
|
||||
**Steps:** rebuild docker-dev; author a scripted alarm whose predicate references a live
|
||||
tag; drive the tag; confirm the alarm node flips active/clear, the historian queue
|
||||
advances (`/alarms/historian`), the `alerts`/Alerts page shows it, and predicate
|
||||
`ctx.Logger` output appears on `/script-log`. User drives sign-in. Defects → new tasks.
|
||||
|
||||
---
|
||||
|
||||
# LAYER 2 — F14b real Part 9 + client ack
|
||||
|
||||
### Task 13: SDK research spike (DeepWiki)
|
||||
|
||||
**Classification:** small (research) · **~5 min** · **Parallelizable with:** Layer-1 tasks
|
||||
|
||||
**Steps:** Use the DeepWiki MCP (`OPCFoundation/UA-.NETStandard`) to confirm: how to
|
||||
create + add an `AlarmConditionState` (and Limit/OffNormal/Discrete subtypes) under a
|
||||
parent in a `CustomNodeManager2`; how to set ActiveState/AckedState/ConfirmedState/
|
||||
ShelvingState/Severity/Retain; how transitions fire events (`ReportEvent`); how inbound
|
||||
`Acknowledge`/`Shelve`/`Confirm` method calls are dispatched + where to hook them. Write
|
||||
findings to `docs/v2/f14b-part9-sdk-notes.md` (committed). This de-risks T14-T17.
|
||||
|
||||
---
|
||||
|
||||
### Task 14: Real condition-node materialisation
|
||||
|
||||
**Classification:** high-risk · **~5 min** · **Parallelizable with:** none (depends T13)
|
||||
|
||||
**Files:** `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs` (replace the
|
||||
placeholder `[active, ack]` variable in `WriteAlarmState` / add a `MaterialiseAlarmCondition`
|
||||
path per `AlarmType`); `Phase7Applier.cs` (call the new materialiser); tests where the
|
||||
SDK allows (node existence/type assertions).
|
||||
|
||||
**Steps:** create real condition nodes on materialise; keep `WriteAlarmState` as a thin
|
||||
shim during transition or replace its callers. Run + commit. (SDK threading: all via the
|
||||
pinned `OpcUaPublishActor` dispatcher.)
|
||||
|
||||
---
|
||||
|
||||
### Task 15: Richer alarm-state bridge
|
||||
|
||||
**Classification:** standard · **~4 min** · **Parallelizable with:** Task 17 (depends T14, T9)
|
||||
|
||||
**Files:** `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs` (new message
|
||||
carrying the full `AlarmConditionState`, not 2 bools); `ScriptedAlarmHostActor` bridge
|
||||
(send the richer message); `OtOpcUaNodeManager` (apply full state to the condition).
|
||||
Tests: message mapping.
|
||||
|
||||
---
|
||||
|
||||
### Task 16: Event firing on transition
|
||||
|
||||
**Classification:** high-risk · **~5 min** · **Parallelizable with:** none (depends T14, T15)
|
||||
|
||||
**Files:** `OtOpcUaNodeManager.cs` (`condition.ReportEvent(...)` on state change). Tests:
|
||||
mapping/coverage where feasible; behaviour proven in T19.
|
||||
|
||||
---
|
||||
|
||||
# LAYER 2 — inbound client ack/shelve (re-scoped 2026-06-11)
|
||||
|
||||
> **Status:** T0–T16 are **merged to `master`** (Layers 0+1 live-verified; Layer 2 Part-9
|
||||
> nodes/state/events done). The original single **T17 "Inbound method dispatch + ack plumbing"**
|
||||
> (`high-risk · ~5 min`) proved to be **four separate hard problems**, each its own task. After a
|
||||
> 2026-06-11 deep dive into the real code, T17 is split into **T17–T20** and the old T18–T20 shift
|
||||
> to **T21–T24** (old T19's Client.CLI work also grew a feature half, T22). This is the deferred
|
||||
> "fresh piece": branch off the **current** `master` (`git switch -c feat/scriptlog-alarm-ack`),
|
||||
> not the old `feat/scriptlog-alarm-runtime` base.
|
||||
|
||||
### Layer 2 design decisions (resolved in the re-scope deep dive)
|
||||
|
||||
These are the load-bearing findings the new tasks rest on — verified against the current code, not
|
||||
the original recon's assumptions:
|
||||
|
||||
1. **Topology — same-node co-location, multi-node ownership.** The OPC UA SDK server (+
|
||||
`OtOpcUaNodeManager`) and the `ScriptedAlarmHostActor` are **both spawned on every
|
||||
driver-role node** in the same `ActorSystem` (`OtOpcUaServerHostedService` +
|
||||
`DriverHostActor.SpawnScriptedAlarmHost`). So per node they're co-located and an in-process
|
||||
`Tell` would reach the local host. **But** in a multi-driver-node cluster each node owns a
|
||||
**disjoint** subset of alarms (its resident equipment, via the T6 artifact equipment-filter),
|
||||
and a client connects to **one** node's server. Whether that node owns the alarm the client
|
||||
acks is **not guaranteed**. **Decision:** route inbound commands over a new DPS topic
|
||||
`alarm-commands` (mirrors `alerts`/`script-logs`), and have each `ScriptedAlarmHostActor`
|
||||
**ignore commands for alarmIds its engine doesn't own**. This works same-node and cross-node
|
||||
with one mechanism. (Open item to confirm in T18: whether each node's address space is
|
||||
partitioned to its own equipment or replicated — if partitioned, a client can only ever ack
|
||||
local alarms and the DPS broadcast is still correct, just always locally satisfied.)
|
||||
|
||||
2. **The node manager has no Akka handle and must stay that way.** `OtOpcUaNodeManager(server,
|
||||
configuration)` (`OtOpcUaSdkServer.CreateMasterNodeManager`) holds **no** `IActorRef` /
|
||||
`ActorSystem` / DI. The existing **forward** seam is `OpcUaPublishActor → IOpcUaAddressSpaceSink
|
||||
(DeferredAddressSpaceSink → SdkAddressSpaceSink → node manager)`. For the **reverse** path the
|
||||
node manager gets a **settable command-router delegate** (`Action<AlarmCommand>`), wired at
|
||||
boot by `OtOpcUaServerHostedService` (which *does* have the DPS mediator) to publish onto
|
||||
`alarm-commands`. The node manager itself never touches Akka.
|
||||
|
||||
3. **No explicit re-projection after an engine op.** Every `ScriptedAlarmEngine` op
|
||||
(`AcknowledgeAsync`/`ConfirmAsync`/`OneShotShelveAsync`/`TimedShelveAsync`/`UnshelveAsync`/
|
||||
`EnableAsync`/`DisableAsync`/`AddCommentAsync` — all exist, signatures verified) raises the
|
||||
engine's `OnEvent`, which the host's **existing** `OnEngineEmission` already projects to the
|
||||
node. So the inbound handler just calls the op and awaits — the ack visibly updates the node
|
||||
for free. This makes T19 small.
|
||||
|
||||
4. **Roles are dropped at the impersonation seam.** `OpcUaApplicationHost.cs:292` does
|
||||
`args.Identity = new UserIdentity(token)` and **discards** `result.Roles` (only logs them at
|
||||
:293). `OpcUaUserAuthResult.Roles` is `IReadOnlyList<string>` (`ReadOnly`/`WriteOperate`/
|
||||
`WriteTune`/`WriteConfigure`/`AlarmAck`); there is an `OpcUaOperation` enum
|
||||
(`Core.Abstractions`) with `AlarmAcknowledge`/`AlarmConfirm`/`AlarmShelve`, but **no role is
|
||||
consulted anywhere post-auth today** (writes aren't gated either — this is greenfield, not a
|
||||
pattern to copy). **Risk (drives T17 being its own task):** it is **unconfirmed** that a custom
|
||||
`UserIdentity` subclass survives the SDK round-trip back to `context.UserIdentity` inside a
|
||||
method handler. T17 must *prove* the round-trip (integration assertion); fallback is populating
|
||||
`GrantedRoleIds` (`NodeIdCollection`) by mapping role strings → role NodeIds, which is more work.
|
||||
|
||||
5. **Double-emit is real, and delta-gating resolves it.** `WriteAlarmCondition` calls
|
||||
`ReportConditionEvent` **unconditionally** (`OtOpcUaNodeManager.cs:156`); the node manager keeps
|
||||
**no** previous snapshot. Once inbound acks route through the engine, the SDK's own
|
||||
`OnAcknowledgeCalled` auto-fires event E2 (applying acked state to the node) **and** the engine
|
||||
round-trip re-projects → would fire E3. Because the SDK applies the acked state *before* the
|
||||
async engine round-trip completes, **delta-gating `WriteAlarmCondition` against the node's
|
||||
current state** suppresses E3 (no delta) while still firing on genuine engine-driven
|
||||
transitions. That's T20. (Fallback if it proves racy: the correlation-suppression option already
|
||||
sketched in the `:190-198` in-code note — skip engine re-projection for inbound-originated
|
||||
transitions.)
|
||||
|
||||
---
|
||||
|
||||
### Task 17: Carry LDAP roles onto the OPC UA session identity
|
||||
|
||||
**Classification:** high-risk · **~5 min** · **Parallelizable with:** Task 22
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/RoleCarryingUserIdentity.cs`
|
||||
(`: UserIdentity`, adds `IReadOnlyList<string> Roles`).
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs:292`
|
||||
(`args.Identity = new RoleCarryingUserIdentity(token, result.Roles)`).
|
||||
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostImpersonationTests.cs`
|
||||
(existing home for `HandleImpersonation`).
|
||||
|
||||
**Steps:**
|
||||
1. **Round-trip spike FIRST (de-risk the whole task).** Before building anything, confirm the SDK
|
||||
preserves a custom `IUserIdentity` instance: in a booted in-process server test (mirror
|
||||
`SdkAddressSpaceSinkTests`' server fixture), set `args.Identity` to a sentinel subclass during
|
||||
impersonation and assert a method handler reads it back via
|
||||
`(context as ISessionOperationContext)?.UserIdentity` **as that subclass**. If it does NOT
|
||||
survive (SDK wraps/strips it), STOP and switch to the `GrantedRoleIds` approach — surface this
|
||||
as a scope change, don't silently expand.
|
||||
2. Failing unit test: `HandleImpersonation` on a successful auth sets `args.Identity` to a
|
||||
`RoleCarryingUserIdentity` whose `Roles` equals `result.Roles` (and the existing
|
||||
identity/denial/anonymous tests still pass).
|
||||
3. Implement `RoleCarryingUserIdentity` + the one-line `:292` swap.
|
||||
4. Run (`OpcUaServer.Tests` + the impersonation tests) + commit by path.
|
||||
|
||||
> Security-path change → high-risk. Touches only the identity construction; no auth-decision logic
|
||||
> changes (roles were already resolved, just discarded). Do **not** change `IOpcUaUserAuthenticator`
|
||||
> or the LDAP bind.
|
||||
|
||||
---
|
||||
|
||||
### Task 18: Node-manager command router + `AlarmAck` veto gate + `alarm-commands` topic
|
||||
|
||||
**Classification:** high-risk · **~5 min** · **Parallelizable with:** none (depends T17)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/AlarmCommand.cs`
|
||||
(`record AlarmCommand(string AlarmId, string Operation, string User, string? Comment, DateTime? UnshelveAtUtc)`;
|
||||
`Operation` ∈ Acknowledge/Confirm/OneShotShelve/TimedShelve/Unshelve/Enable/Disable/AddComment).
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs` — add a settable
|
||||
`Action<AlarmCommand>? AlarmCommandRouter`; in `MaterialiseAlarmCondition` (after `Create` +
|
||||
initial state, before `AddChild`) wire `alarm.OnAcknowledge`/`OnConfirm`/`OnAddComment`/
|
||||
`OnShelve`/`OnTimedUnshelve`. Each delegate: (a) read principal via
|
||||
`(context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity`, **gate on
|
||||
`AlarmAck`** → return `StatusCodes.BadUserAccessDenied` if absent; (b) invoke `AlarmCommandRouter`
|
||||
with the mapped `AlarmCommand` (so the engine updates the **domain** store + audit + alerts
|
||||
historization); (c) return `ServiceResult.Good` so the SDK applies node state + auto-fires (the
|
||||
engine re-projection is de-duped in T20).
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaSdkServer.cs` (pass-through to expose
|
||||
the router setter on the node manager).
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs` (after the server
|
||||
starts + node manager exists: resolve the DPS mediator, set the router to
|
||||
`mediator.Tell(new Publish(ScriptedAlarmHostActor.AlarmCommandsTopic, cmd))`).
|
||||
- Add the topic const `AlarmCommandsTopic = "alarm-commands"` on `ScriptedAlarmHostActor` (used here
|
||||
and in T19).
|
||||
- Test: `OpcUaServer.Tests` — veto gate allows with `AlarmAck` / denies without (drive a wired
|
||||
condition's `OnAcknowledge` with a `RoleCarryingUserIdentity` context); router invoked with the
|
||||
correctly-mapped `AlarmCommand` (fake `Action`).
|
||||
|
||||
**Steps:** TDD the gate + router-mapping in the node manager; then the SDK-server pass-through; then
|
||||
the hosted-service wiring (no unit test for the boot wiring — exercised by T23 live-verify). Commit
|
||||
by path. **Serialize with T20** (both touch `OtOpcUaNodeManager.cs`).
|
||||
|
||||
---
|
||||
|
||||
### Task 19: `ScriptedAlarmHostActor` inbound command handler
|
||||
|
||||
**Classification:** standard · **~4 min** · **Parallelizable with:** Task 20 (depends T18)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs` —
|
||||
subscribe to `AlarmCommandsTopic` in `PreStart` (alongside the existing `_mediator` use);
|
||||
`Receive<AlarmCommand>(OnAlarmCommand)`; `OnAlarmCommand` is `async void`, switches on
|
||||
`Operation` → the matching `engine.<Op>Async(AlarmId, User, …, CancellationToken.None)`.
|
||||
**Ownership filter:** if the engine doesn't own `AlarmId`, no-op (multi-node broadcast). Catch +
|
||||
log op failures (mirror `OnLoadFailed`). **No explicit re-projection** — the engine's `OnEvent`
|
||||
drives the existing `OnEngineEmission` → node update.
|
||||
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmHostActorTests.cs`
|
||||
(extend) — TestKit: an `AlarmCommand{Operation="Acknowledge"}` for a loaded alarm calls the
|
||||
engine's `AcknowledgeAsync` (fake/probe engine seam); an unknown `AlarmId` is ignored;
|
||||
`TimedShelve` without `UnshelveAtUtc` is rejected/logged, not thrown.
|
||||
|
||||
**Steps:** TDD via the existing host-actor test seam; run `dotnet test --filter ScriptedAlarmHostActor`;
|
||||
commit by path.
|
||||
|
||||
---
|
||||
|
||||
### Task 20: Delta-gate event firing (kill the inbound double-emit)
|
||||
|
||||
**Classification:** high-risk · **~4 min** · **Parallelizable with:** Task 19 (depends T18)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs` — keep a
|
||||
`ConcurrentDictionary<string, AlarmConditionSnapshot> _lastAlarmState`; in `WriteAlarmCondition`,
|
||||
after projecting, compare the new `state` to the stored snapshot and **only call
|
||||
`ReportConditionEvent` when it differs** (then store it). Replace the now-stale `:151-156`
|
||||
"fire exactly one event" comment + tighten the `:190-198` double-emit note to "resolved by
|
||||
delta-gate".
|
||||
- Test: `tests/.../OpcUaServer.Tests/SdkAddressSpaceSinkTests.cs` (extend) — two identical
|
||||
`WriteAlarmCondition` calls fire the condition event **once**; a changed call fires again.
|
||||
(Assert via an event-count probe / monitored-item on the booted server fixture.)
|
||||
|
||||
**Steps:** TDD the delta-gate; run `OpcUaServer.Tests`; commit by path. If the booted-server test
|
||||
can't cleanly count events, fall back to asserting the gate's decision via a seam and prove
|
||||
end-to-end in T23. **Serialize after T18** (same file).
|
||||
|
||||
---
|
||||
|
||||
### Task 21: AdminUI ack/shelve control
|
||||
|
||||
**Classification:** standard · **~5 min** · **Parallelizable with:** none (depends T19)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Admin/AcknowledgeAlarmCommand.cs` +
|
||||
`ShelveAlarmCommand.cs` (control-plane messages, mirror `StartDeployment`'s shape with a
|
||||
`CorrelationId`).
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs`
|
||||
(the existing admin-pinned cluster singleton) — `ReceiveAsync` handlers that publish onto
|
||||
`alarm-commands` (reusing T18's topic + the host's ownership filter → the singleton solves
|
||||
cross-node routing for the AdminUI path too).
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Clients/AdminOperationsClient.cs`
|
||||
(+ its interface) — `AcknowledgeAlarmAsync` / `ShelveAlarmAsync` (mirror `StartDeploymentAsync`).
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Alerts.razor` — per-row
|
||||
Acknowledge / Shelve buttons → `IAdminOperationsClient`; operator from
|
||||
`AuthState … User.Identity?.Name`.
|
||||
- Test: the control-plane command service + the new `AdminOperationsActor` handlers (TestKit / Ask).
|
||||
**No bUnit** — the razor is proven in T23.
|
||||
|
||||
**Steps:** TDD the messages + actor handlers + client; wire the razor; run + commit by path.
|
||||
|
||||
---
|
||||
|
||||
### Task 22: Client.CLI ack / confirm / shelve commands
|
||||
|
||||
**Classification:** standard · **~5 min** · **Parallelizable with:** Task 17–T21 (disjoint `Client.*`)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/IOpcUaClientService.cs` +
|
||||
`OpcUaClientService.cs` — `AcknowledgeAlarmAsync` already declared (no command wires it yet); add
|
||||
`ConfirmAlarmAsync` + `ShelveAlarmAsync` (call the SDK `Confirm`/`OneShotShelve`/`TimedShelve`/
|
||||
`Unshelve` methods on the condition).
|
||||
- Create: `src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/{Acknowledge,Confirm,Shelve}Command.cs`
|
||||
(`--node`, `--event-id`, `--comment`; shelve adds `--kind OneShot|Timed --unshelve-at`).
|
||||
- Test: unit-test what's pure (arg→request mapping); the live round-trip is T23.
|
||||
|
||||
**Steps:** add the service methods + CLI commands; build; commit by path. This is **net-new client
|
||||
feature work** (the reason old "T19 verification" couldn't just be a verify pass).
|
||||
|
||||
---
|
||||
|
||||
### Task 23: Live-verify Layer 2 end-to-end
|
||||
|
||||
**Classification:** verification · **Parallelizable with:** none (depends T18, T19, T20, T21, T22)
|
||||
|
||||
**Steps:** docker-dev up. Use the **already-deployed `t12-overheat`** alarm (rig state, below) as the
|
||||
live condition. With `Client.CLI`: `alarms --refresh` shows the real condition; drive
|
||||
`TestMachine_002.TestChangingInt` past the predicate so it fires an event on transition; call the new
|
||||
`acknowledge` command → confirm the ack **round-trips** (node `AckedState` flips, exactly **one** ack
|
||||
event fires — no double-emit, T20 — state **persists** across a node restart). Repeat the ack from
|
||||
the AdminUI `/alerts` buttons (T21) and confirm parity. Verify the `AlarmAck` gate: a user **without**
|
||||
`AlarmAck` is denied (`BadUserAccessDenied`). **Agent does not sign in** — user drives. Defects → new
|
||||
fix tasks.
|
||||
|
||||
---
|
||||
|
||||
### Task 24: Docs + cleanup + finish branch
|
||||
|
||||
**Classification:** small · **~5 min** · **Parallelizable with:** none (depends all)
|
||||
|
||||
**Files:** update `docs/ScriptedAlarms.md`, `docs/VirtualTags.md`, `docs/v2/Runtime.md`,
|
||||
`docs/AlarmTracking.md` (the inbound-ack + `AlarmAck`-gate flow is now real); **correct the stale
|
||||
`docs/v2/phase-7-status.md` alarm-runtime status**; CLAUDE.md note. **Clean up the deliberately-left
|
||||
rig artifacts** (`t12-overheat`, script `SC-ba675b168a85`, the `layer0-logcheck` vtag, and revert
|
||||
filler-02's inert `cycle-time-s` logger line — redeploy). Delete/condense `resume.md` + `pending.md`.
|
||||
Then run superpowers-extended-cc:finishing-a-development-branch (full `dotnet test`, merge to master).
|
||||
|
||||
---
|
||||
|
||||
## Execution notes
|
||||
|
||||
- **Parallel dispatch (Layers 0+1, done):** Layer 0 serial (T1→T2→T3→T4). Layer 1: **T5→T6** serial;
|
||||
**T7, T8 parallel** with T5/T6; T9 waits on T6/T7/T8; T10→T11→T12 serial.
|
||||
- **Parallel dispatch (Layer 2 remainder, T17–T24):**
|
||||
- **T17** first (roles) — its **step-1 round-trip spike is a go/no-go gate** for the gate design.
|
||||
- **T18** after T17 (the veto gate needs the roles). **T19 ∥ T20** after T18 (disjoint files:
|
||||
`ScriptedAlarmHostActor.cs` vs `OtOpcUaNodeManager.cs`).
|
||||
- **T22 runs in parallel with the whole T17–T21 server chain** (only `Client.*` files).
|
||||
- **T21** after T19. **T23** after T18/T19/T20/T21/T22. **T24** last.
|
||||
- **One writer at a time** within a shared file: `OtOpcUaNodeManager.cs` is touched by **T18 and
|
||||
T20 — serialize T18 → T20**. (Layers 0/1 already merged, so Program.cs/T14-T16 contention is moot.)
|
||||
- **Layer boundaries are natural checkpoints** — Layers 0+1 shipped; the T17 round-trip spike is the
|
||||
next gate before committing to the rest of the Layer 2 inbound epic.
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-06-10-script-log-and-scripted-alarm-runtime.md",
|
||||
"designPath": "docs/plans/2026-06-10-script-log-and-scripted-alarm-runtime-design.md",
|
||||
"branch": "feat/scriptlog-alarm-runtime",
|
||||
"baseBranch": "master",
|
||||
"baseSha": "df4c2657",
|
||||
"status": "merged-to-master",
|
||||
"mergedNote": "T17-T24 (inbound alarm ack/shelve) COMPLETE + merged to master 2026-06-11 on branch feat/scriptlog-alarm-ack. All review-approved (high-risk tasks: spec+code serial; standard: parallel). Full solution builds; every affected unit suite green (OpcUaServer 103, Runtime 137, ControlPlane 56, Core.ScriptedAlarms 67, Commons 25, Client.Shared 173, Client.CLI 93, Security 60, AdminUI 363). Pre-existing macOS/integration failures (OpcUaServer.IntegrationTests PKI store-path; Host.IntegrationTests deploy-Rejected confirmed failing on base; AbLegacy/AbCip fixture tests) are NOT from this work. Live-verified on docker-dev: real Part 9 condition materialises; AdminUI Shelve round-trips end-to-end (singleton->alarm-commands->host->engine->Shelved on /alerts) with operator identity threaded; anonymous OPC UA read/subscribe works. FOLLOW-UPS: (1) alerts-topic double-emit — both warm-redundant central nodes evaluate+emit the same alarm (pre-existing Layer-1/redundancy, only active node should emit or dedup at consumer); (2) T21 Timed-shelve datetime-picker UI deferred (backend wired+tested); (3) T21 auto-clear result chip + CorrelationId-wrapper consistency (minor); (4) docker-dev rig DB cleanup (t12-overheat/SC-ba675b168a85/layer0-logcheck) intentionally LEFT in place for further testing.",
|
||||
"note": "Layers 0+1 complete + live-verified; Layer 2 PARTIAL — T13-T16 (Part 9 nodes/state/events) done + reviewed, merged to master. RE-SCOPED 2026-06-11: the single underscoped T17 ('Inbound method dispatch + ack plumbing', ~5min) was a deep dive shown to be FOUR hard problems — now T17 (roles on session identity), T18 (node-manager router + AlarmAck veto + alarm-commands DPS topic), T19 (host-actor inbound handler), T20 (delta-gate double-emit). Old T18->T21 (AdminUI), old T19 split into T22 (Client.CLI ack/confirm/shelve feature work) + T23 (live-verify), old T20->T24 (docs+cleanup+finish). All DEFERRED; start a fresh branch feat/scriptlog-alarm-ack off CURRENT master. T17 step-1 SDK-identity-round-trip spike is the go/no-go gate.",
|
||||
"rescopeBranch": "feat/scriptlog-alarm-ack",
|
||||
"tasks": [
|
||||
{"id": 200, "planTask": 0, "subject": "T0: Branch + test-project check", "status": "completed"},
|
||||
{"id": 201, "planTask": 1, "subject": "T1: IScriptLogPublisher + ScriptLogTopicSink", "status": "completed", "commit": "14fe88fc"},
|
||||
{"id": 202, "planTask": 2, "subject": "T2: Root script logger + DpsScriptLogPublisher + Host wiring", "status": "completed", "commit": "73014258, bf86b3de"},
|
||||
{"id": 203, "planTask": 3, "subject": "T3: Rewire Roslyn evaluators to root logger", "status": "completed", "commit": "bd2dd05a, 788bb68d"},
|
||||
{"id": 204, "planTask": 4, "subject": "T4: Live-verify Layer 0", "status": "completed", "commit": "b5748288 (E2E test); live-verified /script-log 2026-06-10"},
|
||||
{"id": 205, "planTask": 5, "subject": "T5: EquipmentScriptedAlarmPlan + Phase7Composer enrichment", "status": "completed", "commit": "b28c6bd, 55101baa"},
|
||||
{"id": 206, "planTask": 6, "subject": "T6: DeploymentArtifact parity for alarm plan", "status": "completed", "commit": "8e8ca9ef, c9590c03"},
|
||||
{"id": 207, "planTask": 7, "subject": "T7: DependencyMuxTagUpstreamSource", "status": "completed", "commit": "945ccd0b, 55101baa"},
|
||||
{"id": 208, "planTask": 8, "subject": "T8: EfAlarmConditionStateStore", "status": "completed", "commit": "1c96fe0b, 55101baa"},
|
||||
{"id": 209, "planTask": 9, "subject": "T9: ScriptedAlarmHostActor", "status": "completed", "commit": "3b418a54, dafaf2fa"},
|
||||
{"id": 210, "planTask": 10, "subject": "T10: Spawn + apply in DriverHostActor", "status": "completed", "commit": "52567613, a8640a93"},
|
||||
{"id": 211, "planTask": 11, "subject": "T11: Retire orphaned actor + F9b evaluator", "status": "completed", "commit": "fc0d43a3"},
|
||||
{"id": 212, "planTask": 12, "subject": "T12: Live-verify Layer 1", "status": "completed", "commit": "live-verified end-to-end 2026-06-10 (alarm flips active/clear on /alerts, predicate logs on /script-log)"},
|
||||
{"id": 213, "planTask": 13, "subject": "T13: SDK research spike (DeepWiki)", "status": "completed", "commit": "4217b213"},
|
||||
{"id": 214, "planTask": 14, "subject": "T14: Real condition-node materialisation", "status": "completed", "commit": "60d48a2a, b31d7cb0"},
|
||||
{"id": 215, "planTask": 15, "subject": "T15: Richer alarm-state bridge", "status": "completed", "commit": "4eb1d65e, ab5d0752"},
|
||||
{"id": 216, "planTask": 16, "subject": "T16: Event firing on transition", "status": "completed", "commit": "295bb55d, 4c417f7f"},
|
||||
{"id": 217, "planTask": 17, "subject": "T17: Carry LDAP roles onto the OPC UA session identity", "status": "completed", "classification": "high-risk", "blockedBy": [], "note": "RoleCarryingUserIdentity : UserIdentity + OpcUaApplicationHost.cs:292 swap. STEP 1 = SDK-identity-round-trip spike (go/no-go: does a custom IUserIdentity survive back to context.UserIdentity in a method handler? fallback = GrantedRoleIds). Parallelizable with T22."},
|
||||
{"id": 218, "planTask": 18, "subject": "T18: Node-manager command router + AlarmAck veto gate + alarm-commands topic", "status": "completed", "classification": "high-risk", "blockedBy": [217], "note": "AlarmCommand record (Commons); settable Action<AlarmCommand> router on OtOpcUaNodeManager; wire OnAcknowledge/OnConfirm/OnAddComment/OnShelve/OnTimedUnshelve veto delegates in MaterialiseAlarmCondition (gate on AlarmAck from RoleCarryingUserIdentity, route to engine, return Good); pass-through in OtOpcUaSdkServer; publish onto alarm-commands DPS topic from OtOpcUaServerHostedService. Serialize with T20 (same file)."},
|
||||
{"id": 219, "planTask": 19, "subject": "T19: ScriptedAlarmHostActor inbound command handler", "status": "completed", "classification": "standard", "blockedBy": [218], "parallelizableWith": [220], "note": "Subscribe alarm-commands in PreStart; Receive<AlarmCommand> async-void switch -> engine.<Op>Async; ownership-filter unknown AlarmIds (multi-node broadcast); NO explicit re-projection (engine OnEvent -> existing OnEngineEmission)."},
|
||||
{"id": 220, "planTask": 20, "subject": "T20: Delta-gate event firing (kill inbound double-emit)", "status": "completed", "classification": "high-risk", "blockedBy": [218], "parallelizableWith": [219], "note": "ConcurrentDictionary<string,AlarmConditionSnapshot> _lastAlarmState; WriteAlarmCondition fires ReportConditionEvent only on a delta. Resolves SDK-auto-fire (E2) + engine-re-projection (E3) double-emit. Serialize after T18 (same file OtOpcUaNodeManager.cs)."},
|
||||
{"id": 221, "planTask": 21, "subject": "T21: AdminUI ack/shelve control", "status": "completed", "classification": "standard", "blockedBy": [219], "note": "Acknowledge/ShelveAlarmCommand (Commons); AdminOperationsActor singleton handlers publish onto alarm-commands (singleton solves cross-node for AdminUI); AdminOperationsClient methods; Alerts.razor per-row buttons. No bUnit."},
|
||||
{"id": 222, "planTask": 22, "subject": "T22: Client.CLI ack/confirm/shelve commands", "status": "completed", "classification": "standard", "blockedBy": [], "parallelizableWith": [217, 218, 219, 220, 221], "note": "NET-NEW client feature (why old T19 wasn't just a verify): IOpcUaClientService Confirm/Shelve (+ wire existing AcknowledgeAlarmAsync); Acknowledge/Confirm/Shelve CLI commands. Only Client.* files -> parallel with the whole server chain."},
|
||||
{"id": 223, "planTask": 23, "subject": "T23: Live-verify Layer 2 end-to-end", "status": "completed", "classification": "verification", "blockedBy": [218, 219, 220, 221, 222], "note": "Use deployed t12-overheat. Client.CLI + AdminUI ack round-trip (AckedState flips, ONE event, persists across restart); AlarmAck gate denies without role. User drives sign-in."},
|
||||
{"id": 224, "planTask": 24, "subject": "T24: Docs + cleanup + finish branch", "status": "completed", "classification": "small", "blockedBy": [223], "note": "Docs (ScriptedAlarms/VirtualTags/Runtime/AlarmTracking) + fix stale phase-7-status + CLAUDE.md; clean up rig artifacts (t12-overheat, SC-ba675b168a85, layer0-logcheck, revert filler-02 cycle-time-s); delete resume.md+pending.md; finishing-a-development-branch merge."}
|
||||
],
|
||||
"lastUpdated": "2026-06-11"
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
# AdminUI "Disable Login" Dev Flag — Design
|
||||
|
||||
**Date:** 2026-06-11
|
||||
**Status:** Approved (brainstorming) — ready for implementation plan.
|
||||
|
||||
## Goal
|
||||
|
||||
A config flag that **disables login in the AdminUI web app**. When enabled, every
|
||||
request is auto-authenticated as the fixed user **`multi-role-test`** holding **all**
|
||||
roles, so no login form / cookie / LDAP bind is involved. Default **off**; enabled in
|
||||
the local **docker-dev** environment for testing.
|
||||
|
||||
## Why / scope
|
||||
|
||||
Speeds up local AdminUI testing (no sign-in round-trip, no GLAuth dependency). Scope is
|
||||
the **AdminUI cookie web surface only** — the OPC UA server's LDAP auth and the deploy
|
||||
API's `X-Api-Key` are separate auth paths and are **untouched**.
|
||||
|
||||
## Background (current auth, verified)
|
||||
|
||||
- AdminUI auth is a **single cookie scheme** registered in
|
||||
`src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs::AddOtOpcUaAuth`
|
||||
(`AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(...)`).
|
||||
- Authorization has a **`FallbackPolicy = RequireAuthenticatedUser()`** (every page needs
|
||||
auth) plus policies **`FleetAdmin`** (→ role `Administrator`) and **`DriverOperator`**
|
||||
(→ `Operator` or `Administrator`). **All three name the cookie scheme explicitly**
|
||||
(`AuthorizationPolicyBuilder(CookieAuthenticationDefaults.AuthenticationScheme)`).
|
||||
- Login flow: `/login` page → `POST /auth/login` (`Security/Endpoints/AuthEndpoints.cs`)
|
||||
→ `ILdapAuthService` → `IGroupRoleMapper<string>` → builds a `ClaimsPrincipal`
|
||||
(`ZbClaimTypes.Name/Username/DisplayName` + `ZbClaimTypes.Role` per role) →
|
||||
`SignInAsync` cookie.
|
||||
- Blazor reads auth via the custom **`CookieAuthenticationStateProvider`**
|
||||
(`Security/Blazor/`), which **snapshots the boot request's `HttpContext.User`** and
|
||||
then only *invalidates* it by polling `/auth/ping`.
|
||||
- **Single source of truth:** `HttpContext.User`. Populate it for every request and BOTH
|
||||
the HTTP pipeline (pages, `/api/script-analysis/*` `FleetAdmin` endpoints) and the
|
||||
Blazor circuit (`AuthorizeView`, `[CascadingParameter] AuthenticationState`, policy
|
||||
checks) are covered by one seam.
|
||||
- Roles: `AdminRole` enum = `Viewer`, `Designer`, `Administrator`
|
||||
(`Core/.../Configuration/Enums/AdminRole.cs`), plus the appsettings-only control-plane
|
||||
string **`Operator`** (used by the `DriverOperator` policy). Granting all four satisfies
|
||||
every policy and every role-gated `AuthorizeView`.
|
||||
|
||||
## Approach (chosen: always-authenticating handler under the cookie scheme name)
|
||||
|
||||
When the flag is **on**, **replace the `AddCookie(...)` registration with a custom
|
||||
`AuthenticationHandler` registered under the *same* scheme name**
|
||||
(`CookieAuthenticationDefaults.AuthenticationScheme`). Its `HandleAuthenticateAsync`
|
||||
**always returns `AuthenticateResult.Success`** with the `multi-role-test` principal
|
||||
(all roles).
|
||||
|
||||
Registering under the cookie scheme name (not a new name) is the load-bearing detail:
|
||||
the `FallbackPolicy`, `FleetAdmin`, and `DriverOperator` policies all *name that scheme*,
|
||||
so they authenticate through the auto-login handler and pass — **no policy or page code
|
||||
changes**. `UseAuthentication()` stamps `HttpContext.User` on every request, so the
|
||||
circuit-boot snapshot and all minimal-API endpoints get the same principal.
|
||||
|
||||
Alternatives rejected: (2) stub the `AuthenticationStateProvider` + a pipeline middleware
|
||||
— two seams to keep in sync, and a page request still 302s to `/login` unless
|
||||
`HttpContext.User` is also set; (3) extend the existing `Security:Ldap:DevStubMode` to
|
||||
auto-redirect — still routes through the login form + cookie sign-in and grants only
|
||||
`Administrator`.
|
||||
|
||||
## Components
|
||||
|
||||
### 1. Options — `AuthDisableLoginOptions`
|
||||
|
||||
New options class (in `ZB.MOM.WW.OtOpcUa.Security`), bound to a new `Security:Auth`
|
||||
section:
|
||||
|
||||
```csharp
|
||||
public sealed class AuthDisableLoginOptions
|
||||
{
|
||||
public const string SectionName = "Security:Auth";
|
||||
public bool DisableLogin { get; set; } // default OFF
|
||||
public string User { get; set; } = "multi-role-test"; // the impersonated user
|
||||
}
|
||||
```
|
||||
|
||||
Roles are **not** configurable (YAGNI) — when the flag is on the principal always carries
|
||||
**all** canonical roles. The role set is centralized in one place (the enum values of
|
||||
`AdminRole` + the `Operator` literal) so a future role addition updates one spot.
|
||||
|
||||
```jsonc
|
||||
// appsettings.json (default)
|
||||
"Security": { "Auth": { "DisableLogin": false } }
|
||||
```
|
||||
|
||||
### 2. Handler — `AutoLoginAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>`
|
||||
|
||||
`HandleAuthenticateAsync` builds:
|
||||
|
||||
```csharp
|
||||
var claims = new List<Claim> {
|
||||
new(ZbClaimTypes.Name, user), // == ClaimTypes.Name → Identity.Name
|
||||
new(ZbClaimTypes.Username, user),
|
||||
new(ZbClaimTypes.DisplayName, user),
|
||||
};
|
||||
foreach (var role in AllRoles) // Viewer, Designer, Operator, Administrator
|
||||
claims.Add(new Claim(ZbClaimTypes.Role, role)); // == ClaimTypes.Role
|
||||
var principal = new ClaimsPrincipal(
|
||||
new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme));
|
||||
return AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme.Name));
|
||||
```
|
||||
|
||||
Mirrors the claim shape `AuthEndpoints` already produces, so downstream code (Account
|
||||
page, audit actor accessor, policies) sees an identical principal — just minted without a
|
||||
credential check.
|
||||
|
||||
### 3. Wiring — branch in `AddOtOpcUaAuth`
|
||||
|
||||
```csharp
|
||||
services.AddOptions<AuthDisableLoginOptions>().Bind(configuration.GetSection(AuthDisableLoginOptions.SectionName));
|
||||
var disableLogin = configuration.GetSection(AuthDisableLoginOptions.SectionName)
|
||||
.GetValue<bool>(nameof(AuthDisableLoginOptions.DisableLogin));
|
||||
|
||||
var authBuilder = services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
if (disableLogin)
|
||||
{
|
||||
// LOUD warning — see Guardrails.
|
||||
authBuilder.AddScheme<AuthenticationSchemeOptions, AutoLoginAuthenticationHandler>(
|
||||
CookieAuthenticationDefaults.AuthenticationScheme, _ => { });
|
||||
}
|
||||
else
|
||||
{
|
||||
authBuilder.AddCookie(o => { o.LoginPath = "/login"; o.LogoutPath = "/auth/logout"; });
|
||||
}
|
||||
```
|
||||
|
||||
The cookie-options `PostConfigure`, DataProtection, LDAP services, JWT, and the
|
||||
`AddAuthorization` policy block are **left exactly as-is** — they're harmless when the
|
||||
flag is on (`/auth/login` would still work if hit, just pointless), and untouched when
|
||||
off.
|
||||
|
||||
## Data flow (flag on)
|
||||
|
||||
```
|
||||
any HTTP request → UseAuthentication() → AutoLoginAuthenticationHandler.HandleAuthenticateAsync
|
||||
→ HttpContext.User = multi-role-test {Viewer,Designer,Operator,Administrator}
|
||||
├─ page request: FallbackPolicy.RequireAuthenticatedUser ✓ → page renders
|
||||
├─ /api/script-analysis/*: RequireAuthorization("FleetAdmin") → role Administrator ✓
|
||||
└─ Blazor circuit boot: snapshots HttpContext.User → CookieAuthenticationStateProvider._current
|
||||
→ AuthorizeView / [Authorize(Policy=...)] / IAuthorizationService all pass
|
||||
/auth/ping → authenticated → 2xx → ping loop never invalidates the circuit
|
||||
```
|
||||
|
||||
## Guardrails / error handling
|
||||
|
||||
- **Default off.** Production deployments are unaffected unless someone explicitly sets
|
||||
`Security:Auth:DisableLogin=true`.
|
||||
- **Works in any environment** (not hard-gated to `Development`) — docker-dev containers
|
||||
run as `Production`, and a hard-gate would silently no-op exactly where it's wanted.
|
||||
- **Loud startup warning** when on: `LogWarning("⚠ AdminUI LOGIN DISABLED — every request
|
||||
is authenticated as '{User}' with FULL permissions ({Roles}). Dev/test only — never set
|
||||
Security:Auth:DisableLogin=true in production.")`.
|
||||
- **Cosmetic edges (no action needed):** `/login` is never reached (nothing challenges);
|
||||
manually visiting it still shows the form but is harmless. `/auth/logout` clears a
|
||||
non-existent cookie and the next request re-authenticates anyway. These are acceptable
|
||||
for a dev flag; not worth special-casing.
|
||||
|
||||
## docker-dev enablement
|
||||
|
||||
Add to the `environment:` block of **both** AdminUI nodes in
|
||||
`docker-dev/docker-compose.yml` — `central-1` (line ~139) and `central-2` (line ~179),
|
||||
the only `OTOPCUA_ROLES=admin,driver` services:
|
||||
|
||||
```yaml
|
||||
Security__Auth__DisableLogin: "true"
|
||||
```
|
||||
|
||||
(`docker-compose.yml` is an existing tracked file that already carries the dev secrets —
|
||||
no new secret is introduced.) After the edit, `lmxopcua`/docker-dev rebuild picks it up;
|
||||
`/` loads straight into the app as `multi-role-test` with full access, no sign-in.
|
||||
|
||||
## Testing
|
||||
|
||||
No bUnit. In `tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/` (alongside
|
||||
`OtOpcUaLdapAuthServiceTests`):
|
||||
|
||||
- **Handler unit tests:** `AutoLoginAuthenticationHandler` returns `Success` with
|
||||
`Identity.Name == "multi-role-test"` (or the configured `User`) and role claims for
|
||||
**all four** canonical roles; the principal satisfies `FleetAdmin` and `DriverOperator`
|
||||
(assert via `IAuthorizationService`/policy evaluation or by checking `IsInRole`).
|
||||
- **Wiring test:** with `DisableLogin=true`, the registered authentication handler for the
|
||||
cookie scheme is the auto-login handler (and the cookie handler when off). A small DI
|
||||
resolution assertion.
|
||||
- **Live proof:** docker-dev `/run` — `/` loads authenticated as `multi-role-test`,
|
||||
`FleetAdmin`-gated pages (e.g. RoleGrants) and `DriverOperator` actions are available
|
||||
with no sign-in. User drives; agent does not sign in (no sign-in needed when the flag is
|
||||
on — that's the point).
|
||||
|
||||
## Out of scope
|
||||
|
||||
- OPC UA server LDAP auth; deploy API key auth.
|
||||
- Configurable role subsets / multiple impersonated users (YAGNI).
|
||||
- Hard environment gating (revisit only if the flag risks leaking to a real deployment).
|
||||
|
||||
## Hard rules
|
||||
|
||||
Stage by explicit path (never `git add .`); never stage `sql_login.txt` /
|
||||
`src/Server/.../Host/pki/`; never echo the gateway API key into a **new** tracked file
|
||||
(the compose edit touches an existing file that already has it); no force-push, no
|
||||
`--no-verify`; **no Configuration entity / EF migration change**. Build on a feature
|
||||
branch off `master`.
|
||||
@@ -0,0 +1,417 @@
|
||||
# AdminUI "Disable Login" Dev Flag — Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development
|
||||
> (or executing-plans) to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Add a `Security:Auth:DisableLogin` config flag that disables AdminUI login —
|
||||
when on, every request is auto-authenticated as `multi-role-test` with all roles — and
|
||||
enable it in docker-dev.
|
||||
|
||||
**Architecture:** A custom `AuthenticationHandler` registered **under the cookie scheme
|
||||
name** when the flag is on (replacing `AddCookie`); it always returns
|
||||
`AuthenticateResult.Success` with an all-roles principal. Because the `FallbackPolicy` +
|
||||
`FleetAdmin` + `DriverOperator` policies all *name the cookie scheme*, they keep working
|
||||
unchanged. `HttpContext.User` is the single source feeding both the HTTP pipeline and the
|
||||
Blazor circuit. Design: `docs/plans/2026-06-11-adminui-disable-login-design.md` (master
|
||||
`78917673`).
|
||||
|
||||
**Tech Stack:** .NET 10, ASP.NET Core cookie/authentication, Blazor Server, xUnit +
|
||||
Shouldly. No bUnit.
|
||||
|
||||
**Hard rules (every task):** stage by explicit path — never `git add .`; never stage
|
||||
`sql_login.txt` or `src/Server/ZB.MOM.WW.OtOpcUa.Host/pki/`; never echo the gateway API
|
||||
key into a **new** tracked file (the compose edit touches an existing file that already
|
||||
has it); no force-push, no `--no-verify`. **No Configuration entity / EF migration
|
||||
change.** Agent does **not** sign in to the AdminUI — when the flag is on, no sign-in is
|
||||
needed (that's the point).
|
||||
|
||||
**Branch:** `feat/adminui-disable-login` off `master @ 78917673`.
|
||||
|
||||
**Verified context (from the brainstorming exploration — do not re-discover):**
|
||||
- Auth is wired in `src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs`
|
||||
→ `AddOtOpcUaAuth` (the **only** `AddAuthentication`/`AddAuthorization` site). Cookie
|
||||
registration is at lines **72–82**; the options binds are at **36–38**; the policy block
|
||||
(`FallbackPolicy`, `DriverOperator`, `FleetAdmin`) is **113–131** and **must stay
|
||||
unchanged**.
|
||||
- Claim type helpers: **`ZbClaimTypes`** (`ZB.MOM.WW.Auth.Abstractions` — same import
|
||||
`AuthEndpoints.cs` uses) — `Name` (== `ClaimTypes.Name`), `Username`, `DisplayName`,
|
||||
`Role` (== `ClaimTypes.Role`). Mirror the principal shape `AuthEndpoints.cs:118-132`
|
||||
builds.
|
||||
- Roles: enum **`AdminRole`** (`src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Enums/AdminRole.cs`)
|
||||
= `Viewer`, `Designer`, `Administrator`; plus the appsettings-only control-plane string
|
||||
**`Operator`** (the `DriverOperator` policy accepts `Operator` or `Administrator`).
|
||||
- Tests live in `tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/` (xUnit + Shouldly), e.g.
|
||||
`OtOpcUaLdapAuthServiceTests.cs`, `CanonicalAdminRolesTests.cs`.
|
||||
|
||||
---
|
||||
|
||||
### Task 0: Branch + baseline
|
||||
|
||||
**Classification:** small · **~2 min** · **Parallelizable with:** none
|
||||
|
||||
**Files:** none (branch + verify only)
|
||||
|
||||
**Steps:**
|
||||
1. `git switch -c feat/adminui-disable-login` (off `master @ 78917673`).
|
||||
2. `dotnet build ZB.MOM.WW.OtOpcUa.slnx` — green baseline. Confirm
|
||||
`tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/` is in the `.slnx`.
|
||||
3. Commit nothing.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `AuthDisableLoginOptions` + centralized role list
|
||||
|
||||
**Classification:** small · **~4 min** · **Parallelizable with:** none
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.Security/Auth/AuthDisableLoginOptions.cs`
|
||||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.Security/Auth/DevAuthRoles.cs`
|
||||
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/DevAuthRolesTests.cs`
|
||||
|
||||
**Step 1 — failing test** (`DevAuthRolesTests`):
|
||||
- `DevAuthRoles.All` contains **every** `AdminRole` enum name (`Viewer`, `Designer`,
|
||||
`Administrator`) **and** `"Operator"`; count == 4; no duplicates.
|
||||
- (Guards the "grant all roles" contract against a future `AdminRole` addition.)
|
||||
|
||||
```csharp
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Auth;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Security.Tests;
|
||||
|
||||
public class DevAuthRolesTests
|
||||
{
|
||||
[Fact]
|
||||
public void All_covers_every_AdminRole_plus_Operator()
|
||||
{
|
||||
foreach (var name in Enum.GetNames<AdminRole>())
|
||||
DevAuthRoles.All.ShouldContain(name);
|
||||
DevAuthRoles.All.ShouldContain("Operator");
|
||||
DevAuthRoles.All.Length.ShouldBe(Enum.GetNames<AdminRole>().Length + 1);
|
||||
DevAuthRoles.All.Distinct().Count().ShouldBe(DevAuthRoles.All.Length);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2 — run, expect fail** (types don't exist).
|
||||
|
||||
**Step 3 — implement:**
|
||||
```csharp
|
||||
// AuthDisableLoginOptions.cs
|
||||
namespace ZB.MOM.WW.OtOpcUa.Security.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Dev/test flag: when <see cref="DisableLogin"/> is true the AdminUI bypasses the login
|
||||
/// form entirely and auto-authenticates every request as <see cref="User"/> with all roles.
|
||||
/// Default OFF. Never enable in production.
|
||||
/// </summary>
|
||||
public sealed class AuthDisableLoginOptions
|
||||
{
|
||||
/// <summary>Configuration section name (<c>Security:Auth</c>).</summary>
|
||||
public const string SectionName = "Security:Auth";
|
||||
|
||||
/// <summary>When true, disable login and auto-authenticate every request. Default false.</summary>
|
||||
public bool DisableLogin { get; set; }
|
||||
|
||||
/// <summary>The username the auto-login principal is minted with. Default "multi-role-test".</summary>
|
||||
public string User { get; set; } = "multi-role-test";
|
||||
}
|
||||
```
|
||||
```csharp
|
||||
// DevAuthRoles.cs
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Security.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// The full canonical role set granted to the auto-login dev principal: every
|
||||
/// <see cref="AdminRole"/> plus the appsettings-only control-plane role "Operator"
|
||||
/// (required by the DriverOperator policy). Centralised so adding an AdminRole
|
||||
/// automatically widens the grant.
|
||||
/// </summary>
|
||||
public static class DevAuthRoles
|
||||
{
|
||||
/// <summary>Operator role string — not an <see cref="AdminRole"/> enum member; used by the DriverOperator policy.</summary>
|
||||
public const string Operator = "Operator";
|
||||
|
||||
/// <summary>All roles granted to the auto-login principal.</summary>
|
||||
public static readonly string[] All =
|
||||
[.. Enum.GetNames<AdminRole>(), Operator];
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4 — run, expect pass. Step 5 — commit** the 3 files by path.
|
||||
|
||||
> Verify the `ZB.MOM.WW.OtOpcUa.Security` csproj already references the `Configuration`
|
||||
> project (it imports `ZB.MOM.WW.OtOpcUa.Configuration` in `ServiceCollectionExtensions.cs`,
|
||||
> so it does). If `AdminRole` isn't resolvable, that's a plan defect — surface it.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: `AutoLoginAuthenticationHandler`
|
||||
|
||||
**Classification:** high-risk · **~5 min** · **Parallelizable with:** none (depends T1)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.Security/Auth/AutoLoginAuthenticationHandler.cs`
|
||||
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AutoLoginAuthenticationHandlerTests.cs`
|
||||
|
||||
**Step 1 — failing tests** (`AutoLoginAuthenticationHandlerTests`). Drive the handler
|
||||
directly (`InitializeAsync` with a scheme + `DefaultHttpContext`, then
|
||||
`AuthenticateAsync()`), then assert on the resulting principal:
|
||||
- Success: `result.Succeeded == true`; `result.Principal!.Identity!.Name == "multi-role-test"`
|
||||
(default) — and `== "custom"` when `AuthDisableLoginOptions.User = "custom"`.
|
||||
- Principal carries a role claim for **every** `DevAuthRoles.All` value;
|
||||
`principal.IsInRole("Administrator")`, `IsInRole("Operator")`, `IsInRole("Viewer")`,
|
||||
`IsInRole("Designer")` all true.
|
||||
- **Policy satisfaction:** build the real policies and assert the principal passes both —
|
||||
`RequireRole("Administrator")` (FleetAdmin) and `RequireRole("Operator","Administrator")`
|
||||
(DriverOperator) via `IAuthorizationService.AuthorizeAsync`. (Construct an
|
||||
`AuthorizationService` through `new ServiceCollection().AddAuthorization(...)` mirroring
|
||||
the policy block, or assert `ClaimsPrincipal.IsInRole` for each required role — the
|
||||
IsInRole assertion is sufficient and simpler; prefer it.)
|
||||
|
||||
Handler-construction helper (modern ASP.NET ctor — **no `ISystemClock`**):
|
||||
```csharp
|
||||
private static AutoLoginAuthenticationHandler CreateHandler(string user = "multi-role-test")
|
||||
{
|
||||
var schemeOpts = new Mock<IOptionsMonitor<AuthenticationSchemeOptions>>();
|
||||
schemeOpts.Setup(o => o.Get(It.IsAny<string>())).Returns(new AuthenticationSchemeOptions());
|
||||
var disableOpts = Options.Create(new AuthDisableLoginOptions { DisableLogin = true, User = user });
|
||||
var handler = new AutoLoginAuthenticationHandler(
|
||||
schemeOpts.Object, NullLoggerFactory.Instance, UrlEncoder.Default, disableOpts);
|
||||
return handler;
|
||||
}
|
||||
// In each test:
|
||||
// await handler.InitializeAsync(
|
||||
// new AuthenticationScheme(CookieAuthenticationDefaults.AuthenticationScheme, null, typeof(AutoLoginAuthenticationHandler)),
|
||||
// new DefaultHttpContext());
|
||||
// var result = await handler.AuthenticateAsync();
|
||||
```
|
||||
|
||||
**Step 2 — run, expect fail** (handler doesn't exist).
|
||||
|
||||
**Step 3 — implement:**
|
||||
```csharp
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.Auth.Abstractions; // ZbClaimTypes (same import AuthEndpoints uses)
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Security.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Auth handler used ONLY when <see cref="AuthDisableLoginOptions.DisableLogin"/> is true.
|
||||
/// Registered under the cookie scheme name, it authenticates EVERY request as the configured
|
||||
/// dev user with all <see cref="DevAuthRoles.All"/> roles — no credential check, no cookie.
|
||||
/// The minted principal mirrors the shape the real login (AuthEndpoints) produces.
|
||||
/// </summary>
|
||||
public sealed class AutoLoginAuthenticationHandler
|
||||
: AuthenticationHandler<AuthenticationSchemeOptions>
|
||||
{
|
||||
private readonly AuthDisableLoginOptions _opts;
|
||||
|
||||
/// <summary>Initializes the handler with the scheme plumbing and the disable-login options.</summary>
|
||||
public AutoLoginAuthenticationHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder,
|
||||
IOptions<AuthDisableLoginOptions> disableLoginOptions)
|
||||
: base(options, logger, encoder)
|
||||
=> _opts = disableLoginOptions.Value;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
var user = string.IsNullOrWhiteSpace(_opts.User) ? "multi-role-test" : _opts.User;
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ZbClaimTypes.Name, user),
|
||||
new(ZbClaimTypes.Username, user),
|
||||
new(ZbClaimTypes.DisplayName, user),
|
||||
};
|
||||
foreach (var role in DevAuthRoles.All)
|
||||
claims.Add(new Claim(ZbClaimTypes.Role, role));
|
||||
|
||||
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
var ticket = new AuthenticationTicket(principal, Scheme.Name);
|
||||
return Task.FromResult(AuthenticateResult.Success(ticket));
|
||||
}
|
||||
}
|
||||
```
|
||||
> `ClaimsIdentity(claims, authenticationType)` — passing the auth-type string makes
|
||||
> `Identity.IsAuthenticated == true` and `Identity.Name` resolve from the `ClaimTypes.Name`
|
||||
> claim. Confirm `ZbClaimTypes.Name == ClaimTypes.Name` (it is — that's why `Identity.Name`
|
||||
> works on the real login path).
|
||||
|
||||
**Step 4 — run targeted tests** (`dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests
|
||||
--filter AutoLoginAuthenticationHandler`), expect pass. **Step 5 — commit** by path.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Branch `AddOtOpcUaAuth` on the flag (+ loud warning)
|
||||
|
||||
**Classification:** high-risk · **~5 min** · **Parallelizable with:** none (depends T1, T2)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs`
|
||||
(bind options near :36-38; replace the `AddAuthentication().AddCookie(...)` at :72-82 with
|
||||
the flag branch; **leave :88-111 cookie PostConfigure and :113-131 policy block
|
||||
untouched**).
|
||||
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AddOtOpcUaAuthWiringTests.cs`
|
||||
|
||||
**Step 1 — failing wiring tests** (`AddOtOpcUaAuthWiringTests`). Build a `ServiceCollection`,
|
||||
add minimal config, call `AddOtOpcUaAuth`, resolve `IAuthenticationSchemeProvider`, and
|
||||
assert the **handler type** registered for the cookie scheme:
|
||||
- `DisableLogin=true` → the cookie scheme's `HandlerType == typeof(AutoLoginAuthenticationHandler)`.
|
||||
- `DisableLogin=false` (or absent) → `HandlerType == typeof(CookieAuthenticationHandler)`.
|
||||
|
||||
```csharp
|
||||
private static async Task<Type> CookieHandlerTypeAsync(bool disableLogin)
|
||||
{
|
||||
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Security:Auth:DisableLogin"] = disableLogin ? "true" : "false",
|
||||
}).Build();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddSingleton<IConfiguration>(config);
|
||||
services.AddDbContext<OtOpcUaConfigDbContext>(o => o.UseInMemoryDatabase("wiring")); // DataProtection PersistKeysToDbContext needs it
|
||||
services.AddOtOpcUaAuth(config);
|
||||
|
||||
var sp = services.BuildServiceProvider();
|
||||
var provider = sp.GetRequiredService<IAuthenticationSchemeProvider>();
|
||||
var scheme = await provider.GetSchemeAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
return scheme!.HandlerType;
|
||||
}
|
||||
```
|
||||
(If `AddDbContext`/InMemory isn't already available to the test project, prefer asserting
|
||||
via the registered `AuthenticationSchemeOptions`/`IConfigureOptions` instead — but the
|
||||
`Security.Tests` project already constructs `OtOpcUaConfigDbContext` in-memory elsewhere,
|
||||
so reuse that. If a DI dependency makes `BuildServiceProvider` throw, that's a plan defect
|
||||
— surface it rather than mocking half the graph.)
|
||||
|
||||
**Step 2 — run, expect fail.**
|
||||
|
||||
**Step 3 — implement.** Add the bind alongside the existing `AddOptions` calls:
|
||||
```csharp
|
||||
services.AddOptions<AuthDisableLoginOptions>().Bind(configuration.GetSection(AuthDisableLoginOptions.SectionName));
|
||||
```
|
||||
Replace lines 72-82 with:
|
||||
```csharp
|
||||
var disableLogin = configuration
|
||||
.GetSection(AuthDisableLoginOptions.SectionName)
|
||||
.GetValue<bool>(nameof(AuthDisableLoginOptions.DisableLogin));
|
||||
|
||||
var authBuilder = services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
if (disableLogin)
|
||||
{
|
||||
// DEV/TEST ONLY: replace the cookie handler with an always-succeeding handler registered
|
||||
// UNDER the cookie scheme name, so FallbackPolicy + FleetAdmin + DriverOperator (which all
|
||||
// name this scheme) authenticate through it and pass with all roles — zero policy changes.
|
||||
authBuilder.AddScheme<AuthenticationSchemeOptions, AutoLoginAuthenticationHandler>(
|
||||
CookieAuthenticationDefaults.AuthenticationScheme, _ => { });
|
||||
|
||||
// Loud, once-at-first-resolve warning (mirrors the cookie RequireHttps warning idiom).
|
||||
services.AddOptions<AuthDisableLoginOptions>()
|
||||
.PostConfigure<ILoggerFactory>((opts, lf) =>
|
||||
lf.CreateLogger("ZB.MOM.WW.OtOpcUa.Security").LogWarning(
|
||||
"AdminUI LOGIN DISABLED (Security:Auth:DisableLogin=true) — every request is " +
|
||||
"authenticated as '{User}' with FULL permissions ({Roles}). Dev/test only; never " +
|
||||
"enable in production.", opts.User, string.Join(",", DevAuthRoles.All)));
|
||||
}
|
||||
else
|
||||
{
|
||||
authBuilder.AddCookie(o =>
|
||||
{
|
||||
o.LoginPath = "/login";
|
||||
o.LogoutPath = "/auth/logout";
|
||||
});
|
||||
}
|
||||
```
|
||||
Add `using ZB.MOM.WW.OtOpcUa.Security.Auth;` and
|
||||
`using Microsoft.AspNetCore.Authentication;`.
|
||||
|
||||
> The cookie-options `PostConfigure` (`AddOptions<CookieAuthenticationOptions>(...)`, :88-111)
|
||||
> stays — it's keyed to the cookie scheme name and is harmless/ignored when the auto-login
|
||||
> handler backs that scheme. Do NOT delete it. DataProtection, LDAP, JWT, and the
|
||||
> `AddAuthorization` policy block all stay exactly as-is.
|
||||
|
||||
**Step 4 — run** (`dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests`), expect pass +
|
||||
no regressions in the existing auth tests. **Step 5 — commit** by path.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: appsettings default + docker-dev enablement
|
||||
|
||||
**Classification:** small · **~3 min** · **Parallelizable with:** Task 2, Task 3 (disjoint files; depends T1 for the section name)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.json` (add the default-off key under
|
||||
`Security`).
|
||||
- Modify: `docker-dev/docker-compose.yml` (enable on `central-1` ~:139 and `central-2` ~:179).
|
||||
|
||||
**Steps:**
|
||||
1. **appsettings.json** — add `"Auth": { "DisableLogin": false }` inside the existing
|
||||
`"Security"` object (verify the section exists; merge, don't duplicate). This documents
|
||||
the key and keeps prod default OFF.
|
||||
2. **docker-compose.yml** — `git status docker-dev/docker-compose.yml` first (it may carry
|
||||
unrelated working-tree edits — leave those alone). With a **targeted `Edit`** (NOT
|
||||
`git add .`), add to the `environment:` block of **both** AdminUI nodes, next to the
|
||||
other `Security__*` keys:
|
||||
```yaml
|
||||
Security__Auth__DisableLogin: "true"
|
||||
```
|
||||
- `central-1` block (~:139, the `&otopcua-host` anchor).
|
||||
- `central-2` block (~:179, it has its own full `environment:` list — add there too).
|
||||
- Do **not** touch the site-* nodes (driver-only, no UI). Do **not** alter the
|
||||
`GALAXY_MXGW_API_KEY` line.
|
||||
3. **Commit** `src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.json` and
|
||||
`docker-dev/docker-compose.yml` **by explicit path**. If the compose file had unrelated
|
||||
pre-existing edits you did not make, stage **only** your hunk is not possible per-line
|
||||
here — instead surface the pre-existing diff to the user and ask before committing the
|
||||
whole file (do not silently bundle someone else's change).
|
||||
|
||||
> No test (config only). Proven by Task 5's live `/run`.
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Live-verify (docker-dev `/run`)
|
||||
|
||||
**Classification:** verification · **Parallelizable with:** none (depends T3, T4)
|
||||
|
||||
**Steps:**
|
||||
1. Rebuild docker-dev so central-1/central-2 pick up the new image + the
|
||||
`Security__Auth__DisableLogin=true` env:
|
||||
`docker compose -f docker-dev/docker-compose.yml up -d --build` (user drives if a sign-in
|
||||
to anything external is needed — but the AdminUI itself needs none now).
|
||||
2. Browse `http://localhost:9200/` — confirm it loads **straight into the app** (no
|
||||
redirect to `/login`) as **`multi-role-test`**: the Account page shows that user with all
|
||||
roles; a `FleetAdmin`-gated page (e.g. **RoleGrants**) renders; a `DriverOperator` action
|
||||
(DriverStatusPanel Reconnect/Restart) is enabled.
|
||||
3. Confirm the **loud warning** is in the central node logs at startup.
|
||||
4. **Agent does not sign in** (none required). Record outcome. Any defect → new fix task.
|
||||
5. After green: run superpowers-extended-cc:finishing-a-development-branch (full
|
||||
`dotnet test`, then merge to master). Note: leave `Security__Auth__DisableLogin=true` in
|
||||
docker-dev (that's the requested dev state).
|
||||
|
||||
---
|
||||
|
||||
## Execution notes
|
||||
|
||||
- **Serial spine:** T0 → T1 → T2 → T3 → T5. **T4 ∥ T2/T3** (disjoint files; only needs the
|
||||
section name from T1).
|
||||
- **One writer:** only T3 touches `ServiceCollectionExtensions.cs`; T1/T2 add new files;
|
||||
T4 touches config files. No shared-file contention.
|
||||
- **Security classification:** T2 + T3 are `high-risk` (they mint an auth principal and
|
||||
rewire the scheme registration) → serial spec→code review. T1/T4 are `small`.
|
||||
- **Checkpoint:** after T3 the flag works in-process (proven by unit tests); T4+T5 land it
|
||||
in docker-dev. Natural pause after T3 before the live run.
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-06-11-adminui-disable-login.md",
|
||||
"designPath": "docs/plans/2026-06-11-adminui-disable-login-design.md",
|
||||
"branch": "feat/adminui-disable-login",
|
||||
"baseBranch": "master",
|
||||
"baseSha": "78917673",
|
||||
"status": "merged-to-master",
|
||||
"mergedSha": "2ad1dbc8",
|
||||
"note": "SHIPPED + merged to master (fast-forward, HEAD 2ad1dbc8; branch deleted). Security:Auth:DisableLogin flag — auto-authenticate AdminUI as multi-role-test with all roles via an AutoLoginAuthenticationHandler registered UNDER the cookie scheme name (so FallbackPolicy + FleetAdmin + DriverOperator keep working unchanged). Enabled in docker-dev central-1/central-2. AdminUI cookie surface only. Live-verified: / loads as multi-role-test (HTTP 200, no /login redirect), FleetAdmin /role-grants 200, loud warning in both node logs. Review caught + fixed a real bug: handler needed no-op IAuthenticationSignInHandler/SignOutHandler or /auth/logout 500s when the flag is on.",
|
||||
"tasks": [
|
||||
{"id": 229, "planTask": 0, "subject": "DL-T0: Branch + baseline", "classification": "small", "status": "completed", "blockedBy": []},
|
||||
{"id": 230, "planTask": 1, "subject": "DL-T1: AuthDisableLoginOptions + centralized role list", "classification": "small", "status": "completed", "commit": "a92ba6a1", "blockedBy": [229]},
|
||||
{"id": 231, "planTask": 2, "subject": "DL-T2: AutoLoginAuthenticationHandler", "classification": "high-risk", "status": "completed", "commit": "caeaae21", "blockedBy": [230]},
|
||||
{"id": 232, "planTask": 3, "subject": "DL-T3: Branch AddOtOpcUaAuth on the flag (+ loud warning)", "classification": "high-risk", "status": "completed", "commit": "82fec753, 2ad1dbc8", "blockedBy": [230, 231]},
|
||||
{"id": 233, "planTask": 4, "subject": "DL-T4: appsettings default + docker-dev enablement", "classification": "small", "status": "completed", "commit": "a27e82c8", "blockedBy": [230], "parallelizableWith": [231, 232]},
|
||||
{"id": 234, "planTask": 5, "subject": "DL-T5: Live-verify (docker-dev /run)", "classification": "verification", "status": "completed", "note": "live-verified on docker-dev 2026-06-11", "blockedBy": [232, 233]}
|
||||
],
|
||||
"lastUpdated": "2026-06-11"
|
||||
}
|
||||
+9
-4
@@ -82,11 +82,16 @@ Engine wiring (subscription publishing, ApplyDelta diff, bad-quality-on-disconne
|
||||
|
||||
## VirtualTagActor / ScriptedAlarmActor
|
||||
|
||||
Skeleton state machines + message handlers. Engine work:
|
||||
Both are fully wired in production (F8 + F9 shipped). `VirtualTagActor` compiles and evaluates expressions; `ScriptedAlarmActor` owns the per-alarm Part 9 state machine and persists `ScriptedAlarmState` to the config DB.
|
||||
|
||||
- `VirtualTagEngine.Evaluate()` not yet called from `VirtualTagActor.DependencyValueChanged` (F8).
|
||||
- `AlarmConditionService` not yet called from `ScriptedAlarmActor` (F9).
|
||||
- `ScriptedAlarmState` DB persistence on `PreRestart` not wired (F9).
|
||||
### alarm-commands topic (inbound operator ack/shelve)
|
||||
|
||||
`ScriptedAlarmHostActor` subscribes to the `alarm-commands` DPS topic. Two surfaces publish onto this topic:
|
||||
|
||||
- **OPC UA Part 9 method path** — `OtOpcUaNodeManager` handles Acknowledge / Confirm / AddComment / OneShotShelve / TimedShelve / Unshelve calls from external OPC UA clients. Each call is gated on the `AlarmAck` LDAP role (fail-closed); on allow, a `Commons.OpcUa.AlarmCommand` is published onto the topic.
|
||||
- **AdminUI `/alerts` path** — `AdminOperationsActor` (cluster singleton) publishes `AcknowledgeAlarmCommand` / `ShelveAlarmCommand` from the AdminUI operator buttons.
|
||||
|
||||
`ScriptedAlarmHostActor` ownership-filters incoming commands (each node acts only on its own alarms) and dispatches to the matching `ScriptedAlarmEngine` operation. The engine's `OnEvent` callback handles the resulting OPC UA condition-node update.
|
||||
|
||||
## OpcUaPublishActor
|
||||
|
||||
|
||||
@@ -0,0 +1,618 @@
|
||||
# F14b — OPC UA Part 9 Alarm & Condition SDK Notes (T13)
|
||||
|
||||
Reference notes for materialising **real** OPC UA Part 9 Alarm & Condition
|
||||
nodes in our `CustomNodeManager2`-based `OtOpcUaNodeManager`, firing events
|
||||
on state transitions, and accepting inbound Acknowledge / Confirm / Shelve /
|
||||
AddComment method calls. This replaces the current placeholder in
|
||||
`OtOpcUaNodeManager.WriteAlarmState`, which only writes a two-element
|
||||
`[active, acknowledged]` `BaseDataVariableState` and fires no events.
|
||||
|
||||
Consumed by implementation tasks **T14–T17**.
|
||||
|
||||
## Provenance (read this first)
|
||||
|
||||
- **DeepWiki MCP: NOT AVAILABLE** in this session. `mcp__deepwiki__*` tools
|
||||
were not surfaced by the tool registry (only Gmail/Calendar/Drive/Chrome
|
||||
MCP servers were). All findings below come from sources 2 and 3 instead.
|
||||
- **Local NuGet cache (authoritative, version-exact).** Every API name,
|
||||
ctor, delegate, and method signature below was confirmed by **decompiling
|
||||
the exact assembly we reference** — `OPCFoundation.NetStandard.Opc.Ua.*`
|
||||
**v1.5.378.106** (pinned in `Directory.Packages.props`), `net10.0` TFM —
|
||||
with `ilspycmd`. The Part 9 condition types live in `Opc.Ua.Core.dll`;
|
||||
`NodeState` / `ISystemContext` / `IUserIdentity` live in `Opc.Ua.Types.dll`;
|
||||
`CustomNodeManager2` lives in `Opc.Ua.Server.dll`.
|
||||
- **GitHub reference sample (pattern-level, master branch).** The end-to-end
|
||||
node-creation / event-firing / handler-wiring patterns were cross-checked
|
||||
against the OPC Foundation reference server at
|
||||
`Applications/Quickstarts.Servers/Alarms/` (the `AlarmHolders/*` classes +
|
||||
`AlarmNodeManager.cs`). **Caveat:** these samples are on `master`, which is
|
||||
slightly ahead of 1.5.378.106 — they use a few newer optional members
|
||||
(e.g. `SilenceState`, `OutOfServiceState`, `LatchedState`,
|
||||
`PropertyState<T>.With<VariantBuilder>`, `ByteString`). The *core* alarm
|
||||
pattern (Create → set state → ReportEvent; OnAcknowledge/OnConfirm
|
||||
delegates) is identical in both. Anything sample-only that I could not
|
||||
also confirm in the 1.5.378.106 assembly is flagged **[SAMPLE-ONLY]** below.
|
||||
|
||||
### What's verified vs. uncertain
|
||||
|
||||
| Claim | Status |
|
||||
|---|---|
|
||||
| Condition/Alarm type names, class hierarchy, ctors | **Verified** in 1.5.378.106 assembly |
|
||||
| `SetActiveState` / `SetAcknowledgedState` / `SetConfirmedState` / `SetEnableState` / `SetSeverity` / `SetShelvingState` / `SetSuppressedState` signatures | **Verified** in assembly |
|
||||
| `OnAcknowledge` / `OnConfirm` / `OnAddComment` / `OnEnableDisable` / `OnShelve` / `OnTimedUnshelve` delegate field types + signatures | **Verified** in assembly |
|
||||
| `NodeState.Create(...)` / `ReportEvent(...)` / `AddNotifier(...)` signatures | **Verified** in assembly |
|
||||
| `CustomNodeManager2.AddRootNotifier` / `SubscribeToEvents` / `ConditionRefresh` / `Call` behaviour | **Verified** by decompiling `CustomNodeManager2` |
|
||||
| Principal surfacing via `ISessionOperationContext.UserIdentity` (`GetCurrentUserId`) | **Verified** in assembly |
|
||||
| End-to-end wiring sequence (folder EventNotifier → AddRootNotifier → alarm.Create → AddChild → ReportEvent) | **Verified** by decompile + cross-checked against reference sample |
|
||||
| No packaged base alarm server class — must hand-roll on `CustomNodeManager2` | **Verified** (no `AlarmConditionServer`/`SampleNodeManager` type in the `Opc.Ua.Server` NuGet) |
|
||||
| `InitializeAlarmConditionEvent` helper | **DOES NOT EXIST** in this SDK — see Q3 |
|
||||
|
||||
---
|
||||
|
||||
## Q1 — Node creation under a `CustomNodeManager2`
|
||||
|
||||
### Type hierarchy (all in namespace `Opc.Ua`, assembly `Opc.Ua.Core`)
|
||||
|
||||
```
|
||||
BaseObjectState
|
||||
└─ BaseEventState (EventId, EventType, SourceNode, SourceName, Time, ReceiveTime, Message, Severity)
|
||||
└─ ConditionState (EnabledState, Retain, ConditionClassId, ConditionName, BranchId, Quality, Comment, AddComment method)
|
||||
└─ AcknowledgeableConditionState (AckedState, ConfirmedState, Acknowledge + Confirm methods)
|
||||
└─ AlarmConditionState (ActiveState, SuppressedState, ShelvingState, Shelve/Unshelve methods, ...)
|
||||
├─ LimitAlarmState (HighLimit / HighHighLimit / LowLimit / LowLowLimit : PropertyState<double>)
|
||||
│ ├─ ExclusiveLimitAlarmState (LimitState : ExclusiveLimitStateMachineState; SetLimitState)
|
||||
│ │ ├─ ExclusiveLevelAlarmState
|
||||
│ │ ├─ ExclusiveDeviationAlarmState
|
||||
│ │ └─ ExclusiveRateOfChangeAlarmState
|
||||
│ └─ NonExclusiveLimitAlarmState (HighState/HighHighState/LowState/LowLowState : TwoStateVariableState; SetLimitState)
|
||||
│ ├─ NonExclusiveLevelAlarmState
|
||||
│ ├─ NonExclusiveDeviationAlarmState
|
||||
│ └─ NonExclusiveRateOfChangeAlarmState
|
||||
├─ DiscreteAlarmState
|
||||
│ ├─ OffNormalAlarmState
|
||||
│ │ ├─ SystemOffNormalAlarmState
|
||||
│ │ └─ TripAlarmState
|
||||
│ └─ (CertificateExpirationAlarmState, etc.)
|
||||
└─ DiscrepancyAlarmState, InstrumentDiagnosticAlarmState, ...
|
||||
```
|
||||
|
||||
These are all **ModelCompiler-generated** (`[GeneratedCode("Opc.Ua.ModelCompiler")]`).
|
||||
Each carries an embedded base64 `InitializationString`: calling `Create(...)`
|
||||
builds the **entire mandatory child set** (EnabledState, AckedState,
|
||||
ConfirmedState, ActiveState, the Acknowledge/Confirm/AddComment/Enable/Disable
|
||||
methods, etc.) automatically. You do **not** hand-build child variables/methods.
|
||||
|
||||
### Constructors (verified)
|
||||
|
||||
`AlarmConditionState` (and only it, among these) has **two** ctors:
|
||||
|
||||
```csharp
|
||||
public AlarmConditionState(NodeState parent) // classic
|
||||
public AlarmConditionState(ITelemetryContext telemetry, NodeState parent) // 1.5.378 telemetry-aware
|
||||
```
|
||||
|
||||
`ConditionState`, `AcknowledgeableConditionState`, `LimitAlarmState`,
|
||||
`ExclusiveLimitAlarmState`, `NonExclusiveLimitAlarmState`, `OffNormalAlarmState`,
|
||||
etc. expose only the **`(NodeState parent)`** ctor in 1.5.378.106.
|
||||
|
||||
> **Recommendation:** prefer the telemetry ctor for `AlarmConditionState`
|
||||
> when a telemetry context is reachable (`Server.Telemetry`), matching the
|
||||
> reference sample (`new AlarmConditionState(Server.Telemetry, parent)`).
|
||||
> The `(NodeState parent)` ctor is fine and is the only option for the
|
||||
> non-alarm condition types.
|
||||
|
||||
### The create-and-parent pattern (verified shape, from `AlarmHolder.InitializeInternal`)
|
||||
|
||||
```csharp
|
||||
// 'parent' is the owning ObjectState/FolderState already in the address space.
|
||||
// 'trigger' is the source variable the alarm reports against (optional).
|
||||
var alarm = new AlarmConditionState(Server.Telemetry, parent);
|
||||
|
||||
var nodeId = new NodeId($"{parentId}.{alarmName}", NamespaceIndex);
|
||||
var browseName = new QualifiedName(alarmName, NamespaceIndex);
|
||||
var displayName = new LocalizedText(alarmName);
|
||||
|
||||
alarm.SymbolicName = alarmName;
|
||||
alarm.ReferenceTypeId = ReferenceTypeIds.HasComponent; // how the parent links to it
|
||||
|
||||
// Builds the full mandatory child tree from the embedded type definition.
|
||||
alarm.Create(
|
||||
SystemContext, // ISystemContext
|
||||
nodeId, // NodeId (or null/default to auto-assign)
|
||||
browseName, // QualifiedName
|
||||
displayName, // LocalizedText
|
||||
assignNodeIds: true);
|
||||
|
||||
// (optional) point the source variable at the condition
|
||||
trigger.AddReference(ReferenceTypeIds.HasCondition, isInverse: false, alarm.NodeId);
|
||||
|
||||
parent.AddChild(alarm); // wires the parent→alarm reference
|
||||
AddPredefinedNode(SystemContext, alarm); // registers node + all children with the manager
|
||||
```
|
||||
|
||||
`NodeState.Create` signature (verified):
|
||||
```csharp
|
||||
public virtual void Create(ISystemContext context, NodeId nodeId,
|
||||
QualifiedName browseName, LocalizedText displayName,
|
||||
bool assignNodeIds)
|
||||
```
|
||||
|
||||
> **Important:** `AddPredefinedNode(SystemContext, alarm)` registers the alarm
|
||||
> **and all the children `Create` minted** into `PredefinedNodes`, so browse /
|
||||
> read / method-call routing all work. Our existing `EnsureFolder` /
|
||||
> `EnsureVariable` already use `AddPredefinedNode`; the alarm node is the same
|
||||
> call. When tearing down, mirror `RebuildAddressSpace`: `parent.RemoveChild`
|
||||
> + remove from `PredefinedNodes`.
|
||||
|
||||
### What makes a node an event-notifier (so clients can subscribe to its events)
|
||||
|
||||
Two things, both required:
|
||||
|
||||
1. The **notifier object** (typically the folder/area the alarms live under,
|
||||
or our `_root`) must set
|
||||
`EventNotifier = EventNotifiers.SubscribeToEvents` and be registered as a
|
||||
**root notifier**:
|
||||
|
||||
```csharp
|
||||
alarmsFolder.EventNotifier = EventNotifiers.SubscribeToEvents;
|
||||
AddRootNotifier(alarmsFolder); // protected, on CustomNodeManager2
|
||||
```
|
||||
|
||||
`AddRootNotifier` (verified) does three things: sets
|
||||
`notifier.OnReportEvent = OnReportEvent` (which forwards to
|
||||
`Server.ReportEvent`), adds an **inverse `HasNotifier` reference to the
|
||||
Server object** (so the Server object becomes the well-known event entry
|
||||
point clients subscribe to), and back-fills any existing
|
||||
"monitor all events" subscriptions.
|
||||
|
||||
2. The condition node must have a **notifier path up to that root notifier**.
|
||||
`alarm.Create(...)` under a parent that is itself a root notifier is the
|
||||
simplest path; `ReportEvent` walks **inverse** notifier references upward
|
||||
(see Q3). If a condition is several folders deep, each intermediate folder
|
||||
needs `EventNotifier = SubscribeToEvents` and an `AddNotifier` /
|
||||
`HasNotifier` link, OR just make each alarm folder its own root notifier.
|
||||
|
||||
`NodeState.AddNotifier` (verified):
|
||||
```csharp
|
||||
public virtual void AddNotifier(ISystemContext context, NodeId referenceTypeId,
|
||||
bool isInverse, NodeState target)
|
||||
// referenceTypeId null ⇒ HasEventSource. Use HasNotifier for object→object notifier chains.
|
||||
```
|
||||
|
||||
> **Simplest robust design for us:** make the single OtOpcUa alarm-root folder
|
||||
> a root notifier (`SubscribeToEvents` + `AddRootNotifier`) and `Create` every
|
||||
> alarm condition directly (or via plain folders that are themselves root
|
||||
> notifiers) under it. Don't over-engineer the notifier hierarchy until a
|
||||
> deep-tree requirement appears.
|
||||
|
||||
---
|
||||
|
||||
## Q2 — State setters (which take an `ISystemContext`)
|
||||
|
||||
All the `Set*` mutators below are on the condition/alarm types and **take an
|
||||
`ISystemContext` first arg** — pass the manager's `SystemContext` (a
|
||||
`ServerSystemContext`) or a copy of it. They internally update the relevant
|
||||
`TwoStateVariableState` (`.Id.Value`, `.Value` true/false text,
|
||||
`TransitionTime`) and flag change masks.
|
||||
|
||||
| Goal | API (verified signature) | Defined on |
|
||||
|---|---|---|
|
||||
| Active / inactive | `void SetActiveState(ISystemContext context, bool active)` | `AlarmConditionState` |
|
||||
| Acked / unacked | `void SetAcknowledgedState(ISystemContext context, bool acknowledged)` | `AcknowledgeableConditionState` |
|
||||
| Confirmed / unconfirmed | `void SetConfirmedState(ISystemContext context, bool confirmed)` | `AcknowledgeableConditionState` |
|
||||
| Enabled / disabled | `void SetEnableState(ISystemContext context, bool enabled)` | `ConditionState` |
|
||||
| Suppressed | `void SetSuppressedState(ISystemContext context, bool suppressed)` | `AlarmConditionState` |
|
||||
| Shelving (all 3 transitions) | `void SetShelvingState(ISystemContext context, bool shelved, bool oneShot, double shelvingTime)` | `AlarmConditionState` |
|
||||
| Severity | `void SetSeverity(ISystemContext context, EventSeverity severity)` | `ConditionState` |
|
||||
| Comment (+ user) | `void SetComment(ISystemContext context, LocalizedText comment, string clientUserId)` | `ConditionState` |
|
||||
| Exclusive limit zone | `void SetLimitState(ISystemContext context, LimitAlarmStates limit)` | `ExclusiveLimitAlarmState` |
|
||||
| Non-exclusive limit zone | `void SetLimitState(ISystemContext context, LimitAlarmStates limit)` | `NonExclusiveLimitAlarmState` |
|
||||
|
||||
**`SupportsConfirm()`** (verified, on `AcknowledgeableConditionState`) gates
|
||||
whether the Confirm sub-state machine is active. The SDK auto-decides this from
|
||||
whether `ConfirmedState` was materialised. To enable Confirm you must have a
|
||||
`ConfirmedState` child + call `SetConfirmedState` during init.
|
||||
|
||||
### Properties you set directly (not via a `Set*` helper)
|
||||
|
||||
These are inherited `PropertyState<T>` nodes; assign `.Value` and (for event
|
||||
fields) let `ReportEvent` snapshot them:
|
||||
|
||||
| Property | Type (verified) | Notes |
|
||||
|---|---|---|
|
||||
| `Message` | `PropertyState<LocalizedText>` | event message text |
|
||||
| `Severity` | `PropertyState<ushort>` | prefer `SetSeverity` (also stamps `LastSeverity`) |
|
||||
| `Time` | `PropertyState<DateTime>` | event source time (UTC); set per event in `ReportEvent` |
|
||||
| `ReceiveTime` | `PropertyState<DateTime>` | usually `= Time.Value` |
|
||||
| `EventId` | `PropertyState<byte[]>` | **new GUID-bytes per event** (see Q3) |
|
||||
| `EventType` | `PropertyState<NodeId>` | set by `Create` from the type; can override |
|
||||
| `SourceNode` / `SourceName` | `PropertyState<NodeId>` / `PropertyState<string>` | the originating tag |
|
||||
| `Retain` | `PropertyState<bool>` | **drives ConditionRefresh replay** (see Q5) |
|
||||
| `ConditionClassId` | `PropertyState<NodeId>` | e.g. `ObjectTypeIds.ProcessConditionClassType` |
|
||||
| `ConditionName` | `PropertyState<string>` | human name of the condition |
|
||||
| `BranchId` | `PropertyState<NodeId>` | `new NodeId()` (null) for the main branch |
|
||||
| `Quality` | `PropertyState<StatusCode>` | usually `StatusCodes.Good` |
|
||||
| `EnabledState` | `TwoStateVariableState` | set via `SetEnableState`, not directly |
|
||||
|
||||
`EventSeverity` is the `Opc.Ua.EventSeverity` enum (`Low`/`Medium`/`High`/...);
|
||||
`SetSeverity` maps it to the `ushort Severity` value and stamps `LastSeverity`.
|
||||
|
||||
---
|
||||
|
||||
## Q3 — Firing an event on a state transition
|
||||
|
||||
### There is NO `InitializeAlarmConditionEvent` in this SDK
|
||||
|
||||
The method named in the task brief **does not exist** in 1.5.378.106. The real
|
||||
firing path is: set the per-event fields, then call `ReportEvent` passing a
|
||||
**filter target** that is a snapshot of the node.
|
||||
|
||||
`NodeState.ReportEvent` (verified):
|
||||
```csharp
|
||||
public virtual void ReportEvent(ISystemContext context, IFilterTarget e)
|
||||
```
|
||||
|
||||
The reference sample's verbatim pattern (from `ConditionTypeHolder.ReportEvent`):
|
||||
|
||||
```csharp
|
||||
if (alarm.EnabledState.Id.Value) // only fire if enabled
|
||||
{
|
||||
alarm.EventId.Value = Uuid.NewUuid().ToByteString(); // unique per event
|
||||
alarm.Time.Value = DateTime.UtcNow;
|
||||
alarm.ReceiveTime.Value = alarm.Time.Value;
|
||||
// (Message / Severity / ActiveState / AckedState already set by the Set* calls)
|
||||
|
||||
alarm.ClearChangeMasks(SystemContext, includeChildren: true);
|
||||
|
||||
var snapshot = new InstanceStateSnapshot();
|
||||
snapshot.Initialize(SystemContext, alarm);
|
||||
alarm.ReportEvent(SystemContext, snapshot); // IFilterTarget = InstanceStateSnapshot
|
||||
}
|
||||
```
|
||||
|
||||
Key points:
|
||||
|
||||
- **`InstanceStateSnapshot`** is the `IFilterTarget` — a frozen copy of the
|
||||
condition's fields at fire time, so the event a client receives reflects the
|
||||
values *at that instant* even if the live node mutates afterwards. `Initialize`
|
||||
reads the node's children into the snapshot.
|
||||
- **A fresh `EventId` per event** is mandatory — Acknowledge/Confirm/AddComment
|
||||
inbound calls are correlated back to a specific event by this `EventId`
|
||||
(`GetEventByEventId` / `GetBranch`). Reusing one breaks ack routing.
|
||||
- `ReportEvent` then walks **inverse notifier references upward**: each notifier
|
||||
in the chain re-reports, and the root notifier's `OnReportEvent` hands off to
|
||||
`Server.ReportEvent`, which queues the event to subscribed monitored items.
|
||||
- **Typical transition flow:** `SetActiveState`/`SetAcknowledgedState`/... →
|
||||
set `Message`/`Severity` → set `Retain` (active or unacked ⇒ `true`) →
|
||||
`ReportEvent`.
|
||||
|
||||
### Threading / locking
|
||||
|
||||
- **Every node-manager mutation must hold the manager's `Lock`** (we already do
|
||||
this in `OtOpcUaNodeManager` — see `WriteValue`/`EnsureFolder`). Creating the
|
||||
alarm, the `Set*` calls, and `ReportEvent` must all run **under `lock (Lock)`**.
|
||||
`CustomNodeManager2.AddRootNotifier`, `ConditionRefresh`, etc. all take `Lock`
|
||||
internally; if you call them while *already* holding `Lock` that's fine (it's
|
||||
the same monitor — `Lock` is a plain `object` used with C# `lock`, which is
|
||||
re-entrant on the same thread).
|
||||
- `ReportEvent` itself does **not** require any extra lock beyond the manager
|
||||
`Lock` you already hold for the mutation; the Server's event queue is
|
||||
internally synchronised.
|
||||
|
||||
---
|
||||
|
||||
## Q4 — Inbound method calls (Acknowledge / Confirm / AddComment / Shelve)
|
||||
|
||||
### You do NOT route methods by hand
|
||||
|
||||
`alarm.Create(...)` materialises the standard Part 9 methods (`Acknowledge`,
|
||||
`Confirm`, `AddComment`, `Enable`, `Disable`, `OneShotShelve`, `TimedShelve`,
|
||||
`Unshelve`) as `MethodState` children, and the condition types **wire their own
|
||||
`OnCall` handlers in `OnAfterCreate`** (verified):
|
||||
|
||||
```csharp
|
||||
// AcknowledgeableConditionState.OnAfterCreate:
|
||||
Acknowledge.OnCall = OnAcknowledgeCalled;
|
||||
Confirm.OnCall = OnConfirmCalled;
|
||||
// ConditionState.OnAfterCreate:
|
||||
AddComment.OnCall = OnAddCommentCalled;
|
||||
```
|
||||
|
||||
When a client calls `Acknowledge`, the path is:
|
||||
`MasterNodeManager.Call` → `CustomNodeManager2.Call(...)` (verified: routes to
|
||||
`MethodState.OnCall`) → `AcknowledgeableConditionState.OnAcknowledgeCalled(...)`.
|
||||
The built-in `OnAcknowledgeCalled` (verified body) already does **all of this**
|
||||
for you:
|
||||
|
||||
1. `ProcessBeforeAcknowledge` (validates `eventId`, checks `EnabledState`,
|
||||
**invokes your `OnAcknowledge` delegate** for veto/permission — see below);
|
||||
2. resolves the branch via `GetAcknowledgeableBranch(eventId)`;
|
||||
3. `SetAcknowledgedState(context, true)` (+ `SetConfirmedState(false)` if it
|
||||
supports confirm);
|
||||
4. `SetComment(context, comment, GetCurrentUserId(context))`;
|
||||
5. `UpdateRetainState()`;
|
||||
6. if events are monitored, `ReportStateChange(...)` **(fires the condition
|
||||
event for you)** plus an `AuditConditionAcknowledgeEventState` audit event.
|
||||
|
||||
So for a basic implementation you **don't even need to fire an event after an
|
||||
ack** — the SDK does it. You only supply the **veto/permission/business
|
||||
delegates**.
|
||||
|
||||
### Delegate hooks (the wiring point) — verified field types + signatures
|
||||
|
||||
| Hook field | Delegate type | Signature |
|
||||
|---|---|---|
|
||||
| `AcknowledgeableConditionState.OnAcknowledge` | `ConditionAddCommentEventHandler` | `(ISystemContext context, ConditionState condition, byte[] eventId, LocalizedText comment) → ServiceResult` |
|
||||
| `AcknowledgeableConditionState.OnConfirm` | `ConditionAddCommentEventHandler` | same as above |
|
||||
| `ConditionState.OnAddComment` | `ConditionAddCommentEventHandler` | same as above |
|
||||
| `ConditionState.OnEnableDisable` | `ConditionEnableEventHandler` | `(ISystemContext, ConditionState, bool enabling) → ServiceResult` |
|
||||
| `AlarmConditionState.OnShelve` | `AlarmConditionShelveEventHandler` | `(ISystemContext, AlarmConditionState alarm, bool shelving, bool oneShot, double shelvingTime) → ServiceResult` |
|
||||
| `AlarmConditionState.OnTimedUnshelve` | `AlarmConditionTimedUnshelveEventHandler` | `(ISystemContext, AlarmConditionState alarm) → ServiceResult` |
|
||||
|
||||
> Note: `OnAcknowledge`, `OnConfirm`, and `OnAddComment` **all use the same
|
||||
> delegate type** `ConditionAddCommentEventHandler`
|
||||
> `(context, condition, eventId, comment)`, matching the task brief exactly.
|
||||
> There is **no** separate `AcknowledgeMethodHandler` / `ConfirmMethodHandler`
|
||||
> type — those are not in this SDK.
|
||||
|
||||
Wire them right after `Create` (and after setting initial state):
|
||||
|
||||
```csharp
|
||||
alarm.OnAcknowledge = OnAcknowledgeHandler; // ServiceResult OnAcknowledgeHandler(ISystemContext ctx, ConditionState c, byte[] eventId, LocalizedText comment)
|
||||
alarm.OnConfirm = OnConfirmHandler;
|
||||
alarm.OnAddComment = OnAddCommentHandler;
|
||||
alarm.OnEnableDisable = OnEnableDisableHandler;
|
||||
alarm.OnShelve = OnShelveHandler; // only if shelving is supported
|
||||
alarm.OnTimedUnshelve = OnTimedUnshelveHandler;
|
||||
```
|
||||
|
||||
Returning a **bad `ServiceResult`** (e.g.
|
||||
`StatusCodes.BadUserAccessDenied`, `StatusCodes.BadConditionBranchAlreadyAcked`,
|
||||
`StatusCodes.BadEventIdUnknown`) **vetoes** the operation — the SDK aborts the
|
||||
state change and returns that status to the client. This is the natural seam for
|
||||
our **AlarmAck permission gate** (T17): check the caller's role in
|
||||
`OnAcknowledge`, return `BadUserAccessDenied` if they lack `AlarmAck`.
|
||||
|
||||
### How the calling principal reaches the handler
|
||||
|
||||
The `ISystemContext context` passed into the handler is a `ServerSystemContext`
|
||||
(`: SessionSystemContext`) carrying the session's identity. The SDK's own
|
||||
`ConditionState.GetCurrentUserId` (verified) reads it as:
|
||||
|
||||
```csharp
|
||||
if (context is ISessionOperationContext { UserIdentity: not null } s)
|
||||
return s.UserIdentity.DisplayName; // IUserIdentity
|
||||
```
|
||||
|
||||
So in our handlers:
|
||||
|
||||
```csharp
|
||||
var identity = (context as ISessionOperationContext)?.UserIdentity; // Opc.Ua.IUserIdentity
|
||||
// identity.DisplayName : string
|
||||
// identity.GrantedRoleIds : NodeIdCollection ← use for the AlarmAck gate
|
||||
// identity.TokenType : UserTokenType
|
||||
```
|
||||
|
||||
`IUserIdentity` (verified) exposes `DisplayName`, `TokenType`, `IssuedTokenType`,
|
||||
and **`GrantedRoleIds` (`NodeIdCollection`)** — the latter is what the AlarmAck
|
||||
gate should check (or carry our own claims via the identity we set during
|
||||
authentication). `ServerSystemContext` is constructed from the `OperationContext`
|
||||
/ `ISession` (`UserIdentity = session.Identity`), so the principal is whoever
|
||||
authenticated the calling session via our `IOpcUaUserAuthenticator` /
|
||||
`LdapOpcUaUserAuthenticator`.
|
||||
|
||||
> The reference sample also uses `context.UserId` (a `string`) inside handlers —
|
||||
> that resolves on the concrete `SystemContext`/`IOperationContext`. Prefer the
|
||||
> `ISessionOperationContext.UserIdentity` cast above: it's what the SDK itself
|
||||
> uses and gives the full `IUserIdentity` (roles), not just a display string.
|
||||
|
||||
### Shelve specifics
|
||||
|
||||
- Client `OneShotShelve` / `TimedShelve` / `Unshelve` methods call into
|
||||
`AlarmConditionState.OnOneShotShelve` / `OnTimedShelve` / `OnUnshelve`, which
|
||||
invoke your `OnShelve` (with `shelving`/`oneShot`/`shelvingTime` flags) and
|
||||
`OnTimedUnshelve` delegates, then call `SetShelvingState`.
|
||||
- The `ShelvingState` is a `ShelvedStateMachineState` child; for timed shelve
|
||||
the SDK runs an internal timer (`UnshelveTimeUpdateRate`,
|
||||
`OnTimerExpired`/`OnUnshelveTimeUpdate`) that auto-unshelves. `UnshelveTime`
|
||||
is a calculated property (read handler `OnReadUnshelveTime`).
|
||||
- **[SAMPLE-ONLY]** `SilenceState`, `OutOfServiceState`, `LatchedState`,
|
||||
`MaxTimeShelved` are created as **optional** children in the master sample.
|
||||
In 1.5.378.106 the `ShelvingState`/`SuppressedState`/`OutOfServiceState`/
|
||||
`SilenceState`/`LatchedState` properties **exist** on `AlarmConditionState`,
|
||||
but you must materialise the optional ones yourself before `Create`
|
||||
(e.g. `alarm.ShelvingState = new ShelvedStateMachineState(alarm)` then
|
||||
`ShelvingState.Create(...)`). For our first pass we likely only need
|
||||
ActiveState + AckedState + (optional) ConfirmedState + ShelvingState.
|
||||
|
||||
---
|
||||
|
||||
## Q5 — Gotchas / threading / ConditionRefresh / base server
|
||||
|
||||
### Dispatcher / threading
|
||||
- All node-manager mutations under **`lock (Lock)`** (already our convention).
|
||||
Build + `Create` + `Set*` + `ReportEvent` for one transition in a single
|
||||
critical section so a client never observes a half-applied state.
|
||||
- `CustomNodeManager2.Lock` is a plain `object` used with `lock` — re-entrant
|
||||
on the same thread, so calling `AddRootNotifier`/`ConditionRefresh` while
|
||||
holding it is safe.
|
||||
|
||||
### EventId / branches
|
||||
- **Fresh `EventId` per `ReportEvent`** (GUID bytes). Inbound
|
||||
Acknowledge/Confirm carry the `eventId` of the event being acked; the SDK
|
||||
matches it via `GetEventByEventId` / `GetAcknowledgeableBranch` /
|
||||
`GetBranch(eventId)`.
|
||||
- **Branches** (`CreateBranch(context, branchId)` →
|
||||
`ConditionState`) model "a previous unacked occurrence while a new one is
|
||||
already active". They are **optional** — only needed if we want the
|
||||
ack-the-old-while-new-is-active semantics. **For T14–T17 we can skip
|
||||
branching initially** (main branch `BranchId = new NodeId()` / null) and add
|
||||
it later; our `Part9StateMachine` doesn't model branches today.
|
||||
|
||||
### ConditionRefresh (must work, or clients can't recover retained state)
|
||||
- When a client (re)subscribes to events and calls the `ConditionRefresh`
|
||||
method, `CustomNodeManager2.ConditionRefresh` (verified) iterates the
|
||||
monitored items, calls `node.ConditionRefresh(context, events, true)` on each
|
||||
root notifier (or the specific source node), collects the retained condition
|
||||
events, and queues a refresh batch to that monitored item.
|
||||
- For this to replay our active/unacked alarms, **each live condition must have
|
||||
`Retain.Value == true`** while it should appear in a refresh (Part 9 rule:
|
||||
retain while active OR unacknowledged OR unconfirmed). The built-in
|
||||
`UpdateRetainState`/`GetRetainState` handles this if you keep ack/active state
|
||||
in sync; if you compute retain yourself, set `alarm.Retain.Value` on every
|
||||
transition. **`CustomNodeManager2.ConditionRefresh` is `public virtual` and
|
||||
already implemented** — we get refresh "for free" as long as the alarms are
|
||||
registered (`AddPredefinedNode`) and reachable from a root notifier with
|
||||
correct `Retain`.
|
||||
|
||||
### Is there a base alarm server to lean on? — **No (in NuGet).**
|
||||
- The `OPCFoundation.NetStandard.Opc.Ua.Server` **NuGet package** contains
|
||||
**no** `AlarmConditionServer`, `SampleNodeManager`, or alarm-holder helper.
|
||||
Confirmed: the only alarm/condition type in `Opc.Ua.Server` is the interface
|
||||
`IConditionRefreshAsyncNodeManager`. The `AlarmConditionServer` /
|
||||
`Quickstarts.Servers` code lives **only in the GitHub repo's
|
||||
`Applications/` tree**, which is *not* packaged.
|
||||
- **Therefore we hand-roll on `CustomNodeManager2`** (which we already
|
||||
subclass). The base class gives us everything load-bearing:
|
||||
- `Call` → routes client method calls to `MethodState.OnCall` (so
|
||||
Acknowledge/Confirm/AddComment/Shelve "just work" once `Create` runs);
|
||||
- `SubscribeToEvents` / `SubscribeToAllEvents` → flips
|
||||
`node.SetAreEventsMonitored` so `EventsMonitored()` gates audit-event
|
||||
emission;
|
||||
- `AddRootNotifier` / `RemoveRootNotifier` → notifier wiring to the Server
|
||||
object;
|
||||
- `ConditionRefresh` → retained-event replay;
|
||||
- `AddPredefinedNode` → node registration (we use it already).
|
||||
- The reference `AlarmHolders/*` classes are a **good pattern reference to
|
||||
copy selectively** (Create → set state → ReportEvent; delegate wiring), but
|
||||
they're tangled with the sample's controllers/branching/optional-children
|
||||
scaffolding we don't need. Lift the *shape*, not the whole hierarchy.
|
||||
|
||||
---
|
||||
|
||||
## Mapping to our existing domain model (for T14–T17)
|
||||
|
||||
We already have a **pure** Part 9 state machine and domain records in
|
||||
`src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/`:
|
||||
`Part9StateMachine` (`ApplyAcknowledge`/`ApplyConfirm`/`ApplyOneShotShelve`/
|
||||
`ApplyTimedShelve`/`ApplyUnshelve`/`ApplyEnable`/`ApplyDisable`/`ApplyAddComment`/
|
||||
`ApplyShelvingCheck`) and the `AlarmConditionState` / `ShelvingState` /
|
||||
`AlarmComment` records.
|
||||
|
||||
**The SDK node is a *projection* of that domain state, not a second source of
|
||||
truth.** Recommended split:
|
||||
|
||||
- **Inbound (client → us):** keep the SDK delegates *thin*. In
|
||||
`OnAcknowledge`/`OnConfirm`/`OnShelve`/etc., (a) run the **AlarmAck permission
|
||||
gate** off `ISessionOperationContext.UserIdentity`, returning a bad
|
||||
`ServiceResult` to veto if denied; (b) translate to the matching
|
||||
`Part9StateMachine.Apply*` call against our domain state; (c) let the
|
||||
resulting domain transition drive the authoritative store. You can either let
|
||||
the SDK's built-in `OnAcknowledgeCalled` post-processing apply the SDK-side
|
||||
state change + event, or set state yourself — but don't double-fire.
|
||||
- **Outbound (engine → clients):** when our `ScriptedAlarmEngine` produces a new
|
||||
`AlarmConditionState` (active/severity/ack changes), project it onto the SDK
|
||||
node: `SetActiveState` / `SetAcknowledgedState` / `SetConfirmedState` /
|
||||
`SetSeverity` / set `Message` + `Retain`, then `ReportEvent` — all under
|
||||
`lock (Lock)`.
|
||||
- This replaces `OtOpcUaNodeManager.WriteAlarmState`'s `[active, ack]` boolean
|
||||
pair with a real `AlarmConditionState` node keyed by the same alarm node id.
|
||||
|
||||
---
|
||||
|
||||
## Minimal end-to-end sketch (what T14–T16 build toward)
|
||||
|
||||
```csharp
|
||||
// --- one-time, in CreateAddressSpace or first alarm registration (under Lock) ---
|
||||
_alarmsFolder.EventNotifier = EventNotifiers.SubscribeToEvents;
|
||||
AddRootNotifier(_alarmsFolder);
|
||||
|
||||
// --- register one alarm condition (under Lock) ---
|
||||
var alarm = new AlarmConditionState(Server.Telemetry, _alarmsFolder);
|
||||
alarm.SymbolicName = name;
|
||||
alarm.ReferenceTypeId = ReferenceTypeIds.HasComponent;
|
||||
alarm.Create(SystemContext, new NodeId($"{parentId}.{name}", NamespaceIndex),
|
||||
new QualifiedName(name, NamespaceIndex), new LocalizedText(name), true);
|
||||
|
||||
alarm.ConditionName.Value = name;
|
||||
alarm.ConditionClassId.Value = ObjectTypeIds.BaseConditionClassType; // or a specific class
|
||||
alarm.SetEnableState(SystemContext, true);
|
||||
alarm.SetActiveState(SystemContext, false);
|
||||
alarm.SetAcknowledgedState(SystemContext, true);
|
||||
alarm.Retain.Value = false;
|
||||
|
||||
alarm.OnAcknowledge = OnAck; // permission gate + domain Apply
|
||||
alarm.OnConfirm = OnConfirm;
|
||||
alarm.OnAddComment = OnAddComment;
|
||||
alarm.OnEnableDisable = OnEnableDisable;
|
||||
|
||||
_alarmsFolder.AddChild(alarm);
|
||||
AddPredefinedNode(SystemContext, alarm);
|
||||
_alarms[alarmNodeId] = alarm;
|
||||
|
||||
// --- on an engine-driven transition (under Lock) ---
|
||||
alarm.SetActiveState(SystemContext, active: true);
|
||||
alarm.SetAcknowledgedState(SystemContext, acknowledged: false);
|
||||
alarm.SetSeverity(SystemContext, EventSeverity.High);
|
||||
alarm.Message.Value = new LocalizedText("en", "High temperature");
|
||||
alarm.Retain.Value = true; // active|unacked ⇒ retain
|
||||
alarm.EventId.Value = Uuid.NewUuid().ToByteString();
|
||||
alarm.Time.Value = alarm.ReceiveTime.Value = DateTime.UtcNow;
|
||||
alarm.ClearChangeMasks(SystemContext, true);
|
||||
var snap = new InstanceStateSnapshot();
|
||||
snap.Initialize(SystemContext, alarm);
|
||||
alarm.ReportEvent(SystemContext, snap);
|
||||
|
||||
// --- inbound ack handler (called by SDK Call routing; on a server thread) ---
|
||||
ServiceResult OnAck(ISystemContext ctx, ConditionState c, byte[] eventId, LocalizedText comment)
|
||||
{
|
||||
var id = (ctx as ISessionOperationContext)?.UserIdentity;
|
||||
if (!HasAlarmAck(id)) return StatusCodes.BadUserAccessDenied; // T17 gate
|
||||
// translate to Part9StateMachine.ApplyAcknowledge(...) against domain state
|
||||
return ServiceResult.Good; // returning Good lets the SDK set acked state + fire the event
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Open uncertainties to validate during T14–T17
|
||||
|
||||
1. **Telemetry ctor vs classic ctor at runtime.** We verified both ctors exist;
|
||||
confirm `Server.Telemetry` (`ITelemetryContext`) is non-null in our host
|
||||
before relying on the telemetry ctor — fall back to `new AlarmConditionState(parent)`
|
||||
if not. (Not load-bearing; pick whichever the host supports.)
|
||||
2. **Optional children before `Create`.** ~~Whether `ShelvingState` /
|
||||
`ConfirmedState` are auto-created by `Create` or must be instantiated first.~~
|
||||
**RESOLVED in T14 (real-server integration test, 1.5.378.106):** `Create`
|
||||
auto-builds the **full** optional Part 9 child set from the embedded type
|
||||
definition with **no** pre-setting — for `OffNormalAlarmState`, both
|
||||
`ConfirmedState` AND `ShelvingState` come back **non-null** after `Create`
|
||||
(richer than the `[SAMPLE-ONLY]` caveat predicted). So T15/T16 can call
|
||||
`SetConfirmedState` / `SetShelvingState` directly; no manual child
|
||||
materialisation is needed. **Gotcha also found:** `BranchId.Value` is left a
|
||||
**null reference** by `Create`, and the very first `Set*` call
|
||||
(`SetEnableState` → `UpdateRetainState` → `GetRetainState` → `IsBranch()`)
|
||||
**NREs** on it. Fix: set `alarm.BranchId.Value = NodeId.Null` (the main
|
||||
branch) **before** any `Set*` call. T14's `MaterialiseAlarmCondition` does
|
||||
this. (Covered by `SdkAddressSpaceSinkTests.MaterialiseAlarmCondition_*`.)
|
||||
3. **`InstanceStateSnapshot` vs reporting the node directly.** The sample uses
|
||||
an `InstanceStateSnapshot` as the `IFilterTarget`. Confirm whether reporting
|
||||
the alarm node itself (which is also an `IFilterTarget`) is acceptable — the
|
||||
snapshot is safer (frozen values) and is the documented pattern; default to it.
|
||||
4. **Double-event on inbound ack.** The built-in `OnAcknowledgeCalled` already
|
||||
fires `ReportStateChange` after a successful ack. Make sure our handler does
|
||||
**not** also call `ReportEvent` for the same transition (would double-emit).
|
||||
Decide whether to drive SDK state from the handler return (let SDK fire) or
|
||||
suppress the SDK's auto-state-change and project from the engine.
|
||||
5. **`GrantedRoleIds` vs our own claim model.** Confirm whether our
|
||||
`LdapOpcUaUserAuthenticator` populates `IUserIdentity.GrantedRoleIds`, or
|
||||
whether the AlarmAck permission lives on a custom identity we set. The gate
|
||||
in `OnAcknowledge` must read whichever surface our auth actually fills.
|
||||
|
||||
---
|
||||
|
||||
*All API names/signatures verified against `OPCFoundation.NetStandard.Opc.Ua.*`
|
||||
v1.5.378.106 (`net10.0`) via `ilspycmd` decompilation, cross-checked against the
|
||||
OPC Foundation reference server samples at
|
||||
`UA-.NETStandard/Applications/Quickstarts.Servers/Alarms/` (master). DeepWiki
|
||||
MCP was unavailable; no claim here rests on it.*
|
||||
@@ -54,7 +54,7 @@ Shipped as PR #180 (36 tests).
|
||||
| C.3 — Predicate evaluation on input change; activate/clear transitions | **Done** | `ScriptedAlarmEngine.ReevaluateAsync`; `_alarmsReferencing` inverse index |
|
||||
| C.4 — Startup recovery (`ActiveState` re-derived; Enabled/Ack/Confirm/Shelve loaded from store) | **Done** | `ScriptedAlarmEngine.LoadAsync`; `IAlarmStateStore.LoadAsync` |
|
||||
| C.5 — Template substitution (`{TagPath}` tokens resolved at emission time) | **Done** | `MessageTemplate.cs`; `MessageTemplateTests.cs` |
|
||||
| C.6 — OPC UA method binding (Acknowledge / Confirm / AddComment / OneShotShelve / TimedShelve / Unshelve) | **Partial** | Engine methods exist and are tested. `ScriptedAlarmSource.AcknowledgeAsync` defaults the user to `"opcua-client"`. The plan's Stream G wiring of these methods to OPC UA `MethodCall` dispatch on the condition nodes (so OPC UA client method calls reach the engine with the authenticated principal) is noted in the e2e smoke doc as "not yet wired through `DriverNodeManager.MethodCall` dispatch." Operators acknowledge through Admin UI today; the Part 9 method-call path is a follow-up. |
|
||||
| C.6 — OPC UA method binding (Acknowledge / Confirm / AddComment / OneShotShelve / TimedShelve / Unshelve) | **Done** | Engine methods exist and are tested. `OtOpcUaNodeManager` wires all six Part 9 condition methods on each `AlarmConditionState` node, gated on the `AlarmAck` LDAP role. On allow, a `Commons.OpcUa.AlarmCommand` is published onto the `alarm-commands` DPS topic; `ScriptedAlarmHostActor` dispatches to the engine with the authenticated principal. (T18–T19, `feat/scriptlog-alarm-ack`.) |
|
||||
| C.7 — `IAlarmSource` implementation / fan-out registration | **Done** | `ScriptedAlarmSource.cs` |
|
||||
| C.8 — Tests: all state transitions, startup recovery, template substitution, shelving timer expiry | **Done** | `Part9StateMachineTests.cs`, `ScriptedAlarmEngineTests.cs`, `ScriptedAlarmSourceTests.cs`, `MessageTemplateTests.cs` — 5 test files |
|
||||
|
||||
@@ -108,9 +108,9 @@ Shipped as PR #185 (13 Admin service tests; UI completeness is partial — see g
|
||||
| G.2 — `DriverNodeManager` dispatch routes reads by source; writes to non-Driver rejected with `BadUserAccessDenied` | **Done** | PR #186 follow-up; `OpcUaApplicationHost.SetPhase7Sources` threads `_virtualReadable` + `_scriptedAlarmReadable` into the node manager |
|
||||
| G.3 — `AlarmTracker` composition (`ScriptedAlarmEngine` registers as additional `IAlarmSource`) | **Done** | `ScriptedAlarmSource` adapts engine to `IAlarmSource`; `Phase7EngineComposer.Compose` wires it |
|
||||
| G.4 — Tests: mixed equipment folder browsable via Client.CLI; read/subscribe round-trip; alarm transitions in event stream | **Done** | `Phase7ComposerMappingTests.cs`, `Phase7EngineComposerTests.cs`, `ScriptedAlarmReadableTests.cs`, `CachedTagUpstreamSourceTests.cs`, `DriverSubscriptionBridgeTests.cs` — 6 test files in `Server.Tests/Phase7/` |
|
||||
| OPC UA method binding for alarm Ack/Confirm/Shelve | **Not started** | Noted explicitly in `phase-7-e2e-smoke.md` §"Known limitations": `DriverNodeManager.MethodCall` dispatch for scripted alarm methods is not wired. Engine has the methods; the OPC UA call path does not reach them. |
|
||||
| OPC UA method binding for alarm Ack/Confirm/Shelve | **Done** | `OtOpcUaNodeManager` wires Acknowledge / Confirm / AddComment / OneShotShelve / TimedShelve / Unshelve on each `AlarmConditionState` node; all gated on `AlarmAck` LDAP role (T18). `ScriptedAlarmHostActor` subscribes to the `alarm-commands` DPS topic and dispatches to the engine (T19). AdminUI `/alerts` Acknowledge/Shelve/Unshelve buttons route through `AdminOperationsActor` onto the same topic (T21). Client.CLI `ack`/`confirm`/`shelve` commands call the Part 9 methods directly (T22). Live-verified on docker-dev 2026-06-11. |
|
||||
|
||||
Shipped across PRs #184 + #186 (5 + 7 tests).
|
||||
Shipped across PRs #184 + #186 (5 + 7 tests). Inbound ack/shelve path shipped as T17–T22 on branch `feat/scriptlog-alarm-ack`.
|
||||
|
||||
### Stream H — Exit gate
|
||||
|
||||
@@ -140,7 +140,14 @@ These are real open items, not issues with the plan reconciliation.
|
||||
|
||||
### Gap 1 — OPC UA method-call dispatch for scripted alarm methods (Stream G / C.6) — CLOSED
|
||||
|
||||
All Part 9 alarm methods now route to the `ScriptedAlarmEngine`. `Acknowledge` / `Confirm` / `AddComment` route via `DriverNodeManager.RouteScriptedAlarmMethodCalls` (task #24 + follow-up); `AddComment` gates at the `AlarmAcknowledge` tier. `OneShotShelve` / `TimedShelve` / `Unshelve` route via the native `AlarmConditionState.OnShelve` / `OnTimedUnshelve` hooks wired in `MarkAsAlarmCondition`, with the per-instance shelve method NodeIds indexed so the Call gate resolves them to `OpcUaOperation.AlarmShelve`.
|
||||
All Part 9 alarm methods now route to the `ScriptedAlarmEngine` (shipped T17–T22, branch `feat/scriptlog-alarm-ack`):
|
||||
|
||||
- `OtOpcUaNodeManager` wires Acknowledge / Confirm / AddComment / OneShotShelve / TimedShelve / Unshelve on each `AlarmConditionState` node, gated on the `AlarmAck` LDAP role (`RoleCarryingUserIdentity` carries roles onto the session identity). On allow, a `Commons.OpcUa.AlarmCommand` is published onto the `alarm-commands` DPS topic.
|
||||
- `ScriptedAlarmHostActor` subscribes to `alarm-commands`, ownership-filters, and dispatches to the matching engine operation. `OnTimedUnshelve` (SDK auto-unshelve) bypasses the client gate.
|
||||
- `WriteAlarmCondition` delta-gates condition events to suppress the inbound-ack double-emit.
|
||||
- AdminUI `/alerts` Acknowledge / Shelve / Unshelve buttons route through `AdminOperationsActor` singleton onto the same `alarm-commands` topic (gated by `DriverOperator` AdminUI policy).
|
||||
- Client.CLI `ack` / `confirm` / `shelve` commands call the Part 9 methods directly.
|
||||
- Live-verified on docker-dev 2026-06-11.
|
||||
|
||||
### Gap 2 — Admin UI: no `/virtual-tags` tab or form (Stream F.2)
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Exceptions;
|
||||
using CliFx.Infrastructure;
|
||||
using Opc.Ua;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.Shared;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Client.CLI.Commands;
|
||||
|
||||
[Command("ack", Description = "Acknowledge an active alarm condition (OPC UA Part 9)")]
|
||||
public class AcknowledgeCommand : CommandBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates the acknowledge command used to acknowledge an active OPC UA condition from the terminal.
|
||||
/// </summary>
|
||||
/// <param name="factory">The factory that creates the shared client service for the command run.</param>
|
||||
public AcknowledgeCommand(IOpcUaClientServiceFactory factory) : base(factory)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the condition node ID of the alarm event to acknowledge.
|
||||
/// </summary>
|
||||
[CommandOption("node", 'n', Description = "Condition node ID of the alarm to acknowledge", IsRequired = true)]
|
||||
public string NodeId { get; init; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the event ID (hex-encoded) returned by the server in the alarm notification.
|
||||
/// </summary>
|
||||
[CommandOption("event-id", 'e', Description = "EventId from the alarm notification (hex-encoded byte array)", IsRequired = true)]
|
||||
public string EventId { get; init; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the operator comment to include with the acknowledgment.
|
||||
/// </summary>
|
||||
[CommandOption("comment", 'c', Description = "Operator comment for the acknowledgment")]
|
||||
public string Comment { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Connects to the server and acknowledges the specified alarm condition.
|
||||
/// </summary>
|
||||
/// <param name="console">The CLI console used for output and cancellation handling.</param>
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
|
||||
byte[] eventId;
|
||||
try
|
||||
{
|
||||
eventId = Convert.FromHexString(EventId);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
throw new CommandException($"Invalid --event-id value: {ex.Message} (expected hex-encoded bytes, e.g. 0A1B2C)");
|
||||
}
|
||||
|
||||
IOpcUaClientService? service = null;
|
||||
try
|
||||
{
|
||||
var ct = console.RegisterCancellationHandler();
|
||||
(service, _) = await CreateServiceAndConnectAsync(ct);
|
||||
|
||||
var statusCode = await service.AcknowledgeAlarmAsync(NodeId, eventId, Comment, ct);
|
||||
|
||||
if (StatusCode.IsGood(statusCode))
|
||||
await console.Output.WriteLineAsync($"Acknowledge successful: {NodeId}");
|
||||
else
|
||||
await console.Output.WriteLineAsync($"Acknowledge failed: {statusCode}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (service != null)
|
||||
{
|
||||
await service.DisconnectAsync();
|
||||
service.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Exceptions;
|
||||
using CliFx.Infrastructure;
|
||||
using Opc.Ua;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.Shared;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Client.CLI.Commands;
|
||||
|
||||
[Command("confirm", Description = "Confirm an acknowledged alarm condition (OPC UA Part 9 two-stage acknowledgment)")]
|
||||
public class ConfirmCommand : CommandBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates the confirm command used to confirm an acknowledged OPC UA condition from the terminal.
|
||||
/// </summary>
|
||||
/// <param name="factory">The factory that creates the shared client service for the command run.</param>
|
||||
public ConfirmCommand(IOpcUaClientServiceFactory factory) : base(factory)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the condition node ID of the alarm event to confirm.
|
||||
/// </summary>
|
||||
[CommandOption("node", 'n', Description = "Condition node ID of the alarm to confirm", IsRequired = true)]
|
||||
public string NodeId { get; init; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the event ID (hex-encoded) returned by the server in the alarm notification.
|
||||
/// </summary>
|
||||
[CommandOption("event-id", 'e', Description = "EventId from the alarm notification (hex-encoded byte array)", IsRequired = true)]
|
||||
public string EventId { get; init; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the operator comment to include with the confirmation.
|
||||
/// </summary>
|
||||
[CommandOption("comment", 'c', Description = "Operator comment for the confirmation")]
|
||||
public string Comment { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Connects to the server and confirms the specified acknowledged alarm condition.
|
||||
/// </summary>
|
||||
/// <param name="console">The CLI console used for output and cancellation handling.</param>
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
|
||||
byte[] eventId;
|
||||
try
|
||||
{
|
||||
eventId = Convert.FromHexString(EventId);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
throw new CommandException($"Invalid --event-id value: {ex.Message} (expected hex-encoded bytes, e.g. 0A1B2C)");
|
||||
}
|
||||
|
||||
IOpcUaClientService? service = null;
|
||||
try
|
||||
{
|
||||
var ct = console.RegisterCancellationHandler();
|
||||
(service, _) = await CreateServiceAndConnectAsync(ct);
|
||||
|
||||
var statusCode = await service.ConfirmAlarmAsync(NodeId, eventId, Comment, ct);
|
||||
|
||||
if (StatusCode.IsGood(statusCode))
|
||||
await console.Output.WriteLineAsync($"Confirm successful: {NodeId}");
|
||||
else
|
||||
await console.Output.WriteLineAsync($"Confirm failed: {statusCode}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (service != null)
|
||||
{
|
||||
await service.DisconnectAsync();
|
||||
service.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Exceptions;
|
||||
using CliFx.Infrastructure;
|
||||
using Opc.Ua;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.Shared;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Client.CLI.Commands;
|
||||
|
||||
[Command("shelve", Description = "Shelve or unshelve an active alarm condition (OPC UA Part 9 ShelvedStateMachine)")]
|
||||
public class ShelveCommand : CommandBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates the shelve command used to shelve or unshelve an OPC UA alarm condition from the terminal.
|
||||
/// </summary>
|
||||
/// <param name="factory">The factory that creates the shared client service for the command run.</param>
|
||||
public ShelveCommand(IOpcUaClientServiceFactory factory) : base(factory)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the condition node ID of the alarm to shelve or unshelve.
|
||||
/// </summary>
|
||||
[CommandOption("node", 'n', Description = "Condition node ID of the alarm to shelve/unshelve", IsRequired = true)]
|
||||
public string NodeId { get; init; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the shelve operation kind: OneShot, Timed, or Unshelve.
|
||||
/// </summary>
|
||||
[CommandOption("kind", 'k', Description = "Shelve operation: OneShot | Timed | Unshelve", IsRequired = true)]
|
||||
public string Kind { get; init; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the shelving duration in seconds for Timed shelving (must be > 0; ignored for OneShot and Unshelve).
|
||||
/// The value is passed in seconds by the operator and converted to milliseconds for the OPC UA TimedShelve call.
|
||||
/// </summary>
|
||||
[CommandOption("duration", 'd', Description = "Shelving duration in seconds (must be > 0; in seconds, converted to milliseconds for the OPC UA call; required for --kind Timed)")]
|
||||
public double DurationSeconds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Connects to the server and shelves or unshelves the specified alarm condition.
|
||||
/// </summary>
|
||||
/// <param name="console">The CLI console used for output and cancellation handling.</param>
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
|
||||
if (!Enum.TryParse<ShelveKind>(Kind, ignoreCase: true, out var shelveKind))
|
||||
throw new CommandException(
|
||||
$"Invalid --kind value '{Kind}'. Expected one of: OneShot, Timed, Unshelve.");
|
||||
|
||||
if (shelveKind == ShelveKind.Timed && DurationSeconds <= 0)
|
||||
throw new CommandException(
|
||||
"--duration must be greater than 0 when --kind is Timed.");
|
||||
|
||||
IOpcUaClientService? service = null;
|
||||
try
|
||||
{
|
||||
var ct = console.RegisterCancellationHandler();
|
||||
(service, _) = await CreateServiceAndConnectAsync(ct);
|
||||
|
||||
var statusCode = await service.ShelveAlarmAsync(NodeId, shelveKind, DurationSeconds, ct);
|
||||
|
||||
if (StatusCode.IsGood(statusCode))
|
||||
await console.Output.WriteLineAsync($"{shelveKind} successful: {NodeId}");
|
||||
else
|
||||
await console.Output.WriteLineAsync($"{shelveKind} failed: {statusCode}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (service != null)
|
||||
{
|
||||
await service.DisconnectAsync();
|
||||
service.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -103,6 +103,38 @@ public interface IOpcUaClientService : IDisposable
|
||||
/// </returns>
|
||||
Task<StatusCode> AcknowledgeAlarmAsync(string conditionNodeId, byte[] eventId, string comment, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Confirms an acknowledged condition (Part 9 two-stage acknowledgment) using the event identifier returned by an alarm notification.
|
||||
/// </summary>
|
||||
/// <param name="conditionNodeId">The condition node associated with the alarm event being confirmed.</param>
|
||||
/// <param name="eventId">The event identifier returned by the OPC UA server for the alarm event.</param>
|
||||
/// <param name="comment">The operator confirmation comment to write with the method call.</param>
|
||||
/// <param name="ct">The cancellation token that aborts the confirmation request.</param>
|
||||
/// <returns>
|
||||
/// <see cref="StatusCodes.Good"/> on success, or the server's bad <see cref="StatusCode"/>
|
||||
/// (from the underlying <see cref="ServiceResultException"/>) when the confirm call
|
||||
/// returns a bad result. Other transport-level failures still surface as exceptions.
|
||||
/// </returns>
|
||||
Task<StatusCode> ConfirmAlarmAsync(string conditionNodeId, byte[] eventId, string comment, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Shelves or unshelves an active alarm condition (OPC UA Part 9 ShelvedStateMachine).
|
||||
/// </summary>
|
||||
/// <param name="conditionNodeId">The condition node associated with the alarm being shelved.</param>
|
||||
/// <param name="kind">The shelve operation: <c>OneShot</c>, <c>Timed</c>, or <c>Unshelve</c>.</param>
|
||||
/// <param name="shelvingTimeSeconds">
|
||||
/// For <c>Timed</c> shelving: the shelving duration in seconds (must be > 0).
|
||||
/// This value is converted to milliseconds when passed to the OPC UA <c>TimedShelve</c> method
|
||||
/// (<c>Duration</c> is milliseconds per OPC UA Part 9). Ignored for <c>OneShot</c> and <c>Unshelve</c>.
|
||||
/// </param>
|
||||
/// <param name="ct">The cancellation token that aborts the shelve request.</param>
|
||||
/// <returns>
|
||||
/// <see cref="StatusCodes.Good"/> on success, or the server's bad <see cref="StatusCode"/>
|
||||
/// (from the underlying <see cref="ServiceResultException"/>) when the shelve call
|
||||
/// returns a bad result. Other transport-level failures still surface as exceptions.
|
||||
/// </returns>
|
||||
Task<StatusCode> ShelveAlarmAsync(string conditionNodeId, ShelveKind kind, double shelvingTimeSeconds = 0, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Reads raw historical samples for a historized node.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Shelve operations supported by the OPC UA Part 9 ShelvedStateMachine.
|
||||
/// </summary>
|
||||
public enum ShelveKind
|
||||
{
|
||||
/// <summary>
|
||||
/// OneShotShelve: suppresses the alarm for one occurrence. The alarm automatically
|
||||
/// unshelves when it next returns to the inactive state.
|
||||
/// </summary>
|
||||
OneShot,
|
||||
|
||||
/// <summary>
|
||||
/// TimedShelve: suppresses the alarm for a specified duration (seconds).
|
||||
/// Requires a positive <c>shelvingTimeSeconds</c> argument.
|
||||
/// </summary>
|
||||
Timed,
|
||||
|
||||
/// <summary>
|
||||
/// Unshelve: removes an active OneShotShelve or TimedShelve, returning the alarm
|
||||
/// to its normal active-notification state.
|
||||
/// </summary>
|
||||
Unshelve
|
||||
}
|
||||
@@ -376,6 +376,91 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
return StatusCodes.Good;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<StatusCode> ConfirmAlarmAsync(string conditionNodeId, byte[] eventId, string comment,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
ThrowIfNotConnected();
|
||||
|
||||
// Confirm lives on the same .Condition child node as Acknowledge
|
||||
var conditionObjId = conditionNodeId.EndsWith(".Condition")
|
||||
? NodeId.Parse(conditionNodeId)
|
||||
: NodeId.Parse(conditionNodeId + ".Condition");
|
||||
var confirmMethodId = MethodIds.AcknowledgeableConditionType_Confirm;
|
||||
|
||||
try
|
||||
{
|
||||
await _session!.CallMethodAsync(
|
||||
conditionObjId,
|
||||
confirmMethodId,
|
||||
[eventId, new LocalizedText(comment)],
|
||||
ct);
|
||||
}
|
||||
catch (ServiceResultException ex)
|
||||
{
|
||||
Logger.Warning(ex, "Failed to confirm alarm on {ConditionId} (status {Status})",
|
||||
conditionNodeId, ex.StatusCode);
|
||||
return ex.StatusCode;
|
||||
}
|
||||
|
||||
Logger.Debug("Confirmed alarm on {ConditionId}", conditionNodeId);
|
||||
return StatusCodes.Good;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<StatusCode> ShelveAlarmAsync(string conditionNodeId, ShelveKind kind,
|
||||
double shelvingTimeSeconds = 0, CancellationToken ct = default)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
ThrowIfNotConnected();
|
||||
|
||||
// The shelve methods live on the ShelvingState child of the condition node.
|
||||
// conditionNodeId is the alarm/condition node; append .ShelvingState to get the state machine node.
|
||||
var shelvingStateNodeId = conditionNodeId.EndsWith(".ShelvingState")
|
||||
? NodeId.Parse(conditionNodeId)
|
||||
: NodeId.Parse(conditionNodeId + ".ShelvingState");
|
||||
|
||||
NodeId methodId;
|
||||
object[] inputArgs;
|
||||
|
||||
switch (kind)
|
||||
{
|
||||
case ShelveKind.OneShot:
|
||||
methodId = MethodIds.AlarmConditionType_ShelvingState_OneShotShelve;
|
||||
inputArgs = [];
|
||||
break;
|
||||
case ShelveKind.Timed:
|
||||
if (shelvingTimeSeconds <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(shelvingTimeSeconds),
|
||||
"Timed shelving requires a positive shelvingTimeSeconds value.");
|
||||
methodId = MethodIds.AlarmConditionType_ShelvingState_TimedShelve;
|
||||
// OPC UA Part 9 TimedShelve ShelvingTime input is a Duration (Double) in milliseconds.
|
||||
inputArgs = [shelvingTimeSeconds * 1000.0];
|
||||
break;
|
||||
case ShelveKind.Unshelve:
|
||||
methodId = MethodIds.AlarmConditionType_ShelvingState_Unshelve;
|
||||
inputArgs = [];
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(kind), kind, "Unknown ShelveKind value.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _session!.CallMethodAsync(shelvingStateNodeId, methodId, inputArgs, ct);
|
||||
}
|
||||
catch (ServiceResultException ex)
|
||||
{
|
||||
Logger.Warning(ex, "Failed to shelve alarm on {ConditionId} kind={Kind} (status {Status})",
|
||||
conditionNodeId, kind, ex.StatusCode);
|
||||
return ex.StatusCode;
|
||||
}
|
||||
|
||||
Logger.Debug("Shelved alarm on {ConditionId} kind={Kind}", conditionNodeId, kind);
|
||||
return StatusCodes.Good;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<DataValue>> HistoryReadRawAsync(
|
||||
NodeId nodeId, DateTime startTime, DateTime endTime, int maxValues = 1000, CancellationToken ct = default)
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.Engines;
|
||||
|
||||
/// <summary>
|
||||
/// Persistence seam for <c>ScriptedAlarmActor</c>'s in-memory state across actor restarts.
|
||||
/// Captures only the slice the actor's 3-state machine needs (Inactive / Active /
|
||||
/// Acknowledged + last transition + last-ack user). The fuller GxP audit trail
|
||||
/// (<see cref="Configuration.Entities.ScriptedAlarmState"/>'s Comments/Confirmed/Shelving)
|
||||
/// stays in the production engine binding — this seam is the small surface the actor
|
||||
/// consumes directly.
|
||||
/// </summary>
|
||||
public interface IAlarmActorStateStore
|
||||
{
|
||||
/// <summary>Loads the persisted state snapshot for an alarm actor.</summary>
|
||||
/// <param name="alarmId">The alarm identifier.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The alarm state snapshot if found; null if the alarm has no persisted state.</returns>
|
||||
Task<AlarmActorStateSnapshot?> LoadAsync(string alarmId, CancellationToken ct);
|
||||
/// <summary>Saves the alarm actor state snapshot.</summary>
|
||||
/// <param name="snapshot">The state snapshot to persist.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task SaveAsync(AlarmActorStateSnapshot snapshot, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>Persisted slice of <c>ScriptedAlarmActor</c>'s state. Active is NOT persisted —
|
||||
/// it re-derives from the evaluator on startup per Phase 7 decision #14. <c>State</c> here
|
||||
/// distinguishes Acknowledged vs not-yet-acknowledged for cases where the actor came up
|
||||
/// Active and operator interaction had already happened.</summary>
|
||||
/// <param name="AlarmId">Matches <c>ScriptedAlarm.ScriptedAlarmId</c>.</param>
|
||||
/// <param name="State">Inactive / Active / Acknowledged — the actor's 3-state enum, projected to string.</param>
|
||||
/// <param name="LastTransitionUtc">When the actor last transitioned.</param>
|
||||
/// <param name="LastAckUser">Who acknowledged most recently. Null when never acked.</param>
|
||||
public sealed record AlarmActorStateSnapshot(
|
||||
string AlarmId,
|
||||
string State,
|
||||
DateTime LastTransitionUtc,
|
||||
string? LastAckUser);
|
||||
|
||||
/// <summary>No-op default. Bound when no production store is configured (tests, smoke runs).
|
||||
/// Load returns null → actor boots Inactive; Save is a no-op so state doesn't leak.</summary>
|
||||
public sealed class NullAlarmActorStateStore : IAlarmActorStateStore
|
||||
{
|
||||
public static readonly NullAlarmActorStateStore Instance = new();
|
||||
private NullAlarmActorStateStore() { }
|
||||
/// <summary>Always returns null, indicating no persisted state.</summary>
|
||||
/// <param name="alarmId">The alarm identifier (unused).</param>
|
||||
/// <param name="ct">Cancellation token (unused).</param>
|
||||
public Task<AlarmActorStateSnapshot?> LoadAsync(string alarmId, CancellationToken ct) =>
|
||||
Task.FromResult<AlarmActorStateSnapshot?>(null);
|
||||
/// <summary>Completes immediately without persisting anything.</summary>
|
||||
/// <param name="snapshot">The state snapshot (ignored).</param>
|
||||
/// <param name="ct">Cancellation token (unused).</param>
|
||||
public Task SaveAsync(AlarmActorStateSnapshot snapshot, CancellationToken ct) =>
|
||||
Task.CompletedTask;
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.Engines;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction over the scripted-alarm predicate engine. Production binds this to a
|
||||
/// wrapper around <c>ScriptedAlarmEngine</c> from <c>Core.ScriptedAlarms</c>; default
|
||||
/// binding is <see cref="NullScriptedAlarmEvaluator"/> which keeps the alarm in its
|
||||
/// current state (so an unconfigured node never spuriously alarms).
|
||||
/// </summary>
|
||||
public interface IScriptedAlarmEvaluator
|
||||
{
|
||||
/// <summary>Evaluates an alarm predicate against the provided dependencies.</summary>
|
||||
/// <param name="alarmId">The unique identifier of the alarm being evaluated.</param>
|
||||
/// <param name="predicate">The predicate expression to evaluate.</param>
|
||||
/// <param name="dependencies">Read-only dictionary of variable names to values for predicate evaluation.</param>
|
||||
/// <returns>Result containing success flag, alarm active state, and optional failure reason.</returns>
|
||||
ScriptedAlarmEvalResult Evaluate(string alarmId, string predicate, IReadOnlyDictionary<string, object?> dependencies);
|
||||
}
|
||||
|
||||
/// <summary>Result of one alarm-predicate evaluation. <c>Active</c> is only meaningful when
|
||||
/// <c>Success</c> is true; on failure the caller should keep the prior state and log Reason.</summary>
|
||||
public sealed record ScriptedAlarmEvalResult(bool Success, bool Active, string? Reason)
|
||||
{
|
||||
/// <summary>Creates a successful alarm evaluation result with the given active state.</summary>
|
||||
/// <param name="active">Whether the alarm condition is active.</param>
|
||||
/// <returns>A successful evaluation result.</returns>
|
||||
public static ScriptedAlarmEvalResult Ok(bool active) => new(true, active, null);
|
||||
|
||||
/// <summary>Creates a failed alarm evaluation result with the given reason.</summary>
|
||||
/// <param name="reason">Description of the evaluation failure cause.</param>
|
||||
/// <returns>A failed evaluation result.</returns>
|
||||
public static ScriptedAlarmEvalResult Failure(string reason) => new(false, false, reason);
|
||||
}
|
||||
|
||||
/// <summary>Default that always returns <c>Active = false, Success = true</c>. Safe no-op:
|
||||
/// no alarm fires when no real engine is bound.</summary>
|
||||
public sealed class NullScriptedAlarmEvaluator : IScriptedAlarmEvaluator
|
||||
{
|
||||
public static readonly NullScriptedAlarmEvaluator Instance = new();
|
||||
private NullScriptedAlarmEvaluator() { }
|
||||
/// <summary>Returns an inactive alarm result for every evaluation (safe no-op behavior).</summary>
|
||||
/// <param name="alarmId">The alarm identifier (ignored).</param>
|
||||
/// <param name="predicate">The predicate expression (ignored).</param>
|
||||
/// <param name="dependencies">The variable dependencies (ignored).</param>
|
||||
/// <returns>Always returns an inactive alarm result.</returns>
|
||||
public ScriptedAlarmEvalResult Evaluate(string alarmId, string predicate, IReadOnlyDictionary<string, object?> dependencies)
|
||||
=> ScriptedAlarmEvalResult.Ok(active: false);
|
||||
}
|
||||
@@ -15,6 +15,36 @@ public interface IAdminOperationsClient
|
||||
/// <returns>A task representing the asynchronous operation containing the deployment start result.</returns>
|
||||
Task<StartDeploymentResult> StartDeploymentAsync(string createdBy, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Acknowledges one alarm via the admin singleton, which republishes the mapped command onto
|
||||
/// the cluster <c>alarm-commands</c> topic for the owning node to apply.
|
||||
/// </summary>
|
||||
/// <param name="alarmId">The alarm's ScriptedAlarmId.</param>
|
||||
/// <param name="user">The acting operator's name (for audit + the alarm-event User field).</param>
|
||||
/// <param name="comment">Optional free-text comment; null when none.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>The acknowledge result.</returns>
|
||||
Task<AcknowledgeAlarmResult> AcknowledgeAlarmAsync(string alarmId, string user, string? comment, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Shelves or unshelves one alarm via the admin singleton, which republishes the mapped command
|
||||
/// onto the cluster <c>alarm-commands</c> topic for the owning node to apply.
|
||||
/// </summary>
|
||||
/// <param name="alarmId">The alarm's ScriptedAlarmId.</param>
|
||||
/// <param name="user">The acting operator's name (for audit + the alarm-event User field).</param>
|
||||
/// <param name="kind">Which shelve action to perform (OneShot / Timed / Unshelve).</param>
|
||||
/// <param name="unshelveAtUtc">For <see cref="Messages.Admin.ShelveKind.Timed"/>, when the shelve expires; null otherwise.</param>
|
||||
/// <param name="comment">Optional free-text comment; null when none.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>The shelve result.</returns>
|
||||
Task<ShelveAlarmResult> ShelveAlarmAsync(
|
||||
string alarmId,
|
||||
string user,
|
||||
Messages.Admin.ShelveKind kind,
|
||||
DateTime? unshelveAtUtc,
|
||||
string? comment,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Generic Ask: forwards <paramref name="message"/> to the AdminOperationsActor
|
||||
/// cluster-singleton proxy and awaits a reply of type <typeparamref name="T"/>.
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// AdminUI → AdminOperationsActor: acknowledge one alarm on behalf of an operator. The admin
|
||||
/// singleton maps this to a <c>Commons.OpcUa.AlarmCommand</c> with <c>Operation = "Acknowledge"</c>
|
||||
/// and republishes it onto the cluster <c>alarm-commands</c> topic, where the owning
|
||||
/// <c>ScriptedAlarmHostActor</c> filters by ownership and drives the engine. Routing this through
|
||||
/// the admin-pinned singleton lets the AdminUI act without knowing which node owns the alarm —
|
||||
/// the broadcast + ownership filter handle cross-node delivery.
|
||||
/// </summary>
|
||||
/// <param name="AlarmId">The alarm's ScriptedAlarmId (the <c>AlarmTransitionEvent.AlarmId</c> shown on the row).</param>
|
||||
/// <param name="User">The authenticated operator who triggered the acknowledgement.</param>
|
||||
/// <param name="Comment">Optional free-text comment supplied with the acknowledgement; null when none.</param>
|
||||
/// <param name="CorrelationId">Round-trip correlation token.</param>
|
||||
public sealed record AcknowledgeAlarmCommand(
|
||||
string AlarmId,
|
||||
string User,
|
||||
string? Comment,
|
||||
Guid CorrelationId);
|
||||
|
||||
/// <summary>Reply for <see cref="AcknowledgeAlarmCommand"/>.</summary>
|
||||
/// <param name="Ok">True iff the command was published without error.</param>
|
||||
/// <param name="Message">Failure reason; null on success.</param>
|
||||
/// <param name="CorrelationId">Echoes the request's correlation token.</param>
|
||||
public sealed record AcknowledgeAlarmResult(
|
||||
bool Ok,
|
||||
string? Message,
|
||||
Guid CorrelationId);
|
||||
@@ -0,0 +1,52 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// The shelve action an operator requested. Maps 1:1 onto the engine-side <c>Operation</c>
|
||||
/// strings the <c>ScriptedAlarmHostActor</c> dispatches on
|
||||
/// (<c>OneShotShelve</c> / <c>TimedShelve</c> / <c>Unshelve</c>).
|
||||
/// </summary>
|
||||
public enum ShelveKind
|
||||
{
|
||||
/// <summary>Shelve until the next clear (no timer). Maps to <c>Operation = "OneShotShelve"</c>.</summary>
|
||||
OneShot,
|
||||
|
||||
/// <summary>Shelve until an absolute instant. Maps to <c>Operation = "TimedShelve"</c>; carries <c>UnshelveAtUtc</c>.</summary>
|
||||
Timed,
|
||||
|
||||
/// <summary>Remove an existing shelve. Maps to <c>Operation = "Unshelve"</c>.</summary>
|
||||
Unshelve,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// AdminUI → AdminOperationsActor: shelve / unshelve one alarm on behalf of an operator. The admin
|
||||
/// singleton maps <see cref="Kind"/> to the matching <c>Commons.OpcUa.AlarmCommand</c> operation
|
||||
/// (<c>OneShotShelve</c> / <c>TimedShelve</c> / <c>Unshelve</c>), threading
|
||||
/// <see cref="UnshelveAtUtc"/> for <see cref="ShelveKind.Timed"/>, and republishes it onto the
|
||||
/// cluster <c>alarm-commands</c> topic, where the owning <c>ScriptedAlarmHostActor</c> filters by
|
||||
/// ownership and drives the engine.
|
||||
/// </summary>
|
||||
/// <param name="AlarmId">The alarm's ScriptedAlarmId (the <c>AlarmTransitionEvent.AlarmId</c> shown on the row).</param>
|
||||
/// <param name="User">The authenticated operator who triggered the shelve.</param>
|
||||
/// <param name="Kind">Which shelve action to perform.</param>
|
||||
/// <param name="UnshelveAtUtc">
|
||||
/// For <see cref="ShelveKind.Timed"/>, the absolute UTC instant the shelve auto-expires; null for
|
||||
/// <see cref="ShelveKind.OneShot"/> and <see cref="ShelveKind.Unshelve"/>.
|
||||
/// </param>
|
||||
/// <param name="Comment">Optional free-text comment supplied with the shelve; null when none.</param>
|
||||
/// <param name="CorrelationId">Round-trip correlation token.</param>
|
||||
public sealed record ShelveAlarmCommand(
|
||||
string AlarmId,
|
||||
string User,
|
||||
ShelveKind Kind,
|
||||
DateTime? UnshelveAtUtc,
|
||||
string? Comment,
|
||||
Guid CorrelationId);
|
||||
|
||||
/// <summary>Reply for <see cref="ShelveAlarmCommand"/>.</summary>
|
||||
/// <param name="Ok">True iff the command was published without error.</param>
|
||||
/// <param name="Message">Failure reason; null on success.</param>
|
||||
/// <param name="CorrelationId">Echoes the request's correlation token.</param>
|
||||
public sealed record ShelveAlarmResult(
|
||||
bool Ok,
|
||||
string? Message,
|
||||
Guid CorrelationId);
|
||||
@@ -0,0 +1,54 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
|
||||
/// <summary>
|
||||
/// Shared DPS topic for inbound alarm commands (<see cref="AlarmCommand"/>). Publishers — the
|
||||
/// OPC UA node-manager seam (T18) and the AdminUI path via <c>AdminOperationsActor</c> (T21) —
|
||||
/// and the subscriber (<c>ScriptedAlarmHostActor</c>, T19) reference this single constant so a
|
||||
/// rename can't silently desynchronise them. Mirrors the
|
||||
/// <see cref="Messages.Admin.DriverControlTopic"/> pattern; <c>ScriptedAlarmHostActor</c>
|
||||
/// re-exports this as its <c>AlarmCommandsTopic</c> const.
|
||||
/// </summary>
|
||||
public static class AlarmCommandsTopic
|
||||
{
|
||||
/// <summary>The cluster DistributedPubSub topic name inbound alarm commands are published on.</summary>
|
||||
public const string Name = "alarm-commands";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Commons-level command carried from an inbound OPC UA Part 9 alarm method call
|
||||
/// (Acknowledge / Confirm / Shelve / AddComment …) back to the scripted-alarm engine. The SDK
|
||||
/// node manager builds one of these in its condition method-handler delegates after the
|
||||
/// <c>AlarmAck</c> role gate passes, then the host routes it onto the cluster
|
||||
/// <c>alarm-commands</c> DistributedPubSub topic; T19's engine-side subscriber consumes it and
|
||||
/// drives the matching <c>Part9StateMachine.Apply*</c> transition. This is a pure DTO — it makes
|
||||
/// no auth decision and holds no SDK/Akka handle.
|
||||
/// </summary>
|
||||
/// <param name="AlarmId">
|
||||
/// The alarm's ScriptedAlarmId — equal to the materialised condition node's NodeId identifier
|
||||
/// (T14 aligned the condition NodeId to the ScriptedAlarmId). The engine keys its domain state by
|
||||
/// this id.
|
||||
/// </param>
|
||||
/// <param name="Operation">
|
||||
/// The Part 9 operation, one of: <c>Acknowledge</c>, <c>Confirm</c>, <c>OneShotShelve</c>,
|
||||
/// <c>TimedShelve</c>, <c>Unshelve</c>, <c>Enable</c>, <c>Disable</c>, <c>AddComment</c>. These map
|
||||
/// 1:1 onto the engine's <c>Part9StateMachine.Apply*</c> calls on the consuming side (T19).
|
||||
/// Note: <c>Enable</c> and <c>Disable</c> are part of the vocabulary but are not yet wired at the
|
||||
/// node-manager seam (T18 wired Acknowledge/Confirm/AddComment/Shelve/TimedUnshelve only);
|
||||
/// <c>OnEnableDisable</c> delegate wiring is a future task.
|
||||
/// </param>
|
||||
/// <param name="User">The acting user — the authenticated session identity's display/name.</param>
|
||||
/// <param name="Comment">
|
||||
/// The free-text comment supplied with the call (the OPC UA <c>LocalizedText</c> payload's text),
|
||||
/// or <c>null</c> when none was provided.
|
||||
/// </param>
|
||||
/// <param name="UnshelveAtUtc">
|
||||
/// For <c>TimedShelve</c>, the absolute UTC instant the shelve auto-expires
|
||||
/// (<c>DateTime.UtcNow + shelvingTime</c>, where the OPC UA <c>Duration</c> <c>shelvingTime</c> is
|
||||
/// in <b>milliseconds</b>); <c>null</c> for every other operation.
|
||||
/// </param>
|
||||
public sealed record AlarmCommand(
|
||||
string AlarmId,
|
||||
string Operation,
|
||||
string User,
|
||||
string? Comment,
|
||||
DateTime? UnshelveAtUtc);
|
||||
@@ -0,0 +1,45 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
|
||||
/// <summary>
|
||||
/// Commons-level projection of an alarm's full OPC UA Part 9 condition state, carried from the
|
||||
/// Runtime engine to the SDK sink. Commons cannot reference <c>Core.ScriptedAlarms</c> (its
|
||||
/// domain enums live there), so this DTO is deliberately primitive: every field is a
|
||||
/// <see cref="bool"/>, the Commons-local <see cref="AlarmShelvingKind"/> enum, a
|
||||
/// <see cref="ushort"/> severity, or a <see cref="string"/>. The Runtime host maps its Core
|
||||
/// <c>AlarmConditionState</c> + <c>AlarmSeverity</c> down to this shape; the SDK
|
||||
/// <c>OtOpcUaNodeManager</c> projects it back up onto a real <c>AlarmConditionState</c> node.
|
||||
/// </summary>
|
||||
/// <param name="Active">Whether the alarm condition is currently active (ActiveState).</param>
|
||||
/// <param name="Acknowledged">Whether the active transition has been acknowledged (AckedState).</param>
|
||||
/// <param name="Confirmed">Whether the clear transition has been confirmed (ConfirmedState).</param>
|
||||
/// <param name="Enabled">Whether the alarm is enabled (EnabledState); a disabled alarm reports no events.</param>
|
||||
/// <param name="Shelving">The shelving mode (ShelvingState): unshelved, one-shot, or timed.</param>
|
||||
/// <param name="Severity">OPC UA severity on the 1..1000 scale (the SDK <c>SetSeverity</c> input).</param>
|
||||
/// <param name="Message">The human-readable condition message (LocalizedText payload).</param>
|
||||
public sealed record AlarmConditionSnapshot(
|
||||
bool Active,
|
||||
bool Acknowledged,
|
||||
bool Confirmed,
|
||||
bool Enabled,
|
||||
AlarmShelvingKind Shelving,
|
||||
ushort Severity,
|
||||
string Message);
|
||||
|
||||
/// <summary>
|
||||
/// Commons-local mirror of the Core <c>ShelvingKind</c> enum so this assembly carries no
|
||||
/// dependency on <c>Core.ScriptedAlarms</c>. <see cref="Unshelved"/> = no suppression,
|
||||
/// <see cref="OneShot"/> = suppress the next active transition, <see cref="Timed"/> = suppress
|
||||
/// until a configured expiry. The Runtime host maps the Core enum onto these members; the SDK
|
||||
/// sink maps them onto the Part 9 <c>SetShelvingState(shelved, oneShot, shelvingTime)</c> flags.
|
||||
/// </summary>
|
||||
public enum AlarmShelvingKind
|
||||
{
|
||||
/// <summary>No shelving — the alarm behaves normally.</summary>
|
||||
Unshelved,
|
||||
|
||||
/// <summary>One-shot shelve — suppresses the next active transition, then expires.</summary>
|
||||
OneShot,
|
||||
|
||||
/// <summary>Timed shelve — suppresses until a configured expiry timestamp passes.</summary>
|
||||
Timed,
|
||||
}
|
||||
@@ -30,13 +30,21 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink
|
||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc)
|
||||
=> _inner.WriteValue(nodeId, value, quality, sourceTimestampUtc);
|
||||
|
||||
/// <summary>Writes an alarm state through the inner sink.</summary>
|
||||
/// <summary>Writes a full alarm-condition state through the inner sink.</summary>
|
||||
/// <param name="alarmNodeId">The node ID of the alarm condition.</param>
|
||||
/// <param name="active">Whether the alarm is active.</param>
|
||||
/// <param name="acknowledged">Whether the alarm has been acknowledged.</param>
|
||||
/// <param name="state">The full condition state to project onto the node.</param>
|
||||
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
||||
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
|
||||
=> _inner.WriteAlarmState(alarmNodeId, active, acknowledged, sourceTimestampUtc);
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc)
|
||||
=> _inner.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc);
|
||||
|
||||
/// <summary>Materialises a real Part 9 alarm-condition node through the inner sink.</summary>
|
||||
/// <param name="alarmNodeId">The alarm node ID (== ScriptedAlarmId).</param>
|
||||
/// <param name="equipmentNodeId">The equipment folder node ID the condition parents under.</param>
|
||||
/// <param name="displayName">The human-readable condition name.</param>
|
||||
/// <param name="alarmType">The domain alarm type.</param>
|
||||
/// <param name="severity">The domain severity.</param>
|
||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity)
|
||||
=> _inner.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity);
|
||||
|
||||
/// <summary>Ensures a folder exists in the address space through the inner sink.</summary>
|
||||
/// <param name="folderNodeId">The node ID of the folder.</param>
|
||||
|
||||
@@ -15,12 +15,30 @@ public interface IOpcUaAddressSpaceSink
|
||||
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
||||
void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc);
|
||||
|
||||
/// <summary>Write an alarm-condition Variable's active/acknowledged state.</summary>
|
||||
/// <param name="alarmNodeId">The OPC UA node ID of the alarm.</param>
|
||||
/// <param name="active">Whether the alarm is active.</param>
|
||||
/// <param name="acknowledged">Whether the alarm has been acknowledged.</param>
|
||||
/// <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"/>,
|
||||
/// this projects the whole <see cref="AlarmConditionSnapshot"/>
|
||||
/// (Enabled/Active/Acked/Confirmed/Shelving/Severity/Message) onto it and recomputes Retain;
|
||||
/// otherwise it falls back to the legacy two-element <c>[Active, Acknowledged]</c> placeholder
|
||||
/// variable. A materialised condition also fires a Part 9 condition event on each transition (T16)
|
||||
/// so subscribed clients receive the alarm event, not just the changed attributes.</summary>
|
||||
/// <param name="alarmNodeId">The OPC UA node ID of the alarm (== ScriptedAlarmId for materialised conditions).</param>
|
||||
/// <param name="state">The full condition state to project onto the node.</param>
|
||||
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
||||
void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc);
|
||||
void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc);
|
||||
|
||||
/// <summary>
|
||||
/// Materialise a real OPC UA Part 9 alarm-condition node under its equipment folder so clients
|
||||
/// can browse it as a proper condition (with basic Active/Ack state). The node id equals the
|
||||
/// alarm node id (the ScriptedAlarmId) so subsequent <see cref="WriteAlarmCondition"/> calls update
|
||||
/// it. Used by <c>Phase7Applier.MaterialiseScriptedAlarms</c>. Idempotent.
|
||||
/// </summary>
|
||||
/// <param name="alarmNodeId">The alarm node ID (== ScriptedAlarmId); becomes the condition's NodeId.</param>
|
||||
/// <param name="equipmentNodeId">The equipment folder node ID the condition parents under.</param>
|
||||
/// <param name="displayName">Human-readable condition name (BrowseName / DisplayName / Message).</param>
|
||||
/// <param name="alarmType">Domain alarm type — mapped to the SDK condition subtype by the sink.</param>
|
||||
/// <param name="severity">Domain severity (OPC UA 1..1000 scale); mapped to the SDK severity buckets.</param>
|
||||
void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity);
|
||||
|
||||
/// <summary>
|
||||
/// Ensure a folder node exists under the given parent. Used by <c>Phase7Applier</c> to
|
||||
@@ -68,7 +86,10 @@ public sealed class NullOpcUaAddressSpaceSink : IOpcUaAddressSpaceSink
|
||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
|
||||
|
||||
/// <inheritdoc />
|
||||
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc) { }
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) { }
|
||||
|
||||
/// <inheritdoc />
|
||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity) { }
|
||||
|
||||
/// <inheritdoc />
|
||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
|
||||
|
||||
@@ -20,6 +20,12 @@ public static class EquipmentScriptPaths
|
||||
private static readonly Regex GetTagRefRegex =
|
||||
new(@"ctx\s*\.\s*GetTag\s*\(\s*""([^""]+)""\s*\)", RegexOptions.Compiled);
|
||||
|
||||
// {TagPath} message-template token — a single-brace {…} run. The negative look-behind/-ahead
|
||||
// reject the reserved {{equip}} double-brace form (and any other doubled brace), so "{A.X}"
|
||||
// matches but "{{equip}}" does not yield "{equip}".
|
||||
private static readonly Regex MessageTemplateTokenRegex =
|
||||
new(@"(?<!\{)\{([^{}]+)\}(?!\})", RegexOptions.Compiled);
|
||||
|
||||
// ctx.GetTag("…") OR ctx.SetVirtualTag("…", …) — first string-literal arg captured in
|
||||
// three parts (prefix, content, closing quote) so token substitution touches ONLY the
|
||||
// literal content (never a comment, Logger string, or other code).
|
||||
@@ -92,4 +98,42 @@ public static class EquipmentScriptPaths
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merge a scripted alarm's dependency graph: the predicate script's distinct
|
||||
/// <c>ctx.GetTag("…")</c> read refs (via <see cref="ExtractDependencyRefs"/>, first-seen order)
|
||||
/// UNION the distinct <c>{TagPath}</c> token paths referenced in the message template (first-seen
|
||||
/// order, appended after the predicate reads, trimmed + non-empty). The reserved
|
||||
/// <c>{{equip}}</c> double-brace form is excluded by the token regex. Deterministic so the live
|
||||
/// composer (<c>Phase7Composer</c>) and the artifact-decode mirror (<c>DeploymentArtifact</c>)
|
||||
/// produce the exact same ordered list — the byte-parity contract <c>EquipmentScriptedAlarmPlan</c>
|
||||
/// equality depends on. Scripted alarms do NOT use <c>{{equip}}</c> substitution (only virtual
|
||||
/// tags do) — pass the predicate source as-is.
|
||||
/// </summary>
|
||||
/// <param name="predicateSource">The resolved predicate script source.</param>
|
||||
/// <param name="messageTemplate">The alarm message template carrying <c>{TagPath}</c> tokens.</param>
|
||||
/// <returns>The merged, distinct, deterministically-ordered dependency refs.</returns>
|
||||
public static IReadOnlyList<string> ExtractAlarmDependencyRefs(string? predicateSource, string? messageTemplate)
|
||||
{
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
var result = new List<string>();
|
||||
|
||||
// Predicate reads first — same regex extraction the VirtualTag projection uses.
|
||||
foreach (var r in ExtractDependencyRefs(predicateSource))
|
||||
{
|
||||
if (seen.Add(r)) result.Add(r);
|
||||
}
|
||||
|
||||
// Then message-template {TagPath} tokens (first-seen), trimmed, non-empty.
|
||||
if (!string.IsNullOrEmpty(messageTemplate))
|
||||
{
|
||||
foreach (Match m in MessageTemplateTokenRegex.Matches(messageTemplate))
|
||||
{
|
||||
var token = m.Groups[1].Value.Trim();
|
||||
if (token.Length > 0 && seen.Add(token)) result.Add(token);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction over the mechanism that publishes <see cref="ScriptLogEntry"/> records
|
||||
/// to a downstream consumer (e.g. an Akka DPS <c>script-logs</c> topic). Implemented by
|
||||
/// the concrete DPS publisher in a later task; the sink depends only on this interface
|
||||
/// so the Core.Scripting layer has no Akka dependency.
|
||||
/// </summary>
|
||||
public interface IScriptLogPublisher
|
||||
{
|
||||
/// <summary>
|
||||
/// Publish a single <see cref="ScriptLogEntry"/>. Implementations must be
|
||||
/// thread-safe — Serilog sinks may be called from any thread.
|
||||
/// </summary>
|
||||
/// <param name="entry">The entry to publish. Must not be <c>null</c>.</param>
|
||||
void Publish(ScriptLogEntry entry);
|
||||
}
|
||||
@@ -47,7 +47,8 @@ public sealed class ScriptLogCompanionSink : ILogEventSink
|
||||
if (logEvent.Level < _minMirrorLevel) return;
|
||||
|
||||
var scriptName = "unknown";
|
||||
if (logEvent.Properties.TryGetValue(ScriptLoggerFactory.ScriptNameProperty, out var prop)
|
||||
if ((logEvent.Properties.TryGetValue(ScriptLoggerFactory.ScriptNameProperty, out var prop)
|
||||
|| logEvent.Properties.TryGetValue(ScriptLoggerFactory.ScriptIdProperty, out prop))
|
||||
&& prop is ScalarValue sv && sv.Value is string s)
|
||||
{
|
||||
scriptName = s;
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
using Serilog.Core;
|
||||
using Serilog.Events;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
/// <summary>
|
||||
/// Serilog sink that converts each script <see cref="LogEvent"/> into a
|
||||
/// <see cref="ScriptLogEntry"/> and forwards it to an <see cref="IScriptLogPublisher"/>.
|
||||
/// The publisher implementation (supplied by a later task) routes the entry onto the
|
||||
/// Akka DPS <c>script-logs</c> topic so the live Script-log Admin UI page can display it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Expected to be registered in the root script-logger pipeline alongside the
|
||||
/// rolling <c>scripts-*.log</c> file sink and the
|
||||
/// <see cref="ScriptLogCompanionSink"/>. Events below the configured minimum level
|
||||
/// are silently discarded so Debug/Verbose noise does not saturate the cluster bus
|
||||
/// in production deployments.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Identity properties (<c>ScriptId</c>, <c>VirtualTagId</c>, <c>AlarmId</c>,
|
||||
/// <c>EquipmentId</c>) are lifted from the event's structured-property bag. If
|
||||
/// <c>ScriptId</c> is absent the sink falls back to the legacy <c>ScriptName</c>
|
||||
/// property so pipelines that pre-date this sink continue to work correctly. If
|
||||
/// neither property is present <c>ScriptId</c> defaults to <c>"unknown"</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ScriptLogTopicSink : ILogEventSink
|
||||
{
|
||||
private readonly IScriptLogPublisher _publisher;
|
||||
private readonly LogEventLevel _min;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="ScriptLogTopicSink"/> class.</summary>
|
||||
/// <param name="publisher">The publisher that routes entries to the cluster bus. Must not be <c>null</c>.</param>
|
||||
/// <param name="min">Minimum log level to forward. Events below this level are discarded (default: <see cref="LogEventLevel.Information"/>).</param>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="publisher"/> is <c>null</c>.</exception>
|
||||
public ScriptLogTopicSink(IScriptLogPublisher publisher, LogEventLevel min = LogEventLevel.Information)
|
||||
{
|
||||
_publisher = publisher ?? throw new ArgumentNullException(nameof(publisher));
|
||||
_min = min;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts the <paramref name="logEvent"/> to a <see cref="ScriptLogEntry"/> and
|
||||
/// publishes it, unless the event is below the configured minimum level.
|
||||
/// </summary>
|
||||
/// <param name="logEvent">The Serilog event to process. Silently ignored when <c>null</c>.</param>
|
||||
public void Emit(LogEvent logEvent)
|
||||
{
|
||||
if (logEvent is null || logEvent.Level < _min) return;
|
||||
|
||||
// Extract a string scalar property by key, returning null when absent or non-string.
|
||||
string? P(string key) =>
|
||||
logEvent.Properties.TryGetValue(key, out var v) && v is ScalarValue { Value: string s }
|
||||
? s
|
||||
: null;
|
||||
|
||||
_publisher.Publish(new ScriptLogEntry(
|
||||
ScriptId: P(ScriptLoggerFactory.ScriptIdProperty)
|
||||
?? P(ScriptLoggerFactory.ScriptNameProperty)
|
||||
?? "unknown",
|
||||
Level: logEvent.Level.ToString(),
|
||||
Message: logEvent.RenderMessage(),
|
||||
TimestampUtc: logEvent.Timestamp.UtcDateTime,
|
||||
VirtualTagId: P(ScriptLoggerFactory.VirtualTagIdProperty),
|
||||
AlarmId: P(ScriptLoggerFactory.AlarmIdProperty),
|
||||
EquipmentId: P(ScriptLoggerFactory.EquipmentIdProperty)));
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,18 @@ public sealed class ScriptLoggerFactory
|
||||
/// <summary>Structured property name the enricher binds. Stable for log filtering.</summary>
|
||||
public const string ScriptNameProperty = "ScriptName";
|
||||
|
||||
/// <summary>Structured property name carrying the Script row identifier (<c>Script.ScriptId</c>).</summary>
|
||||
public const string ScriptIdProperty = "ScriptId";
|
||||
|
||||
/// <summary>Structured property name carrying the VirtualTag identifier, when the script runs in a virtual-tag context.</summary>
|
||||
public const string VirtualTagIdProperty = "VirtualTagId";
|
||||
|
||||
/// <summary>Structured property name carrying the ScriptedAlarm identifier, when the script runs in an alarm context.</summary>
|
||||
public const string AlarmIdProperty = "AlarmId";
|
||||
|
||||
/// <summary>Structured property name carrying the Equipment identifier for per-equipment script evaluations.</summary>
|
||||
public const string EquipmentIdProperty = "EquipmentId";
|
||||
|
||||
private readonly ILogger _rootLogger;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="ScriptLoggerFactory"/> class.</summary>
|
||||
@@ -49,4 +61,34 @@ public sealed class ScriptLoggerFactory
|
||||
throw new ArgumentException("Script name is required.", nameof(scriptName));
|
||||
return _rootLogger.ForContext(ScriptNameProperty, scriptName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a per-evaluation script logger bound with the supplied identity properties.
|
||||
/// <see cref="ScriptIdProperty"/> is always bound; <see cref="VirtualTagIdProperty"/>,
|
||||
/// <see cref="AlarmIdProperty"/>, and <see cref="EquipmentIdProperty"/> are bound only
|
||||
/// when the corresponding argument is non-null. Every event the returned logger emits
|
||||
/// carries these properties so the <see cref="ScriptLogTopicSink"/> can attribute each
|
||||
/// line on the Script-log page.
|
||||
/// </summary>
|
||||
/// <param name="scriptId">The Script row identifier; always bound. Required.</param>
|
||||
/// <param name="virtualTagId">VirtualTag context, when the script runs against a virtual tag.</param>
|
||||
/// <param name="alarmId">ScriptedAlarm context, when the script runs an alarm predicate.</param>
|
||||
/// <param name="equipmentId">Equipment scope, for per-equipment script evaluations.</param>
|
||||
/// <returns>An ILogger instance bound with the supplied identity properties.</returns>
|
||||
/// <exception cref="ArgumentException">Thrown when <paramref name="scriptId"/> is null or whitespace.</exception>
|
||||
public ILogger Create(
|
||||
string scriptId,
|
||||
string? virtualTagId = null,
|
||||
string? alarmId = null,
|
||||
string? equipmentId = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(scriptId))
|
||||
throw new ArgumentException("Script id is required.", nameof(scriptId));
|
||||
|
||||
var logger = _rootLogger.ForContext(ScriptIdProperty, scriptId);
|
||||
if (virtualTagId is not null) logger = logger.ForContext(VirtualTagIdProperty, virtualTagId);
|
||||
if (alarmId is not null) logger = logger.ForContext(AlarmIdProperty, alarmId);
|
||||
if (equipmentId is not null) logger = logger.ForContext(EquipmentIdProperty, equipmentId);
|
||||
return logger;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
/// <summary>
|
||||
/// DI-friendly holder for the root script <see cref="Serilog.ILogger"/> so it can be
|
||||
/// injected into the Roslyn evaluators without clashing with the application's main
|
||||
/// <c>Serilog.Log.Logger</c> (both are <see cref="Serilog.ILogger"/>; a wrapper type
|
||||
/// gives the container an unambiguous registration to resolve).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The wrapped logger is the composed script pipeline — a rolling <c>scripts-*.log</c>
|
||||
/// file sink, the <see cref="ScriptLogCompanionSink"/> mirroring errors to the main log,
|
||||
/// and the <see cref="ScriptLogTopicSink"/> fanning entries onto the cluster
|
||||
/// <c>script-logs</c> topic for the live Script-log Admin UI page.
|
||||
/// <para>
|
||||
/// Implements <see cref="IDisposable"/> so the DI container flushes the rolling-file
|
||||
/// sink on graceful host shutdown.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ScriptRootLogger : IDisposable
|
||||
{
|
||||
private readonly Serilog.Core.Logger _logger;
|
||||
|
||||
/// <summary>The composed root script logger (file + companion mirror + DPS topic sink).</summary>
|
||||
public Serilog.ILogger Logger => _logger;
|
||||
|
||||
/// <summary>Initializes a new <see cref="ScriptRootLogger"/>.</summary>
|
||||
/// <param name="logger">The composed Serilog logger; owned + disposed by this wrapper.</param>
|
||||
public ScriptRootLogger(Serilog.Core.Logger logger) =>
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
/// <summary>Disposes the underlying logger, flushing the rolling-file sink.</summary>
|
||||
public void Dispose() => _logger.Dispose();
|
||||
}
|
||||
@@ -20,6 +20,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
@@ -37,6 +37,38 @@ public sealed class AdminOperationsClient : IAdminOperationsClient
|
||||
return await _proxy.Ask<StartDeploymentResult>(msg, AskTimeout, linked.Token);
|
||||
}
|
||||
|
||||
/// <summary>Acknowledges one alarm via the admin singleton.</summary>
|
||||
/// <param name="alarmId">The alarm's ScriptedAlarmId.</param>
|
||||
/// <param name="user">The acting operator's name.</param>
|
||||
/// <param name="comment">Optional free-text comment; null when none.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>The acknowledge result.</returns>
|
||||
public async Task<AcknowledgeAlarmResult> AcknowledgeAlarmAsync(
|
||||
string alarmId, string user, string? comment, CancellationToken ct)
|
||||
{
|
||||
var msg = new AcknowledgeAlarmCommand(alarmId, user, comment, Guid.NewGuid());
|
||||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
linked.CancelAfter(AskTimeout);
|
||||
return await _proxy.Ask<AcknowledgeAlarmResult>(msg, AskTimeout, linked.Token);
|
||||
}
|
||||
|
||||
/// <summary>Shelves or unshelves one alarm via the admin singleton.</summary>
|
||||
/// <param name="alarmId">The alarm's ScriptedAlarmId.</param>
|
||||
/// <param name="user">The acting operator's name.</param>
|
||||
/// <param name="kind">Which shelve action to perform.</param>
|
||||
/// <param name="unshelveAtUtc">For a timed shelve, when it expires; null otherwise.</param>
|
||||
/// <param name="comment">Optional free-text comment; null when none.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>The shelve result.</returns>
|
||||
public async Task<ShelveAlarmResult> ShelveAlarmAsync(
|
||||
string alarmId, string user, ShelveKind kind, DateTime? unshelveAtUtc, string? comment, CancellationToken ct)
|
||||
{
|
||||
var msg = new ShelveAlarmCommand(alarmId, user, kind, unshelveAtUtc, comment, Guid.NewGuid());
|
||||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
linked.CancelAfter(AskTimeout);
|
||||
return await _proxy.Ask<ShelveAlarmResult>(msg, AskTimeout, linked.Token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generic Ask — forwards any message to the AdminOperationsActor singleton proxy.
|
||||
/// Uses the caller-supplied <paramref name="ct"/> for cancellation; does not impose an
|
||||
|
||||
@@ -4,9 +4,15 @@
|
||||
and the AB CIP ALMD bridge. *@
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs
|
||||
@using ZB.MOM.WW.OtOpcUa.Commons.Interfaces
|
||||
@using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin
|
||||
@using ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts
|
||||
@inject IInProcessBroadcaster<AlarmTransitionEvent> Alarms
|
||||
@inject AuthenticationStateProvider AuthState
|
||||
@inject IAuthorizationService AuthorizationService
|
||||
@inject IAdminOperationsClient AdminOps
|
||||
@implements IDisposable
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
@@ -47,6 +53,10 @@ else
|
||||
<th class="num">Severity</th>
|
||||
<th>User</th>
|
||||
<th>Message</th>
|
||||
@if (_canOperate)
|
||||
{
|
||||
<th>Actions</th>
|
||||
}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -60,6 +70,35 @@ else
|
||||
<td class="num">@e.Severity</td>
|
||||
<td>@e.User</td>
|
||||
<td>@e.Message</td>
|
||||
@if (_canOperate)
|
||||
{
|
||||
@* DriverOperator-gated Acknowledge / Shelve / Unshelve. Each routes through
|
||||
the AdminOperationsActor singleton, which republishes onto the cluster
|
||||
'alarm-commands' topic; the owning node applies it (ownership filter). *@
|
||||
<td>
|
||||
<div class="d-flex gap-1 align-items-center">
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-outline-secondary"
|
||||
disabled="@_busyAlarmId.Equals(e.AlarmId)"
|
||||
@onclick="() => AcknowledgeAsync(e.AlarmId)"
|
||||
title="Acknowledge this alarm">Ack</button>
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-outline-secondary"
|
||||
disabled="@_busyAlarmId.Equals(e.AlarmId)"
|
||||
@onclick="() => ShelveAsync(e.AlarmId, ShelveKind.OneShot)"
|
||||
title="Shelve this alarm until it next clears">Shelve</button>
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-outline-secondary"
|
||||
disabled="@_busyAlarmId.Equals(e.AlarmId)"
|
||||
@onclick="() => ShelveAsync(e.AlarmId, ShelveKind.Unshelve)"
|
||||
title="Remove an existing shelve">Unshelve</button>
|
||||
</div>
|
||||
@if (_opResultAlarmId.Equals(e.AlarmId) && _opResultMessage is not null)
|
||||
{
|
||||
<span class="chip @(_opResultOk ? "chip-ok" : "chip-bad")" style="font-size:0.8rem">@_opResultMessage</span>
|
||||
}
|
||||
</td>
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
@@ -74,13 +113,103 @@ else
|
||||
private readonly List<AlarmTransitionEvent> _rows = new();
|
||||
private bool _connected;
|
||||
|
||||
protected override void OnInitialized()
|
||||
// Authorization — DriverOperator gates the per-row Ack/Shelve/Unshelve controls.
|
||||
private bool _canOperate;
|
||||
|
||||
// Per-row action state. Only one alarm action is in flight at a time; the busy/result
|
||||
// fields are keyed by AlarmId so the spinner + result chip attach to the right row.
|
||||
private string _busyAlarmId = "";
|
||||
private string _opResultAlarmId = "";
|
||||
private string? _opResultMessage;
|
||||
private bool _opResultOk;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
// Live alarm tail straight from the in-process broadcaster (fed by AlertSignalRBridge off the
|
||||
// 'alerts' DPS topic). A Blazor Server component can't self-connect a SignalR HubConnection
|
||||
// behind a reverse proxy — see IInProcessBroadcaster — so we subscribe in-process instead.
|
||||
Alarms.Received += OnAlarm;
|
||||
_connected = true;
|
||||
|
||||
// Check DriverOperator authorization so the per-row action controls only render for
|
||||
// permitted users. The username is re-read at click time (GetCurrentUserNameAsync) so a
|
||||
// mid-session token refresh lands in the published command + audit accurately.
|
||||
var auth = await AuthState.GetAuthenticationStateAsync();
|
||||
var authResult = await AuthorizationService.AuthorizeAsync(auth.User, null, "DriverOperator");
|
||||
_canOperate = authResult.Succeeded;
|
||||
}
|
||||
|
||||
private async Task AcknowledgeAsync(string alarmId)
|
||||
{
|
||||
_busyAlarmId = alarmId;
|
||||
_opResultMessage = null;
|
||||
StateHasChanged();
|
||||
try
|
||||
{
|
||||
var user = await GetCurrentUserNameAsync();
|
||||
using var cts = new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(15));
|
||||
var result = await AdminOps.AcknowledgeAlarmAsync(
|
||||
alarmId, user, comment: null, cts.Token);
|
||||
ShowOpResult(alarmId, result.Ok, result.Ok ? "Ack sent" : (result.Message ?? "Failed"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ShowOpResult(alarmId, false, ex.Message.Length > 60 ? ex.Message[..60] + "…" : ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_busyAlarmId = "";
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ShelveAsync(string alarmId, ShelveKind kind)
|
||||
{
|
||||
_busyAlarmId = alarmId;
|
||||
_opResultMessage = null;
|
||||
StateHasChanged();
|
||||
try
|
||||
{
|
||||
var user = await GetCurrentUserNameAsync();
|
||||
// Timed shelve (with an unshelve-at datetime picker) is deferred — only OneShot + Unshelve
|
||||
// are surfaced here, so unshelveAtUtc is always null. TimedShelve is fully wired through the
|
||||
// singleton + AlarmCommand if a UI is added later.
|
||||
using var cts = new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(15));
|
||||
var result = await AdminOps.ShelveAlarmAsync(
|
||||
alarmId, user, kind, unshelveAtUtc: null, comment: null, cts.Token);
|
||||
var verb = kind == ShelveKind.Unshelve ? "Unshelve" : "Shelve";
|
||||
ShowOpResult(alarmId, result.Ok, result.Ok ? $"{verb} sent" : (result.Message ?? "Failed"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ShowOpResult(alarmId, false, ex.Message.Length > 60 ? ex.Message[..60] + "…" : ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_busyAlarmId = "";
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-reads the AuthenticationState at call time so the operator name forwarded to the
|
||||
/// command + audit reflects the current claims-principal (survives token refresh during a
|
||||
/// long-lived circuit). Returns "unknown" if no Name claim is present.
|
||||
/// </summary>
|
||||
private async Task<string> GetCurrentUserNameAsync()
|
||||
{
|
||||
var auth = await AuthState.GetAuthenticationStateAsync();
|
||||
return auth.User.Identity?.Name
|
||||
?? auth.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value
|
||||
?? "unknown";
|
||||
}
|
||||
|
||||
private void ShowOpResult(string alarmId, bool ok, string message)
|
||||
{
|
||||
_opResultAlarmId = alarmId;
|
||||
_opResultOk = ok;
|
||||
_opResultMessage = message;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void OnAlarm(AlarmTransitionEvent evt) =>
|
||||
|
||||
@@ -4,6 +4,7 @@ using Akka.Event;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
@@ -54,6 +55,87 @@ public sealed class AdminOperationsActor : ReceiveActor
|
||||
ReceiveAsync<TestDriverConnect>(HandleTestDriverConnectAsync);
|
||||
ReceiveAsync<RestartDriver>(HandleRestartDriverAsync);
|
||||
ReceiveAsync<ReconnectDriver>(HandleReconnectDriverAsync);
|
||||
Receive<AcknowledgeAlarmCommand>(HandleAcknowledgeAlarm);
|
||||
Receive<ShelveAlarmCommand>(HandleShelveAlarm);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// AdminUI Acknowledge path. Maps the control-plane command to a
|
||||
/// <see cref="AlarmCommand"/> (<c>Operation = "Acknowledge"</c>) and publishes it onto the
|
||||
/// cluster <c>alarm-commands</c> topic. The broadcast lands on every driver node's
|
||||
/// <c>ScriptedAlarmHostActor</c>; only the node owning the alarm acts (ownership filter), so the
|
||||
/// admin singleton needs no knowledge of placement. Synchronous — the publish is fire-and-forget
|
||||
/// via the mediator, so there is no awaitable work and no DB write.
|
||||
/// </summary>
|
||||
private void HandleAcknowledgeAlarm(AcknowledgeAlarmCommand msg)
|
||||
{
|
||||
var replyTo = Sender;
|
||||
try
|
||||
{
|
||||
var alarmCmd = new AlarmCommand(
|
||||
AlarmId: msg.AlarmId,
|
||||
Operation: "Acknowledge",
|
||||
User: msg.User,
|
||||
Comment: msg.Comment,
|
||||
UnshelveAtUtc: null);
|
||||
|
||||
DistributedPubSub.Get(Context.System).Mediator.Tell(new Publish(AlarmCommandsTopic.Name, alarmCmd));
|
||||
|
||||
_log.Info("AdminOps: Acknowledge published for alarm {AlarmId} by {User}", msg.AlarmId, msg.User);
|
||||
replyTo.Tell(new AcknowledgeAlarmResult(true, null, msg.CorrelationId));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Error(ex, "AdminOps: Acknowledge failed for alarm {AlarmId}", msg.AlarmId);
|
||||
replyTo.Tell(new AcknowledgeAlarmResult(false, ex.Message, msg.CorrelationId));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// AdminUI Shelve / Unshelve path. Maps <see cref="ShelveAlarmCommand.Kind"/> to the matching
|
||||
/// <see cref="AlarmCommand"/> operation (<c>OneShotShelve</c> / <c>TimedShelve</c> /
|
||||
/// <c>Unshelve</c>), threading <see cref="ShelveAlarmCommand.UnshelveAtUtc"/> for the timed kind,
|
||||
/// and publishes onto the cluster <c>alarm-commands</c> topic. Ownership filtering happens on the
|
||||
/// owning node exactly as for Acknowledge.
|
||||
/// </summary>
|
||||
private void HandleShelveAlarm(ShelveAlarmCommand msg)
|
||||
{
|
||||
var replyTo = Sender;
|
||||
try
|
||||
{
|
||||
var (operation, unshelveAt) = msg.Kind switch
|
||||
{
|
||||
ShelveKind.OneShot => ("OneShotShelve", (DateTime?)null),
|
||||
ShelveKind.Timed => ("TimedShelve", msg.UnshelveAtUtc),
|
||||
ShelveKind.Unshelve => ("Unshelve", (DateTime?)null),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(msg), msg.Kind, "Unknown shelve kind."),
|
||||
};
|
||||
|
||||
// TimedShelve requires an unshelve instant — the engine rejects it otherwise. Guard here so
|
||||
// the AdminUI gets an immediate, attributable failure instead of a silently-dropped command.
|
||||
if (msg.Kind == ShelveKind.Timed && unshelveAt is null)
|
||||
{
|
||||
replyTo.Tell(new ShelveAlarmResult(false, "TimedShelve requires UnshelveAtUtc.", msg.CorrelationId));
|
||||
return;
|
||||
}
|
||||
|
||||
var alarmCmd = new AlarmCommand(
|
||||
AlarmId: msg.AlarmId,
|
||||
Operation: operation,
|
||||
User: msg.User,
|
||||
Comment: msg.Comment,
|
||||
UnshelveAtUtc: unshelveAt);
|
||||
|
||||
DistributedPubSub.Get(Context.System).Mediator.Tell(new Publish(AlarmCommandsTopic.Name, alarmCmd));
|
||||
|
||||
_log.Info("AdminOps: {Operation} published for alarm {AlarmId} by {User}", operation, msg.AlarmId, msg.User);
|
||||
replyTo.Tell(new ShelveAlarmResult(true, null, msg.CorrelationId));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Error(ex, "AdminOps: Shelve ({Kind}) failed for alarm {AlarmId}", msg.Kind, msg.AlarmId);
|
||||
replyTo.Tell(new ShelveAlarmResult(false, ex.Message, msg.CorrelationId));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleStartDeploymentAsync(StartDeployment msg)
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
using SerilogLogger = Serilog.ILogger;
|
||||
using SerilogLog = Serilog.Log;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Engines;
|
||||
|
||||
/// <summary>
|
||||
/// F9b — production <see cref="IScriptedAlarmEvaluator"/> binding. Compiles each unique
|
||||
/// predicate once via <see cref="ScriptEvaluator{TContext, TResult}"/> against
|
||||
/// <see cref="AlarmPredicateContext"/> and caches the resulting evaluator. Predicates are
|
||||
/// pure functions returning <c>bool</c>: <see cref="AlarmPredicateContext.SetVirtualTag"/>
|
||||
/// throws so a misbehaving script can't smuggle a side effect into alarm evaluation.
|
||||
///
|
||||
/// Failure modes (compile error, sandbox violation, runtime exception, timeout) all surface
|
||||
/// as <see cref="ScriptedAlarmEvalResult.Failure"/>; <see cref="ScriptedAlarmActor"/>
|
||||
/// preserves the prior state on failure (does not flip Active/Inactive).
|
||||
/// </summary>
|
||||
public sealed class RoslynScriptedAlarmEvaluator : IScriptedAlarmEvaluator, IDisposable
|
||||
{
|
||||
private static readonly SerilogLogger ScriptLogger = SerilogLog.ForContext<RoslynScriptedAlarmEvaluator>();
|
||||
|
||||
private readonly ConcurrentDictionary<string, ScriptEvaluator<AlarmPredicateContext, bool>> _cache
|
||||
= new(StringComparer.Ordinal);
|
||||
private readonly ILogger<RoslynScriptedAlarmEvaluator> _logger;
|
||||
private readonly TimeSpan _runTimeout;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>Initializes a new instance of the Roslyn scripted alarm evaluator.</summary>
|
||||
/// <param name="logger">Logger for diagnostic messages.</param>
|
||||
/// <param name="runTimeout">Optional timeout for script evaluation; defaults to 2 seconds.</param>
|
||||
public RoslynScriptedAlarmEvaluator(ILogger<RoslynScriptedAlarmEvaluator> logger, TimeSpan? runTimeout = null)
|
||||
{
|
||||
_logger = logger;
|
||||
_runTimeout = runTimeout ?? TimeSpan.FromSeconds(2);
|
||||
}
|
||||
|
||||
/// <summary>Evaluates a scripted alarm predicate against provided dependencies.</summary>
|
||||
/// <param name="alarmId">The alarm identifier for logging purposes.</param>
|
||||
/// <param name="predicate">The predicate expression to evaluate.</param>
|
||||
/// <param name="dependencies">Variables available to the predicate expression.</param>
|
||||
/// <returns>Evaluation result with success flag and active state or failure reason.</returns>
|
||||
public ScriptedAlarmEvalResult Evaluate(string alarmId, string predicate, IReadOnlyDictionary<string, object?> dependencies)
|
||||
{
|
||||
if (_disposed) return ScriptedAlarmEvalResult.Failure("evaluator disposed");
|
||||
if (string.IsNullOrWhiteSpace(predicate)) return ScriptedAlarmEvalResult.Failure("empty predicate");
|
||||
|
||||
ScriptEvaluator<AlarmPredicateContext, bool> evaluator;
|
||||
try
|
||||
{
|
||||
evaluator = _cache.GetOrAdd(predicate, ScriptEvaluator<AlarmPredicateContext, bool>.Compile);
|
||||
}
|
||||
catch (CompilationErrorException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Alarm {Id}: predicate compile failed", alarmId);
|
||||
return ScriptedAlarmEvalResult.Failure($"compile error: {ex.Message}");
|
||||
}
|
||||
catch (ScriptSandboxViolationException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Alarm {Id}: predicate sandbox violation", alarmId);
|
||||
return ScriptedAlarmEvalResult.Failure($"sandbox violation: {ex.Message}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Alarm {Id}: predicate compile threw", alarmId);
|
||||
return ScriptedAlarmEvalResult.Failure($"compile failure: {ex.Message}");
|
||||
}
|
||||
|
||||
var readCache = BuildReadCache(dependencies);
|
||||
var context = new AlarmPredicateContext(readCache, ScriptLogger);
|
||||
|
||||
try
|
||||
{
|
||||
using var cts = new CancellationTokenSource(_runTimeout);
|
||||
var active = evaluator.RunAsync(context, cts.Token).GetAwaiter().GetResult();
|
||||
return ScriptedAlarmEvalResult.Ok(active);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return ScriptedAlarmEvalResult.Failure($"predicate timed out after {_runTimeout.TotalSeconds:F1}s");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Alarm {Id}: predicate execution threw", alarmId);
|
||||
return ScriptedAlarmEvalResult.Failure($"predicate threw: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, DataValueSnapshot> BuildReadCache(
|
||||
IReadOnlyDictionary<string, object?> deps)
|
||||
{
|
||||
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 and all cached script evaluators.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
foreach (var ev in _cache.Values)
|
||||
{
|
||||
try { ev.Dispose(); } catch { /* best-effort */ }
|
||||
}
|
||||
_cache.Clear();
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ 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;
|
||||
using SerilogLog = Serilog.Log;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Engines;
|
||||
|
||||
@@ -23,20 +22,24 @@ namespace ZB.MOM.WW.OtOpcUa.Host.Engines;
|
||||
/// </summary>
|
||||
public sealed class RoslynVirtualTagEvaluator : IVirtualTagEvaluator, IDisposable
|
||||
{
|
||||
private static readonly SerilogLogger ScriptLogger = SerilogLog.ForContext<RoslynVirtualTagEvaluator>();
|
||||
|
||||
private readonly ConcurrentDictionary<string, ScriptEvaluator<VirtualTagContext, object?>> _cache
|
||||
= new(StringComparer.Ordinal);
|
||||
private readonly ILogger<RoslynVirtualTagEvaluator> _logger;
|
||||
private readonly SerilogLogger _scriptRoot;
|
||||
private readonly TimeSpan _runTimeout;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>Initializes a new RoslynVirtualTagEvaluator with the given logger and optional timeout.</summary>
|
||||
/// <param name="logger">Logger for recording compilation and execution errors.</param>
|
||||
/// <summary>Initializes a new RoslynVirtualTagEvaluator with the given loggers and optional timeout.</summary>
|
||||
/// <param name="logger">Logger for recording compilation and execution errors (host 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">Maximum execution time for each script; defaults to 2 seconds if not specified.</param>
|
||||
public RoslynVirtualTagEvaluator(ILogger<RoslynVirtualTagEvaluator> logger, TimeSpan? runTimeout = null)
|
||||
public RoslynVirtualTagEvaluator(
|
||||
ILogger<RoslynVirtualTagEvaluator> logger,
|
||||
ScriptRootLogger scriptRoot,
|
||||
TimeSpan? runTimeout = null)
|
||||
{
|
||||
_logger = logger;
|
||||
_scriptRoot = (scriptRoot ?? throw new ArgumentNullException(nameof(scriptRoot))).Logger;
|
||||
_runTimeout = runTimeout ?? TimeSpan.FromSeconds(2);
|
||||
}
|
||||
|
||||
@@ -84,12 +87,18 @@ public sealed class RoslynVirtualTagEvaluator : IVirtualTagEvaluator, IDisposabl
|
||||
}
|
||||
|
||||
var readCache = BuildReadCache(dependencies);
|
||||
// Per-evaluation script logger: bind both ScriptId and VirtualTagId from the virtual-tag id
|
||||
// (in the live path the script id equals the virtual-tag id) so the Script-log page can
|
||||
// attribute each line. EquipmentId stays unbound for now.
|
||||
var scriptLog = _scriptRoot
|
||||
.ForContext(ScriptLoggerFactory.ScriptIdProperty, virtualTagId)
|
||||
.ForContext(ScriptLoggerFactory.VirtualTagIdProperty, virtualTagId);
|
||||
var context = new VirtualTagContext(
|
||||
readCache,
|
||||
setVirtualTag: (path, _) =>
|
||||
_logger.LogDebug("VirtualTag {Id}: cross-tag write to {Path} dropped (single-tag adapter)",
|
||||
virtualTagId, path),
|
||||
logger: ScriptLogger);
|
||||
logger: scriptLog);
|
||||
|
||||
try
|
||||
{
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
using Serilog;
|
||||
using Serilog.Events;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Logging;
|
||||
|
||||
/// <summary>
|
||||
/// Composes the root script <see cref="Serilog.ILogger"/> — the single pipeline every
|
||||
/// per-script logger derives from. Fans each script log event out to three destinations:
|
||||
/// a dedicated rolling <c>scripts-*.log</c> file, the main server log (via
|
||||
/// <see cref="ScriptLogCompanionSink"/>, mirroring Error-or-higher events at Warning), and
|
||||
/// the cluster <c>script-logs</c> DPS topic (via <see cref="ScriptLogTopicSink"/>) so the
|
||||
/// live Script-log Admin UI page can tail script output.
|
||||
/// </summary>
|
||||
public static class ScriptRootLoggerFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds the composed root script logger.
|
||||
/// </summary>
|
||||
/// <param name="publisher">
|
||||
/// The publisher the topic sink forwards entries to (the DPS publisher in production).
|
||||
/// </param>
|
||||
/// <param name="filePath">
|
||||
/// Path template for the rolling <c>scripts-*.log</c> file sink (e.g. <c>logs/scripts-.log</c>).
|
||||
/// </param>
|
||||
/// <param name="topicMinLevel">
|
||||
/// Minimum level for events forwarded to the <c>script-logs</c> topic; lower-level events
|
||||
/// still land in the file sink but are not pushed onto the cluster bus.
|
||||
/// </param>
|
||||
/// <param name="companionLogger">
|
||||
/// The application's main logger that receives mirrored Error-or-higher script events at
|
||||
/// Warning level. Passed explicitly so callers control the source rather than reading the
|
||||
/// global <see cref="Serilog.Log.Logger"/> static inside the factory.
|
||||
/// </param>
|
||||
/// <returns>The composed root script logger.</returns>
|
||||
public static Serilog.Core.Logger Build(
|
||||
IScriptLogPublisher publisher, string filePath, LogEventLevel topicMinLevel,
|
||||
Serilog.ILogger companionLogger)
|
||||
=> new LoggerConfiguration()
|
||||
.MinimumLevel.Verbose()
|
||||
.WriteTo.File(filePath, rollingInterval: RollingInterval.Day)
|
||||
.WriteTo.Sink(new ScriptLogCompanionSink(companionLogger))
|
||||
.WriteTo.Sink(new ScriptLogTopicSink(publisher, topicMinLevel))
|
||||
.CreateLogger();
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
using Akka.Actor;
|
||||
using Akka.Cluster.Tools.PublishSubscribe;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||
|
||||
@@ -24,6 +27,7 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
|
||||
private readonly DeferredAddressSpaceSink _deferredSink;
|
||||
private readonly DeferredServiceLevelPublisher _deferredServiceLevel;
|
||||
private readonly IOpcUaUserAuthenticator _userAuthenticator;
|
||||
private readonly Func<ActorSystem> _actorSystemAccessor;
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private readonly ILogger<OtOpcUaServerHostedService> _logger;
|
||||
|
||||
@@ -37,18 +41,23 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
|
||||
/// <param name="deferredSink">The deferred address space sink that receives the real sink once the server is ready.</param>
|
||||
/// <param name="deferredServiceLevel">The deferred service level publisher that receives the real publisher once the server is ready.</param>
|
||||
/// <param name="userAuthenticator">The OPC UA user authenticator.</param>
|
||||
/// <param name="actorSystemAccessor">Lazy accessor for the running <see cref="ActorSystem"/>, used to
|
||||
/// resolve the DistributedPubSub mediator the inbound alarm-command router publishes through. Resolved
|
||||
/// lazily (mirroring <c>DpsScriptLogPublisher</c>) so construction never races Akka startup.</param>
|
||||
/// <param name="loggerFactory">The logger factory for creating loggers.</param>
|
||||
public OtOpcUaServerHostedService(
|
||||
IOptions<OpcUaApplicationHostOptions> options,
|
||||
DeferredAddressSpaceSink deferredSink,
|
||||
DeferredServiceLevelPublisher deferredServiceLevel,
|
||||
IOpcUaUserAuthenticator userAuthenticator,
|
||||
Func<ActorSystem> actorSystemAccessor,
|
||||
ILoggerFactory loggerFactory)
|
||||
{
|
||||
_options = options.Value;
|
||||
_deferredSink = deferredSink;
|
||||
_deferredServiceLevel = deferredServiceLevel;
|
||||
_userAuthenticator = userAuthenticator;
|
||||
_actorSystemAccessor = actorSystemAccessor;
|
||||
_loggerFactory = loggerFactory;
|
||||
_logger = loggerFactory.CreateLogger<OtOpcUaServerHostedService>();
|
||||
}
|
||||
@@ -88,6 +97,30 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
|
||||
|
||||
_deferredSink.SetSink(new SdkAddressSpaceSink(_server.NodeManager));
|
||||
|
||||
// Wire the reverse-path inbound-alarm-command router: a client Acknowledge/Confirm/Shelve that
|
||||
// passes the node manager's AlarmAck gate publishes the mapped AlarmCommand onto the cluster
|
||||
// `alarm-commands` topic (same DistributedPubSub mediator the `alerts`/`script-logs` topics use).
|
||||
// The Tell is fire-and-forget so the handler — which runs under the SDK's Lock — never blocks.
|
||||
// The mediator is resolved per-publish via the lazy ActorSystem accessor so a transient cluster
|
||||
// condition is tolerated and construction never raced Akka startup.
|
||||
_server.SetAlarmCommandRouter(cmd =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var mediator = DistributedPubSub.Get(_actorSystemAccessor()).Mediator;
|
||||
mediator.Tell(new Publish(ScriptedAlarmHostActor.AlarmCommandsTopic, cmd));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// The router runs under the SDK Lock on a server thread; a cluster hiccup must not
|
||||
// escape into the SDK's Call path. Log + drop — the client still gets Good for the
|
||||
// node-state change; the missed command surfaces as a non-applied engine transition.
|
||||
_logger.LogWarning(ex,
|
||||
"OtOpcUaServerHostedService: failed to route inbound alarm command {Operation} for {AlarmId}",
|
||||
cmd.Operation, cmd.AlarmId);
|
||||
}
|
||||
});
|
||||
|
||||
// ServiceLevel publisher needs IServerInternal — only available after Start.
|
||||
if (_server.CurrentInstance is { } serverInternal)
|
||||
{
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using Akka.Actor;
|
||||
using Akka.Hosting;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Serilog;
|
||||
using Serilog.Events;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Clients;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Components;
|
||||
@@ -11,14 +13,17 @@ using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.ControlPlane;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
using ZB.MOM.WW.OtOpcUa.Host;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Drivers;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Engines;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Health;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Observability;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Scripting;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Roles;
|
||||
@@ -104,14 +109,25 @@ if (hasDriver)
|
||||
// Replaces the F8-default NullVirtualTagEvaluator so VirtualTagActor evaluates real user
|
||||
// scripts at runtime.
|
||||
builder.Services.AddSingleton<RoslynVirtualTagEvaluator>(sp =>
|
||||
new RoslynVirtualTagEvaluator(sp.GetRequiredService<ILoggerFactory>().CreateLogger<RoslynVirtualTagEvaluator>()));
|
||||
new RoslynVirtualTagEvaluator(
|
||||
sp.GetRequiredService<ILoggerFactory>().CreateLogger<RoslynVirtualTagEvaluator>(),
|
||||
sp.GetRequiredService<ScriptRootLogger>()));
|
||||
builder.Services.AddSingleton<IVirtualTagEvaluator>(sp => sp.GetRequiredService<RoslynVirtualTagEvaluator>());
|
||||
|
||||
// F9b — same pattern for scripted-alarm predicates. The actor preserves prior state on
|
||||
// any Failure result, so a misbehaving script can't flip Active/Inactive spuriously.
|
||||
builder.Services.AddSingleton<RoslynScriptedAlarmEvaluator>(sp =>
|
||||
new RoslynScriptedAlarmEvaluator(sp.GetRequiredService<ILoggerFactory>().CreateLogger<RoslynScriptedAlarmEvaluator>()));
|
||||
builder.Services.AddSingleton<IScriptedAlarmEvaluator>(sp => sp.GetRequiredService<RoslynScriptedAlarmEvaluator>());
|
||||
// Script-log fan-out (Layer 0). The DPS publisher resolves the ActorSystem lazily so it never
|
||||
// races Akka startup. ScriptRootLogger wraps the composed pipeline (rolling scripts-*.log +
|
||||
// error mirror to the main log + script-logs DPS topic) for unambiguous DI resolution; Task 3
|
||||
// injects it into the Roslyn evaluators above.
|
||||
var scriptLogFilePath = builder.Configuration["Scripting:LogFilePath"] ?? "logs/scripts-.log";
|
||||
var scriptLogTopicMinLevel = Enum.TryParse<LogEventLevel>(
|
||||
builder.Configuration["Scripting:LogTopicMinLevel"], ignoreCase: true, out var parsedLevel)
|
||||
? parsedLevel
|
||||
: LogEventLevel.Information;
|
||||
builder.Services.AddSingleton<IScriptLogPublisher>(sp =>
|
||||
new DpsScriptLogPublisher(() => sp.GetRequiredService<ActorSystem>()));
|
||||
builder.Services.AddSingleton(sp => new ScriptRootLogger(
|
||||
ScriptRootLoggerFactory.Build(
|
||||
sp.GetRequiredService<IScriptLogPublisher>(), scriptLogFilePath, scriptLogTopicMinLevel, Serilog.Log.Logger)));
|
||||
|
||||
builder.Services.AddValidatedOptions<LdapOptions, LdapOptionsValidator>(builder.Configuration, LdapOptions.SectionName);
|
||||
// TryAdd so a fused admin+driver node (where AddOtOpcUaAuth also registers these) ends up
|
||||
@@ -131,6 +147,10 @@ if (hasDriver)
|
||||
builder.Services.AddValidatedOptions<OpcUaApplicationHostOptions, OpcUaApplicationHostOptionsValidator>(
|
||||
builder.Configuration, "OpcUa");
|
||||
|
||||
// Lazy ActorSystem accessor so OtOpcUaServerHostedService can resolve the DistributedPubSub
|
||||
// mediator (for the inbound alarm-command router) without racing Akka startup — same pattern the
|
||||
// DpsScriptLogPublisher above uses. TryAdd so a fused admin+driver node registers it exactly once.
|
||||
builder.Services.TryAddSingleton<Func<ActorSystem>>(sp => () => sp.GetRequiredService<ActorSystem>());
|
||||
builder.Services.AddHostedService<OtOpcUaServerHostedService>();
|
||||
}
|
||||
|
||||
|
||||
@@ -5,5 +5,10 @@
|
||||
{ "Name": "Console" },
|
||||
{ "Name": "File", "Args": { "path": "logs/otopcua-.log", "rollingInterval": "Day" } }
|
||||
]
|
||||
},
|
||||
"Security": {
|
||||
"Auth": {
|
||||
"DisableLogin": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,7 +289,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
args.Identity = new UserIdentity(token);
|
||||
args.Identity = new RoleCarryingUserIdentity(token, result.Roles);
|
||||
logger.LogInformation("OpcUaApplicationHost: UserName auth granted for {User} ({Roles})",
|
||||
token.UserName, string.Join(",", result.Roles));
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Collections.Concurrent;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Server;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
|
||||
@@ -28,6 +29,12 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
|
||||
private readonly ConcurrentDictionary<string, BaseDataVariableState> _variables = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, FolderState> _folders = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, AlarmConditionState> _alarmConditions = new(StringComparer.Ordinal);
|
||||
/// <summary>Folders we have already promoted to event-notifiers + registered as root notifiers,
|
||||
/// so repeated <see cref="MaterialiseAlarmCondition"/> calls don't double-add (idempotent guard).
|
||||
/// Keyed by NodeId → the actual <see cref="FolderState"/> so <see cref="RebuildAddressSpace"/> can
|
||||
/// pass the folder to <c>RemoveRootNotifier</c> on teardown.</summary>
|
||||
private readonly Dictionary<NodeId, FolderState> _notifierFolders = new();
|
||||
private FolderState? _root;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="OtOpcUaNodeManager"/> class with the OPC UA server and configuration.</summary>
|
||||
@@ -43,6 +50,32 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
public int VariableCount => _variables.Count;
|
||||
/// <summary>Gets the count of folder nodes currently managed.</summary>
|
||||
public int FolderCount => _folders.Count;
|
||||
/// <summary>Gets the count of real Part 9 <see cref="AlarmConditionState"/> nodes currently managed.</summary>
|
||||
public int AlarmConditionCount => _alarmConditions.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Reverse-path sink for inbound OPC UA Part 9 alarm method calls. When a client invokes a
|
||||
/// materialised condition's Acknowledge / Confirm / Shelve / AddComment method, the condition's
|
||||
/// handler (wired in <see cref="MaterialiseAlarmCondition"/>) gates on the caller's
|
||||
/// <c>AlarmAck</c> role and, when allowed, builds an <see cref="AlarmCommand"/> and invokes this
|
||||
/// delegate. The host sets it at boot to a non-blocking <c>mediator.Tell</c> onto the
|
||||
/// <c>alarm-commands</c> DistributedPubSub topic; T19's engine-side subscriber consumes it.
|
||||
/// <para>
|
||||
/// This is the ONLY reverse coupling out of the node manager — by design it is a plain
|
||||
/// <see cref="Action{AlarmCommand}"/> (no Akka / <c>IActorRef</c> / DI handle). The handler
|
||||
/// delegates run under the manager's <c>Lock</c>; the invoked action MUST be non-blocking
|
||||
/// (a fire-and-forget <c>Tell</c>) so there is no deadlock. Null (the default) makes every
|
||||
/// handler a safe no-op — it still gates + returns, just routes nowhere.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public Action<AlarmCommand>? AlarmCommandRouter { get; set; }
|
||||
|
||||
/// <summary>Look up a materialised Part 9 alarm-condition node by its alarm node id (the
|
||||
/// ScriptedAlarmId), or null if not yet materialised. Exposed for tests + diagnostics.</summary>
|
||||
/// <param name="alarmNodeId">The alarm node identifier (== ScriptedAlarmId).</param>
|
||||
/// <returns>The cached <see cref="AlarmConditionState"/>, or null when none is registered.</returns>
|
||||
public AlarmConditionState? TryGetAlarmCondition(string alarmNodeId) =>
|
||||
_alarmConditions.TryGetValue(alarmNodeId, out var condition) ? condition : null;
|
||||
|
||||
/// <summary>
|
||||
/// Apply a value write from <see cref="IOpcUaAddressSpaceSink.WriteValue"/>. Creates the
|
||||
@@ -56,10 +89,17 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(nodeId);
|
||||
var variable = _variables.GetOrAdd(nodeId, CreateVariable);
|
||||
|
||||
lock (Lock)
|
||||
{
|
||||
// CreateVariable mutates the SDK address space (_root.AddChild + AddPredefinedNode),
|
||||
// so it MUST run under Lock — the SDK's subscription/ConditionRefresh threads take it too.
|
||||
if (!_variables.TryGetValue(nodeId, out var variable))
|
||||
{
|
||||
variable = CreateVariable(nodeId);
|
||||
_variables[nodeId] = variable;
|
||||
}
|
||||
|
||||
variable.Value = value;
|
||||
variable.StatusCode = StatusFromQuality(quality);
|
||||
variable.Timestamp = sourceTimestampUtc;
|
||||
@@ -67,27 +107,484 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Apply an alarm-state write. Surfaced as a two-element Variable carrying
|
||||
/// <c>[active, acknowledged]</c> — proper <c>AlarmConditionState</c> + event firing
|
||||
/// comes when the F14b walker integration lands and registers real condition nodes.</summary>
|
||||
/// <param name="alarmNodeId">The node identifier of the alarm variable.</param>
|
||||
/// <param name="active">Whether the alarm is currently active.</param>
|
||||
/// <param name="acknowledged">Whether the alarm has been acknowledged.</param>
|
||||
/// <summary>
|
||||
/// Apply a full Part 9 alarm-condition write. When a real <see cref="AlarmConditionState"/> has
|
||||
/// been materialised for <paramref name="alarmNodeId"/> (via <see cref="MaterialiseAlarmCondition"/>),
|
||||
/// this projects the whole <paramref name="state"/> snapshot
|
||||
/// (Enabled / Active / Acked / Confirmed / Shelving / Severity / Message) onto the live condition
|
||||
/// node and recomputes Retain (T15 — richer state; <b>still no event firing</b>, that lands in T16).
|
||||
/// Otherwise it falls back to the legacy two-element <c>[Active, Acknowledged]</c>
|
||||
/// <see cref="BaseDataVariableState"/> placeholder so callers whose alarm node hasn't been
|
||||
/// materialised (and the existing unit tests) keep working.
|
||||
/// </summary>
|
||||
/// <param name="alarmNodeId">The node identifier of the alarm (== ScriptedAlarmId for materialised conditions).</param>
|
||||
/// <param name="state">The full condition state to project onto the node.</param>
|
||||
/// <param name="sourceTimestampUtc">The timestamp of the alarm state change in UTC.</param>
|
||||
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(alarmNodeId);
|
||||
var variable = _variables.GetOrAdd(alarmNodeId, CreateVariable);
|
||||
ArgumentNullException.ThrowIfNull(state);
|
||||
|
||||
// Look up + project under a SINGLE Lock so a concurrent RebuildAddressSpace can't clear
|
||||
// _alarmConditions / detach the condition node between the lookup and the Set* calls.
|
||||
lock (Lock)
|
||||
{
|
||||
variable.Value = new[] { active, acknowledged };
|
||||
if (_alarmConditions.TryGetValue(alarmNodeId, out var condition))
|
||||
{
|
||||
// T20 delta-gate: read the node's CURRENT live condition state FIRST (before projecting
|
||||
// the incoming snapshot onto it), then decide fire-vs-suppress by comparing the incoming
|
||||
// snapshot to that current state. We gate against the NODE's state, NOT a "last written"
|
||||
// cache, because an inbound client ack the SDK applied (OnAcknowledge returned Good →
|
||||
// SDK mutated AckedState + auto-fired its own event) NEVER passed through this method, so a
|
||||
// last-written cache would be stale and wrongly report a delta. By the time the engine
|
||||
// re-projects that ack here, the node already holds the acked state → no delta → suppress.
|
||||
var current = ReadConditionDelta(condition);
|
||||
var incoming = ToConditionDelta(state, condition);
|
||||
bool fire = ShouldFireConditionEvent(current, incoming);
|
||||
|
||||
// EnabledState / AckedState / ActiveState are mandatory children — always present after
|
||||
// Create. Confirm + Shelving are optional Part 9 children: T14's real-server finding is
|
||||
// that Create auto-builds them for our subtypes, but a base AlarmConditionState (or a
|
||||
// future SDK that builds a leaner child set) may leave them null. Null-guard each optional
|
||||
// child so projecting Confirmed/Shelving onto a node that lacks the sub-state machine is a
|
||||
// no-op rather than an NRE.
|
||||
condition.SetEnableState(SystemContext, state.Enabled);
|
||||
condition.SetActiveState(SystemContext, state.Active);
|
||||
condition.SetAcknowledgedState(SystemContext, state.Acknowledged);
|
||||
if (condition.ConfirmedState is not null)
|
||||
{
|
||||
condition.SetConfirmedState(SystemContext, state.Confirmed);
|
||||
}
|
||||
if (condition.ShelvingState is not null)
|
||||
{
|
||||
// SetShelvingState(shelved, oneShot, shelvingTime): map our 3-way kind onto the SDK's
|
||||
// (shelved, oneShot) flag pair. Timed shelving's expiry is owned by the engine, not the
|
||||
// SDK timer, so we pass shelvingTime=0 (no SDK-managed auto-unshelve).
|
||||
condition.SetShelvingState(
|
||||
SystemContext,
|
||||
shelved: state.Shelving != AlarmShelvingKind.Unshelved,
|
||||
oneShot: state.Shelving == AlarmShelvingKind.OneShot,
|
||||
shelvingTime: 0);
|
||||
}
|
||||
condition.SetSeverity(SystemContext, MapSeverity(state.Severity));
|
||||
condition.Message.Value = new LocalizedText(state.Message);
|
||||
|
||||
// Part 9: retain the condition while it is active OR unacknowledged so a client's
|
||||
// ConditionRefresh replays it. The event firing below also depends on this Retain being
|
||||
// correct (a non-retained inactive+acked condition still fires its transition event, but
|
||||
// won't be replayed on a later ConditionRefresh).
|
||||
condition.Retain.Value = state.Active || !state.Acknowledged;
|
||||
condition.Time.Value = sourceTimestampUtc;
|
||||
condition.ReceiveTime.Value = sourceTimestampUtc;
|
||||
|
||||
// T20 — fire a real Part 9 condition event ONLY when this projection is a genuine state
|
||||
// change (the delta-gate decided above, against the node's pre-projection state). A
|
||||
// genuine engine-driven transition (alarm goes active/clear, severity bucket shifts, an
|
||||
// engine-side ack, etc.) differs from the node's current state → fire. The re-projection
|
||||
// of a client ack the SDK already applied equals the node's current state → no delta →
|
||||
// suppress, so we don't double-emit (E2 from the SDK + E3 from here). ReportConditionEvent
|
||||
// stamps a fresh EventId, ClearChangeMasks, and ReportEvent — all still under this lock.
|
||||
if (fire)
|
||||
{
|
||||
ReportConditionEvent(condition, sourceTimestampUtc);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: alarm not materialised as a real condition — keep the legacy bool[2] variable so
|
||||
// un-materialised callers (and the existing unit tests) keep working. CreateVariable mutates
|
||||
// the SDK address space, so it MUST run under Lock (see WriteValue).
|
||||
if (!_variables.TryGetValue(alarmNodeId, out var variable))
|
||||
{
|
||||
variable = CreateVariable(alarmNodeId);
|
||||
_variables[alarmNodeId] = variable;
|
||||
}
|
||||
|
||||
variable.Value = new[] { state.Active, state.Acknowledged };
|
||||
variable.StatusCode = StatusCodes.Good;
|
||||
variable.Timestamp = sourceTimestampUtc;
|
||||
variable.ClearChangeMasks(SystemContext, includeChildren: false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fire a real OPC UA Part 9 condition event for one engine-driven state transition on a
|
||||
/// materialised <see cref="AlarmConditionState"/>. The caller MUST already hold <c>Lock</c> and
|
||||
/// have applied the new state via the <c>Set*</c> projection — this stamps a fresh per-event
|
||||
/// <c>EventId</c>, <c>ClearChangeMasks</c>, then <c>ReportEvent</c> with an
|
||||
/// <see cref="InstanceStateSnapshot"/> (a frozen copy of the condition's children at fire time,
|
||||
/// so a subscribing client sees the values at this instant even if the live node mutates after).
|
||||
/// <para>
|
||||
/// A fresh <c>EventId</c> per event is a Part 9 requirement: inbound Acknowledge / Confirm /
|
||||
/// AddComment calls are correlated back to a specific event by this id (the SDK matches it via
|
||||
/// <c>GetEventByEventId</c> / <c>GetBranch</c>), so T17's ack routing relies on it being unique
|
||||
/// per emission. We use the main branch only (<c>BranchId == NodeId.Null</c>, set at
|
||||
/// materialise) — no branch creation here.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Double-emit note (resolved by delta-gate).</b> An inbound client Acknowledge/Confirm
|
||||
/// goes through the SDK's own handler, which (after T18's gate returns Good) applies the acked
|
||||
/// state to the node and auto-fires its own condition event (E2) — directly on the node,
|
||||
/// BYPASSING <see cref="WriteAlarmCondition"/>. The engine then re-projects that same logical
|
||||
/// transition through <see cref="WriteAlarmCondition"/>, which would otherwise fire a second
|
||||
/// event (E3). <see cref="WriteAlarmCondition"/>'s delta-gate suppresses E3: it compares the
|
||||
/// incoming snapshot against the NODE's CURRENT state, and because the SDK has ALREADY
|
||||
/// pre-applied the inbound-ack state, the re-projection is a no-delta no-op (no fire). Genuine
|
||||
/// engine-driven transitions still differ from the node's current state, so they fire here as
|
||||
/// before.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="alarm">The materialised condition whose new state has already been projected; must be non-null.</param>
|
||||
/// <param name="ts">The source/receive timestamp (UTC) for this event.</param>
|
||||
private void ReportConditionEvent(AlarmConditionState alarm, DateTime ts)
|
||||
{
|
||||
// Fresh GUID-bytes EventId per event — mandatory for Part 9 ack correlation (T17 relies on it).
|
||||
alarm.EventId.Value = Guid.NewGuid().ToByteArray();
|
||||
// Time/ReceiveTime were already set to sourceTimestampUtc by the WriteAlarmCondition projection
|
||||
// immediately above; the assignment here is a locality repeat (same value, no behavioral change)
|
||||
// so the restamp is co-located with the EventId and ClearChangeMasks in the same method.
|
||||
alarm.Time.Value = ts;
|
||||
alarm.ReceiveTime.Value = ts;
|
||||
|
||||
// Snapshot the children, then notify subscribers. ClearChangeMasks must precede the snapshot so
|
||||
// the InstanceStateSnapshot captures the just-projected values.
|
||||
alarm.ClearChangeMasks(SystemContext, includeChildren: true);
|
||||
|
||||
try
|
||||
{
|
||||
// InstanceStateSnapshot is the IFilterTarget — a frozen copy of the condition's fields at fire
|
||||
// time. ReportEvent walks inverse notifier references up to the root-notifier folder (promoted
|
||||
// in MaterialiseAlarmCondition), whose OnReportEvent hands off to Server.ReportEvent → the
|
||||
// event reaches subscribed monitored items.
|
||||
var snapshot = new InstanceStateSnapshot();
|
||||
snapshot.Initialize(SystemContext, alarm);
|
||||
alarm.ReportEvent(SystemContext, snapshot);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// A failed event report must NOT break the state projection or the calling actor: the node's
|
||||
// state has already been applied + ClearChangeMasks'd, so attribute subscribers still see the
|
||||
// change; only the event delivery is lost. This CustomNodeManager2 carries no ILogger, so log
|
||||
// through the SDK's static trace (Utils.LogError) instead of swallowing silently — a recurring
|
||||
// failure here is then visible in the server log rather than invisible. T19's live Client.CLI
|
||||
// run is the integration proof that the happy path delivers.
|
||||
// Utils.LogError routes to the SDK's trace sink. It's [Obsolete] in 1.5.378 in favour of an
|
||||
// ITelemetryContext/ILogger this CustomNodeManager2 doesn't have wired — suppress the
|
||||
// deprecation here (wiring the telemetry logger through is a separate follow-up); the point is
|
||||
// that a recurring failure is visible in the server trace rather than silently swallowed.
|
||||
#pragma warning disable CS0618 // Type or member is obsolete
|
||||
Utils.LogError(ex, "OtOpcUaNodeManager: failed to report Part 9 condition event for {0}", alarm.NodeId);
|
||||
#pragma warning restore CS0618
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The gate-relevant slice of a Part 9 condition's state — exactly the fields that drive a
|
||||
/// condition event AND that an <see cref="AlarmConditionSnapshot"/> can change. As a record, two
|
||||
/// instances compare by value, so <see cref="ShouldFireConditionEvent"/> is a plain inequality.
|
||||
/// <para>
|
||||
/// <b>Severity</b> is stored as the MAPPED <see cref="EventSeverity"/> bucket (a
|
||||
/// <see cref="ushort"/>) — the same value the node holds after <c>SetSeverity</c> — so two
|
||||
/// raw severities that fall in the same bucket are correctly treated as "no change". The
|
||||
/// <b>Shelving</b> kind is read back from / mapped to the SDK's shelving state machine so the
|
||||
/// live node and the snapshot compare on the same 3-way (Unshelved/OneShot/Timed) axis.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Why <c>CommentAdded</c> is not a field here (intentional).</b>
|
||||
/// <c>EmissionKind.CommentAdded</c> is produced only by
|
||||
/// <c>Part9StateMachine.ApplyAddComment</c>, which is reached only via
|
||||
/// <c>ScriptedAlarmEngine.AddCommentAsync</c>, which is called only from
|
||||
/// <c>ScriptedAlarmHostActor</c>'s inbound <c>AlarmCommand</c> handler — meaning
|
||||
/// <c>CommentAdded</c> ALWAYS originates from a client calling the condition's
|
||||
/// <c>AddComment</c> method. On that path T18's <c>OnAddComment</c> delegate returns
|
||||
/// <c>ServiceResult.Good</c>, so the OPC UA SDK itself applies the comment to the node
|
||||
/// and auto-fires the Part 9 comment event (E2) directly to subscribers — BEFORE the
|
||||
/// engine re-projects via <see cref="WriteAlarmCondition"/>. When that re-projection
|
||||
/// arrives here, the delta-gate sees no change in any compared field (the snapshot carries
|
||||
/// no comments list) and correctly suppresses a second event (E3). Force-firing for
|
||||
/// <c>CommentAdded</c> would double-emit. There is no engine-internal or script-driven
|
||||
/// comment path, so suppression never drops a needed event.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Why <c>Retain</c> is absent (intentional — safe today).</b>
|
||||
/// <c>Retain</c> is projected as <c>state.Active || !state.Acknowledged</c> in
|
||||
/// <see cref="WriteAlarmCondition"/>. Every path that flips <c>Retain</c> necessarily
|
||||
/// changes <c>Active</c> or <c>Acknowledged</c> (both ARE compared fields), so a
|
||||
/// <c>Retain</c> flip always rides along with a real delta and fires correctly. If a
|
||||
/// future engine were to set <c>Retain</c> independently — without touching
|
||||
/// <c>Active</c>/<c>Acknowledged</c> — it would need to be added here.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal readonly record struct AlarmConditionDelta(
|
||||
bool Active,
|
||||
bool Acknowledged,
|
||||
bool Confirmed,
|
||||
bool Enabled,
|
||||
AlarmShelvingKind Shelving,
|
||||
ushort MappedSeverity,
|
||||
string Message);
|
||||
|
||||
/// <summary>Decide whether a <see cref="WriteAlarmCondition"/> projection is a genuine state change
|
||||
/// (and so should fire a Part 9 condition event) by comparing the node's pre-projection state to the
|
||||
/// incoming snapshot. Pure + value-based so it's unit-testable in isolation: returns <c>true</c> iff
|
||||
/// any gate-relevant field differs. An inbound client ack the SDK already applied makes
|
||||
/// <paramref name="current"/> == <paramref name="incoming"/> ⇒ <c>false</c> (suppress the re-projected
|
||||
/// double-emit); a genuine engine-driven transition differs ⇒ <c>true</c> (fire).</summary>
|
||||
/// <param name="current">The node's current (pre-projection) gate-relevant state.</param>
|
||||
/// <param name="incoming">The incoming snapshot's gate-relevant state.</param>
|
||||
/// <returns><c>true</c> to fire a condition event; <c>false</c> to suppress (no delta).</returns>
|
||||
internal static bool ShouldFireConditionEvent(AlarmConditionDelta current, AlarmConditionDelta incoming) =>
|
||||
current != incoming;
|
||||
|
||||
/// <summary>Read the gate-relevant slice off the LIVE condition node. Mandatory children
|
||||
/// (Active/Acked/Enabled) are always present; Confirmed/Shelving are optional and null-guarded
|
||||
/// (a leaner child set ⇒ treat as the unset default). Severity is read as the already-mapped
|
||||
/// <see cref="EventSeverity"/> bucket the node stores, and shelving is mapped from the shelving
|
||||
/// state machine's CurrentState so it lines up with <see cref="ToConditionDelta"/>.</summary>
|
||||
private static AlarmConditionDelta ReadConditionDelta(AlarmConditionState condition) => new(
|
||||
Active: condition.ActiveState?.Id?.Value ?? false,
|
||||
Acknowledged: condition.AckedState?.Id?.Value ?? true,
|
||||
Confirmed: condition.ConfirmedState?.Id?.Value ?? true,
|
||||
Enabled: condition.EnabledState?.Id?.Value ?? true,
|
||||
Shelving: ReadShelvingKind(condition),
|
||||
MappedSeverity: condition.Severity?.Value ?? (ushort)0,
|
||||
Message: condition.Message?.Value?.Text ?? string.Empty);
|
||||
|
||||
/// <summary>Build the gate-relevant slice from the incoming snapshot, normalising the two fields that
|
||||
/// the node stores in a derived form: Severity is run through <see cref="MapSeverity"/> so it matches
|
||||
/// the bucket the node holds (the projection calls <c>SetSeverity(MapSeverity(...))</c>), and an
|
||||
/// optional Confirmed/Shelving that the node can't actually hold (missing child) is folded to the
|
||||
/// node's read-back default so it never spuriously registers as a delta.</summary>
|
||||
private static AlarmConditionDelta ToConditionDelta(AlarmConditionSnapshot state, AlarmConditionState condition) => new(
|
||||
Active: state.Active,
|
||||
Acknowledged: state.Acknowledged,
|
||||
// If the node has no ConfirmedState child, the projection is a no-op there; mirror the node's
|
||||
// read-back default (true) so a snapshot Confirmed value can't create a phantom delta.
|
||||
Confirmed: condition.ConfirmedState is not null ? state.Confirmed : true,
|
||||
Enabled: state.Enabled,
|
||||
// Likewise for shelving: without a ShelvingState child the projection can't apply, so fold to the
|
||||
// node's read-back default (Unshelved).
|
||||
Shelving: condition.ShelvingState is not null ? state.Shelving : AlarmShelvingKind.Unshelved,
|
||||
MappedSeverity: (ushort)MapSeverity(state.Severity),
|
||||
Message: state.Message ?? string.Empty);
|
||||
|
||||
/// <summary>Map the live shelving state machine's CurrentState back to our 3-way
|
||||
/// <see cref="AlarmShelvingKind"/> by matching its well-known Part 9 state object id. Any node without
|
||||
/// a shelving sub-state machine (or an unrecognised/unset state) reads as
|
||||
/// <see cref="AlarmShelvingKind.Unshelved"/> — the same value <see cref="ToConditionDelta"/> folds an
|
||||
/// unsupported shelving snapshot to, so the two stay comparable.</summary>
|
||||
private static AlarmShelvingKind ReadShelvingKind(AlarmConditionState condition)
|
||||
{
|
||||
var stateId = condition.ShelvingState?.CurrentState?.Id?.Value as NodeId;
|
||||
if (stateId == ObjectIds.ShelvedStateMachineType_OneShotShelved) return AlarmShelvingKind.OneShot;
|
||||
if (stateId == ObjectIds.ShelvedStateMachineType_TimedShelved) return AlarmShelvingKind.Timed;
|
||||
return AlarmShelvingKind.Unshelved;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Materialise a real OPC UA Part 9 <see cref="AlarmConditionState"/> node under its equipment
|
||||
/// folder so clients can browse it as a proper condition (and subscribe to its events). The node
|
||||
/// id is the alarm node id (the ScriptedAlarmId) so subsequent
|
||||
/// <see cref="WriteAlarmCondition"/> calls — which target that same id — update this node.
|
||||
/// <para>
|
||||
/// This is the T14 production replacement for the <c>bool[2]</c> placeholder: it creates
|
||||
/// node + basic Active/Ack state + the notifier wiring needed for T16 events, but fires
|
||||
/// <b>no</b> events itself.
|
||||
/// </para>
|
||||
/// Idempotent: a second call with the same <paramref name="alarmNodeId"/> tears down the prior
|
||||
/// node and re-creates it cleanly (so a redeploy with a changed type/severity is reflected).
|
||||
/// </summary>
|
||||
/// <param name="alarmNodeId">The alarm node identifier (== ScriptedAlarmId); becomes the condition's NodeId.</param>
|
||||
/// <param name="equipmentNodeId">The equipment folder node id the condition parents under (null/unknown ⇒ root).</param>
|
||||
/// <param name="displayName">Human-readable condition name (BrowseName / DisplayName / Message / ConditionName).</param>
|
||||
/// <param name="alarmType">Domain alarm type — maps to the SDK condition subtype (see remarks).</param>
|
||||
/// <param name="severity">Domain severity (treated as an OPC UA 1..1000 severity); mapped to <see cref="EventSeverity"/>.</param>
|
||||
/// <remarks>
|
||||
/// <para><b>AlarmType → SDK subtype mapping.</b> Script-driven alarms have no OPC limit /
|
||||
/// setpoint values, so any limit-style subtype would have unset limit children. We therefore
|
||||
/// map: <c>OffNormalAlarm</c> → <see cref="OffNormalAlarmState"/>, <c>DiscreteAlarm</c> →
|
||||
/// <see cref="DiscreteAlarmState"/>, and everything else (including <c>AlarmCondition</c> and
|
||||
/// <c>LimitAlarm</c>, which has no script-supplied limits) → the base
|
||||
/// <see cref="AlarmConditionState"/>. LimitAlarm deliberately falls back to base per the T13
|
||||
/// notes — a script alarm carries no High/Low limits to populate.</para>
|
||||
/// </remarks>
|
||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(alarmNodeId);
|
||||
ArgumentException.ThrowIfNullOrEmpty(displayName);
|
||||
|
||||
lock (Lock)
|
||||
{
|
||||
// Idempotent: drop any prior node for this id so a re-materialise (e.g. changed
|
||||
// type/severity on redeploy) reflects cleanly instead of leaking the old node.
|
||||
if (_alarmConditions.TryRemove(alarmNodeId, out var existing))
|
||||
{
|
||||
existing.Parent?.RemoveChild(existing);
|
||||
PredefinedNodes?.Remove(existing.NodeId);
|
||||
}
|
||||
|
||||
var parent = ResolveParentFolder(equipmentNodeId);
|
||||
|
||||
AlarmConditionState alarm = CreateAlarmConditionOfType(alarmType, parent);
|
||||
alarm.SymbolicName = displayName;
|
||||
// HasComponent so the parent folder "owns" the condition (matches the T13 notes' pattern).
|
||||
alarm.ReferenceTypeId = ReferenceTypeIds.HasComponent;
|
||||
|
||||
// Create builds the full mandatory Part 9 child set (EnabledState, AckedState,
|
||||
// ActiveState, the Acknowledge/Confirm/AddComment/Enable/Disable methods, ...) from the
|
||||
// type's embedded definition; we do not hand-build them.
|
||||
alarm.Create(
|
||||
SystemContext,
|
||||
new NodeId(alarmNodeId, NamespaceIndex),
|
||||
new QualifiedName(displayName, NamespaceIndex),
|
||||
new LocalizedText(displayName),
|
||||
assignNodeIds: true);
|
||||
|
||||
// Main-branch id MUST be a concrete (null) NodeId before any Set* call: SetEnableState ->
|
||||
// UpdateRetainState -> GetRetainState -> IsBranch() dereferences BranchId.Value, which
|
||||
// Create leaves as a null reference and would NRE. NodeId.Null marks "the main branch".
|
||||
// (Real-server finding from the T14 integration test — not obvious from the SDK notes.)
|
||||
if (alarm.BranchId is not null) alarm.BranchId.Value = NodeId.Null;
|
||||
|
||||
// Initial state via the SDK setters (T14: basic state only, NO event firing).
|
||||
alarm.SetEnableState(SystemContext, true);
|
||||
alarm.SetActiveState(SystemContext, false);
|
||||
alarm.SetAcknowledgedState(SystemContext, true);
|
||||
alarm.SetSeverity(SystemContext, MapSeverity(severity));
|
||||
alarm.Retain.Value = false; // inactive + acked ⇒ nothing to retain yet
|
||||
alarm.Message.Value = new LocalizedText(displayName);
|
||||
if (alarm.ConditionName is not null) alarm.ConditionName.Value = displayName;
|
||||
|
||||
// T18 — inbound Part 9 method handlers. Create() materialised the Acknowledge/Confirm/
|
||||
// AddComment/Shelve/Unshelve method nodes and the condition types wired their built-in OnCall
|
||||
// routing; these delegates are the veto/permission seam the SDK invokes BEFORE applying the
|
||||
// state change. Each gates on the caller's AlarmAck role (fails closed) and, when allowed,
|
||||
// routes a mapped AlarmCommand to the engine via AlarmCommandRouter, then returns Good so the
|
||||
// SDK applies its node state + auto-fires its own event (E2).
|
||||
// T20: the engine re-projects that same logical transition through WriteAlarmCondition; its
|
||||
// delta-gate (compares against the node's current state, which the SDK already pre-applied)
|
||||
// sees no change and suppresses the would-be second event (E3) — so no double-emit.
|
||||
alarm.OnAcknowledge = (context, condition, _, comment) =>
|
||||
HandleAlarmCommand(context, condition, "Acknowledge", comment, unshelveAt: null);
|
||||
alarm.OnConfirm = (context, condition, _, comment) =>
|
||||
HandleAlarmCommand(context, condition, "Confirm", comment, unshelveAt: null);
|
||||
alarm.OnAddComment = (context, condition, _, comment) =>
|
||||
HandleAlarmCommand(context, condition, "AddComment", comment, unshelveAt: null);
|
||||
alarm.OnShelve = (context, condition, shelving, oneShot, shelvingTime) =>
|
||||
{
|
||||
// SDK invocation shapes (verified against the decompiled AlarmConditionState):
|
||||
// OneShotShelve → (shelving:true, oneShot:true, 0.0) ⇒ OneShotShelve, no expiry
|
||||
// TimedShelve → (shelving:true, oneShot:false, ms) ⇒ TimedShelve, expiry = UtcNow + ms
|
||||
// Unshelve → (shelving:false, oneShot:false, 0.0) ⇒ Unshelve, no expiry
|
||||
// shelvingTime is an OPC UA Duration (milliseconds).
|
||||
var (operation, unshelveAt) =
|
||||
!shelving ? ("Unshelve", (DateTime?)null)
|
||||
: oneShot ? ("OneShotShelve", null)
|
||||
: ("TimedShelve", DateTime.UtcNow + TimeSpan.FromMilliseconds(shelvingTime));
|
||||
return HandleAlarmCommand(context, condition, operation, comment: null, unshelveAt);
|
||||
};
|
||||
// The auto-unshelve timer callback is SDK-initiated (the TimedShelve duration expired); the SDK
|
||||
// fires it with the node manager's system context — there is NO session and NO user identity.
|
||||
// Routing through HandleAlarmCommand would hit the AlarmAck gate and return BadUserAccessDenied,
|
||||
// leaving the alarm permanently shelved. Instead, bypass the client gate, extract the AlarmId the
|
||||
// same way HandleAlarmCommand does, and route an Unshelve command so the engine clears its shelve
|
||||
// state. The manual-client Unshelve path goes through OnShelve(shelving:false) and stays gated.
|
||||
alarm.OnTimedUnshelve = (context, condition) =>
|
||||
{
|
||||
var alarmId = condition.NodeId.Identifier?.ToString() ?? string.Empty;
|
||||
AlarmCommandRouter?.Invoke(new AlarmCommand(alarmId, "Unshelve", string.Empty, null, null));
|
||||
return ServiceResult.Good;
|
||||
};
|
||||
|
||||
parent.AddChild(alarm);
|
||||
|
||||
// Promote the equipment folder to an event notifier + register it as a root notifier so
|
||||
// T16's ReportEvent has a notifier path up to the Server object. Guard so repeated
|
||||
// materialise under the same folder doesn't double-add the root notifier.
|
||||
EnsureFolderIsEventNotifier(parent);
|
||||
|
||||
AddPredefinedNode(SystemContext, alarm);
|
||||
_alarmConditions[alarmNodeId] = alarm;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shared body for every inbound Part 9 alarm method handler (T18). Resolves the calling
|
||||
/// principal off the SDK <paramref name="context"/>, applies the <c>AlarmAck</c> role gate
|
||||
/// (<b>fails closed</b>: a missing identity or a missing role is denied), and on success builds a
|
||||
/// mapped <see cref="AlarmCommand"/> and routes it through <see cref="AlarmCommandRouter"/>.
|
||||
/// </summary>
|
||||
/// <param name="context">The SDK context the handler delegate was invoked with — a
|
||||
/// <c>ServerSystemContext</c> (an <see cref="ISessionOperationContext"/>) carrying the session
|
||||
/// identity. T17 attached the LDAP roles as a <see cref="RoleCarryingUserIdentity"/>.</param>
|
||||
/// <param name="condition">The condition the method targets; its <c>NodeId</c> identifier is the
|
||||
/// ScriptedAlarmId (T14 aligned them), which becomes <see cref="AlarmCommand.AlarmId"/>.</param>
|
||||
/// <param name="operation">The Part 9 operation name (e.g. <c>Acknowledge</c>, <c>TimedShelve</c>).</param>
|
||||
/// <param name="comment">The call's comment text, or <c>null</c> when none was supplied.</param>
|
||||
/// <param name="unshelveAt">For <c>TimedShelve</c>, the computed UTC expiry; otherwise <c>null</c>.</param>
|
||||
/// <returns><c>ServiceResult.Good</c> when allowed (the SDK then applies state + auto-fires its
|
||||
/// event); <c>BadUserAccessDenied</c> when the gate vetoes (no route, no state mutation).</returns>
|
||||
private ServiceResult HandleAlarmCommand(
|
||||
ISystemContext context, ConditionState condition, string operation, LocalizedText? comment, DateTime? unshelveAt)
|
||||
{
|
||||
// Resolve the principal the SAME way the SDK's own GetCurrentUserId does, then narrow to the
|
||||
// role-carrying identity T17 attached. Anonymous / non-role-carrying identities ⇒ null ⇒ denied.
|
||||
var identity = (context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity;
|
||||
if (identity is null || !identity.Roles.Contains(OpcUaDataPlaneRoles.AlarmAck, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
// Fail closed: no role / no identity ⇒ veto. Returning a bad ServiceResult aborts the SDK's
|
||||
// state change and surfaces the status to the client; we never route or mutate.
|
||||
return new ServiceResult(StatusCodes.BadUserAccessDenied);
|
||||
}
|
||||
|
||||
var cmd = new AlarmCommand(
|
||||
AlarmId: condition.NodeId.Identifier?.ToString() ?? string.Empty,
|
||||
Operation: operation,
|
||||
User: identity.DisplayName ?? string.Empty,
|
||||
Comment: comment?.Text,
|
||||
UnshelveAtUtc: unshelveAt);
|
||||
|
||||
// Non-blocking by contract (host wires a fire-and-forget mediator.Tell); safe to call under Lock.
|
||||
AlarmCommandRouter?.Invoke(cmd);
|
||||
|
||||
// Good ⇒ the SDK applies the node-state change + auto-fires its own condition event.
|
||||
return ServiceResult.Good;
|
||||
}
|
||||
|
||||
/// <summary>Map our domain <c>AlarmType</c> string to the matching SDK condition subtype. Script
|
||||
/// alarms have no OPC limit/setpoint values, so limit-style types fall back to the base
|
||||
/// <see cref="AlarmConditionState"/> (see <see cref="MaterialiseAlarmCondition"/> remarks).</summary>
|
||||
private static AlarmConditionState CreateAlarmConditionOfType(string alarmType, NodeState parent) => alarmType switch
|
||||
{
|
||||
"OffNormalAlarm" => new OffNormalAlarmState(parent),
|
||||
"DiscreteAlarm" => new DiscreteAlarmState(parent),
|
||||
// "LimitAlarm" / "AlarmCondition" / unknown ⇒ base: a script-driven alarm has no OPC limits
|
||||
// to populate, so the limit subtypes would carry unset High/Low children.
|
||||
_ => new AlarmConditionState(parent),
|
||||
};
|
||||
|
||||
/// <summary>Promote <paramref name="folder"/> to <see cref="EventNotifiers.SubscribeToEvents"/> and
|
||||
/// register it as a root notifier (idempotent — guarded by <see cref="_notifierFolders"/>) so the
|
||||
/// alarm condition has a notifier path to the Server object for T16's event propagation.</summary>
|
||||
private void EnsureFolderIsEventNotifier(FolderState folder)
|
||||
{
|
||||
if (!_notifierFolders.TryAdd(folder.NodeId, folder)) return;
|
||||
folder.EventNotifier = EventNotifiers.SubscribeToEvents;
|
||||
AddRootNotifier(folder);
|
||||
folder.ClearChangeMasks(SystemContext, includeChildren: false);
|
||||
}
|
||||
|
||||
/// <summary>Map an integer domain severity (treated as the OPC UA 1..1000 scale) onto the
|
||||
/// <see cref="EventSeverity"/> enum buckets the SDK's <c>SetSeverity</c> expects.</summary>
|
||||
private static EventSeverity MapSeverity(int severity) => severity switch
|
||||
{
|
||||
< 200 => EventSeverity.Low,
|
||||
< 400 => EventSeverity.MediumLow,
|
||||
< 600 => EventSeverity.Medium,
|
||||
< 800 => EventSeverity.MediumHigh,
|
||||
_ => EventSeverity.High,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Ensure a folder node exists at <paramref name="folderNodeId"/> with the given display
|
||||
/// name, parented under <paramref name="parentNodeId"/> (or the namespace root when null).
|
||||
@@ -206,12 +703,32 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
}
|
||||
_variables.Clear();
|
||||
|
||||
foreach (var alarm in _alarmConditions.Values)
|
||||
{
|
||||
alarm.Parent?.RemoveChild(alarm);
|
||||
PredefinedNodes?.Remove(alarm.NodeId);
|
||||
}
|
||||
_alarmConditions.Clear();
|
||||
|
||||
foreach (var f in _folders.Values)
|
||||
{
|
||||
f.Parent?.RemoveChild(f);
|
||||
PredefinedNodes?.Remove(f.NodeId);
|
||||
}
|
||||
_folders.Clear();
|
||||
|
||||
// Detach the Server↔folder HasNotifier ref for every promoted folder before dropping the
|
||||
// guard, otherwise the rebuild leaks an orphaned root-notifier reference on the Server
|
||||
// object. RemoveRootNotifier just severs that link, so its order relative to the folder
|
||||
// teardown above doesn't matter — but it must run under this same Lock.
|
||||
foreach (var folder in _notifierFolders.Values)
|
||||
{
|
||||
RemoveRootNotifier(folder);
|
||||
}
|
||||
|
||||
// Drop the notifier-folder guard so re-materialised alarms re-promote their (rebuilt)
|
||||
// equipment folders to event notifiers.
|
||||
_notifierFolders.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Server;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
|
||||
@@ -17,6 +18,24 @@ public sealed class OtOpcUaSdkServer : StandardServer
|
||||
/// <see cref="CreateMasterNodeManager"/>. Null until the SDK has bootstrapped.</summary>
|
||||
public OtOpcUaNodeManager? NodeManager => _otOpcUaNodeManager;
|
||||
|
||||
/// <summary>
|
||||
/// Wire the reverse-path sink for inbound Part 9 alarm method calls onto the created
|
||||
/// <see cref="OtOpcUaNodeManager"/>. The host calls this after start with a non-blocking
|
||||
/// <c>mediator.Tell</c> that publishes each <see cref="AlarmCommand"/> onto the
|
||||
/// <c>alarm-commands</c> DistributedPubSub topic. No-op (returns <c>false</c>) when the node
|
||||
/// manager has not been created yet, so the caller can detect a too-early call.
|
||||
/// </summary>
|
||||
/// <param name="router">The router invoked by the condition handlers once the <c>AlarmAck</c>
|
||||
/// gate passes; may be <c>null</c> to clear it.</param>
|
||||
/// <returns><c>true</c> when the router was set on a live node manager; <c>false</c> when no node
|
||||
/// manager exists yet.</returns>
|
||||
public bool SetAlarmCommandRouter(Action<AlarmCommand>? router)
|
||||
{
|
||||
if (_otOpcUaNodeManager is null) return false;
|
||||
_otOpcUaNodeManager.AlarmCommandRouter = router;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override MasterNodeManager CreateMasterNodeManager(
|
||||
IServerInternal server, ApplicationConfiguration configuration)
|
||||
|
||||
@@ -59,12 +59,12 @@ public sealed class Phase7Applier
|
||||
var removedCount = 0;
|
||||
foreach (var eq in plan.RemovedEquipment)
|
||||
{
|
||||
SafeWriteAlarmState(eq.EquipmentId, active: false, acknowledged: false, ts);
|
||||
SafeWriteAlarmCondition(eq.EquipmentId, RemovedConditionState, ts);
|
||||
removedCount++;
|
||||
}
|
||||
foreach (var alarm in plan.RemovedAlarms)
|
||||
{
|
||||
SafeWriteAlarmState(alarm.ScriptedAlarmId, active: false, acknowledged: false, ts);
|
||||
SafeWriteAlarmCondition(alarm.ScriptedAlarmId, RemovedConditionState, ts);
|
||||
removedCount++;
|
||||
}
|
||||
|
||||
@@ -285,6 +285,36 @@ public sealed class Phase7Applier
|
||||
composition.EquipmentVirtualTags.Select(v => v.EquipmentId).Distinct(StringComparer.Ordinal).Count());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// T14 — materialise real OPC UA Part 9 <c>AlarmConditionState</c> nodes from a composition
|
||||
/// snapshot. For each <b>enabled</b> <see cref="EquipmentScriptedAlarmPlan"/>, register a
|
||||
/// condition node (keyed by its <see cref="EquipmentScriptedAlarmPlan.ScriptedAlarmId"/>, which
|
||||
/// is the same id <c>OpcUaPublishActor.AlarmStateUpdate</c> targets) under its equipment folder.
|
||||
/// Disabled alarms are skipped — they expose no node. Must run AFTER
|
||||
/// <see cref="MaterialiseHierarchy"/> so the equipment folders exist. Idempotent (the sink's
|
||||
/// <c>MaterialiseAlarmCondition</c> re-creates cleanly on re-apply).
|
||||
/// </summary>
|
||||
/// <param name="composition">The composition result containing the scripted alarms to materialise.</param>
|
||||
public void MaterialiseScriptedAlarms(Phase7CompositionResult composition)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(composition);
|
||||
if (composition.EquipmentScriptedAlarms.Count == 0) return;
|
||||
|
||||
var materialised = 0;
|
||||
foreach (var alarm in composition.EquipmentScriptedAlarms)
|
||||
{
|
||||
if (!alarm.Enabled) continue;
|
||||
SafeMaterialiseAlarmCondition(alarm.ScriptedAlarmId, alarm.EquipmentId, alarm.Name, alarm.AlarmType, alarm.Severity);
|
||||
materialised++;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Phase7Applier: scripted alarms materialised (alarms={Alarms}, equipment={Equipment})",
|
||||
materialised,
|
||||
composition.EquipmentScriptedAlarms.Where(a => a.Enabled)
|
||||
.Select(a => a.EquipmentId).Distinct(StringComparer.Ordinal).Count());
|
||||
}
|
||||
|
||||
/// <summary>Deterministic NodeId for a tag's FolderPath sub-folder, scoped under its equipment
|
||||
/// folder so two equipments' identically-named sub-folders never collide.</summary>
|
||||
private static string EquipmentSubFolderNodeId(string equipmentId, string folderPath) =>
|
||||
@@ -302,10 +332,28 @@ public sealed class Phase7Applier
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "Phase7Applier: EnsureVariable threw for {Node}", nodeId); }
|
||||
}
|
||||
|
||||
private void SafeWriteAlarmState(string nodeId, bool active, bool acknowledged, DateTime ts)
|
||||
/// <summary>The "no-event" condition state written to a removed equipment / alarm node before the
|
||||
/// rebuild tears it down: inactive, acked, confirmed, enabled, unshelved, severity 0, empty message.
|
||||
/// Drives Retain to false so a removed condition stops replaying on ConditionRefresh.</summary>
|
||||
private static readonly AlarmConditionSnapshot RemovedConditionState = new(
|
||||
Active: false,
|
||||
Acknowledged: true,
|
||||
Confirmed: true,
|
||||
Enabled: true,
|
||||
Shelving: AlarmShelvingKind.Unshelved,
|
||||
Severity: 0,
|
||||
Message: string.Empty);
|
||||
|
||||
private void SafeWriteAlarmCondition(string nodeId, AlarmConditionSnapshot state, DateTime ts)
|
||||
{
|
||||
try { _sink.WriteAlarmState(nodeId, active, acknowledged, ts); }
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "Phase7Applier: WriteAlarmState threw for {Node}", nodeId); }
|
||||
try { _sink.WriteAlarmCondition(nodeId, state, ts); }
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "Phase7Applier: WriteAlarmCondition threw for {Node}", nodeId); }
|
||||
}
|
||||
|
||||
private void SafeMaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity)
|
||||
{
|
||||
try { _sink.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity); }
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "Phase7Applier: MaterialiseAlarmCondition threw for {Node}", alarmNodeId); }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
@@ -61,6 +62,17 @@ public sealed record Phase7CompositionResult(
|
||||
/// <summary>Equipment-namespace VirtualTags. See <see cref="EquipmentVirtualTagPlan"/>. Init-only,
|
||||
/// defaults empty so every existing constructor + call site keeps compiling.</summary>
|
||||
public IReadOnlyList<EquipmentVirtualTagPlan> EquipmentVirtualTags { get; init; } = Array.Empty<EquipmentVirtualTagPlan>();
|
||||
|
||||
/// <summary>
|
||||
/// Per-equipment scripted-alarm host plans — the richer analogue of the thin
|
||||
/// <see cref="ScriptedAlarmPlans"/> projection. Each carries the fully-resolved predicate
|
||||
/// source (joined from its <see cref="Script"/>) and the merged dependency graph (predicate
|
||||
/// <c>ctx.GetTag("…")</c> reads UNION <c>{TagPath}</c> tokens in the message template) so the
|
||||
/// runtime alarm host can subscribe to every signal and evaluate the predicate. See
|
||||
/// <see cref="EquipmentScriptedAlarmPlan"/>. Init-only, defaults empty so every existing
|
||||
/// constructor + call site keeps compiling unchanged.
|
||||
/// </summary>
|
||||
public IReadOnlyList<EquipmentScriptedAlarmPlan> EquipmentScriptedAlarms { get; init; } = Array.Empty<EquipmentScriptedAlarmPlan>();
|
||||
}
|
||||
|
||||
public sealed record UnsAreaProjection(string UnsAreaId, string DisplayName);
|
||||
@@ -149,6 +161,85 @@ public sealed record EquipmentVirtualTagPlan(
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One Equipment-owned scripted alarm from a <see cref="ScriptedAlarm"/> row, joined to its
|
||||
/// predicate <see cref="Script"/> (by <see cref="PredicateScriptId"/>) for the source. The
|
||||
/// richer host analogue of the thin <see cref="ScriptedAlarmPlan"/>: the runtime alarm host
|
||||
/// spawns one Part 9 condition per plan, evaluates <see cref="PredicateSource"/> over
|
||||
/// <see cref="DependencyRefs"/>, and resolves the <see cref="MessageTemplate"/>'s
|
||||
/// <c>{TagPath}</c> tokens at emission time. <see cref="DependencyRefs"/> = the distinct
|
||||
/// <c>ctx.GetTag("…")</c> read literals in the predicate source UNION the distinct
|
||||
/// <c>{TagPath}</c> token paths referenced in the message template (the reserved
|
||||
/// <c>{{equip}}</c> double-brace form is excluded). <see cref="Enabled"/> is carried (never
|
||||
/// dropped) so the host — not the composer — decides whether to host a disabled alarm. Designed
|
||||
/// to be cleanly serializable so the artifact-decode seam (sibling task) can mirror it for
|
||||
/// byte-parity, exactly like <see cref="EquipmentVirtualTagPlan"/>.
|
||||
/// </summary>
|
||||
/// <param name="ScriptedAlarmId">Stable logical id — drives the condition name + diff identity.</param>
|
||||
/// <param name="EquipmentId">Owning equipment folder the alarm hangs under.</param>
|
||||
/// <param name="Name">Operator-facing alarm name.</param>
|
||||
/// <param name="AlarmType">Concrete Part 9 type ("AlarmCondition"/"LimitAlarm"/"OffNormalAlarm"/"DiscreteAlarm").</param>
|
||||
/// <param name="Severity">Numeric severity 1..1000 per OPC UA Part 9.</param>
|
||||
/// <param name="MessageTemplate">Template with <c>{TagPath}</c> tokens resolved at emission time.</param>
|
||||
/// <param name="PredicateScriptId">Logical FK to the predicate script.</param>
|
||||
/// <param name="PredicateSource">The resolved predicate script source (joined by <paramref name="PredicateScriptId"/>).</param>
|
||||
/// <param name="DependencyRefs">Distinct predicate read refs UNION message-template token paths.</param>
|
||||
/// <param name="HistorizeToAveva">When true, transitions route to the Aveva Historian sink.</param>
|
||||
/// <param name="Retain">OPC UA Part 9 <c>Retain</c> flag.</param>
|
||||
/// <param name="Enabled">Whether the alarm is enabled — carried for the host to decide on.</param>
|
||||
public sealed record EquipmentScriptedAlarmPlan(
|
||||
string ScriptedAlarmId,
|
||||
string EquipmentId,
|
||||
string Name,
|
||||
string AlarmType,
|
||||
int Severity,
|
||||
string MessageTemplate,
|
||||
string PredicateScriptId,
|
||||
string PredicateSource,
|
||||
IReadOnlyList<string> DependencyRefs,
|
||||
bool HistorizeToAveva,
|
||||
bool Retain,
|
||||
bool Enabled)
|
||||
{
|
||||
/// <summary>Structural equality: the auto-generated record equality would compare
|
||||
/// <see cref="DependencyRefs"/> (an interface-typed list) BY REFERENCE, flagging every alarm as
|
||||
/// "changed" on every parse (fresh list instances). Compare it element-wise so a no-op redeploy
|
||||
/// diffs empty (mirrors <see cref="EquipmentVirtualTagPlan"/>).</summary>
|
||||
/// <remarks>
|
||||
/// <b>DependencyRefs equality is order-sensitive</b> (SequenceEqual).
|
||||
/// <see cref="EquipmentScriptPaths.ExtractAlarmDependencyRefs"/> is the canonical, deterministic
|
||||
/// producer of that order (predicate <c>ctx.GetTag</c> reads first, then first-seen message
|
||||
/// template tokens). Downstream byte-parity between the live composer and the artifact-decode
|
||||
/// mirror depends on both sides calling <c>ExtractAlarmDependencyRefs</c> with identical inputs.
|
||||
/// </remarks>
|
||||
public bool Equals(EquipmentScriptedAlarmPlan? other) =>
|
||||
other is not null &&
|
||||
ScriptedAlarmId == other.ScriptedAlarmId &&
|
||||
EquipmentId == other.EquipmentId &&
|
||||
Name == other.Name &&
|
||||
AlarmType == other.AlarmType &&
|
||||
Severity == other.Severity &&
|
||||
MessageTemplate == other.MessageTemplate &&
|
||||
PredicateScriptId == other.PredicateScriptId &&
|
||||
PredicateSource == other.PredicateSource &&
|
||||
HistorizeToAveva == other.HistorizeToAveva &&
|
||||
Retain == other.Retain &&
|
||||
Enabled == other.Enabled &&
|
||||
DependencyRefs.SequenceEqual(other.DependencyRefs, StringComparer.Ordinal);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override int GetHashCode()
|
||||
{
|
||||
var hash = new HashCode();
|
||||
hash.Add(ScriptedAlarmId); hash.Add(EquipmentId); hash.Add(Name);
|
||||
hash.Add(AlarmType); hash.Add(Severity); hash.Add(MessageTemplate);
|
||||
hash.Add(PredicateScriptId); hash.Add(PredicateSource);
|
||||
hash.Add(HistorizeToAveva); hash.Add(Retain); hash.Add(Enabled);
|
||||
foreach (var r in DependencyRefs) hash.Add(r, StringComparer.Ordinal);
|
||||
return hash.ToHashCode();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure composer that flattens the live-edit DB tables into the address-space build plan a
|
||||
/// driver-role host needs. Same inputs → same outputs, no logging, no DB writes. The driver-role
|
||||
@@ -325,10 +416,55 @@ public static class Phase7Composer
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// Equipment scripted alarms = each ScriptedAlarm joined to its predicate Script (by
|
||||
// PredicateScriptId) for the source. An alarm whose PredicateScriptId has no matching Script
|
||||
// row is SKIPPED (not emitted) with a structured warning rather than failing the whole
|
||||
// compose — the upstream draft validator is the authority that should prevent the dangling
|
||||
// reference. DependencyRefs = the predicate's distinct ctx.GetTag("…") reads UNION the
|
||||
// distinct {TagPath} tokens in the MessageTemplate (predicate reads first, then first-seen
|
||||
// template tokens; the reserved {{equip}} double-brace form is excluded). Enabled is carried
|
||||
// (never dropped) — the runtime host decides whether to host a disabled alarm. Ordered by
|
||||
// EquipmentId then ScriptedAlarmId so the upcoming artifact byte-parity test is reliable.
|
||||
//
|
||||
// Eager foreach (not lazy LINQ Select) so the Trace.TraceWarning fires exactly once per
|
||||
// compose call; a lazy select would re-fire on every re-enumeration of the LINQ chain.
|
||||
var equipmentScriptedAlarms = new List<EquipmentScriptedAlarmPlan>();
|
||||
foreach (var a in scriptedAlarms
|
||||
.OrderBy(a => a.EquipmentId, StringComparer.Ordinal)
|
||||
.ThenBy(a => a.ScriptedAlarmId, StringComparer.Ordinal))
|
||||
{
|
||||
if (!scriptsById.TryGetValue(a.PredicateScriptId, out var s))
|
||||
{
|
||||
Trace.TraceWarning(
|
||||
"Phase7Composer: scripted alarm '{0}' (equipment '{1}') references predicate " +
|
||||
"script '{2}' which is not in the supplied scripts — skipping.",
|
||||
a.ScriptedAlarmId, a.EquipmentId, a.PredicateScriptId);
|
||||
continue;
|
||||
}
|
||||
var source = s.SourceCode;
|
||||
equipmentScriptedAlarms.Add(new EquipmentScriptedAlarmPlan(
|
||||
ScriptedAlarmId: a.ScriptedAlarmId,
|
||||
EquipmentId: a.EquipmentId,
|
||||
Name: a.Name,
|
||||
AlarmType: a.AlarmType,
|
||||
Severity: a.Severity,
|
||||
MessageTemplate: a.MessageTemplate,
|
||||
PredicateScriptId: a.PredicateScriptId,
|
||||
PredicateSource: source,
|
||||
// Scripted alarms do NOT use {{equip}} substitution (only virtual tags do) — pass the
|
||||
// predicate source as-is. The merge (predicate reads first, then template tokens) lives
|
||||
// in the shared EquipmentScriptPaths helper so the artifact-decode mirror agrees.
|
||||
DependencyRefs: EquipmentScriptPaths.ExtractAlarmDependencyRefs(source, a.MessageTemplate),
|
||||
HistorizeToAveva: a.HistorizeToAveva,
|
||||
Retain: a.Retain,
|
||||
Enabled: a.Enabled));
|
||||
}
|
||||
|
||||
return new Phase7CompositionResult(areas, lines, nodes, plans, alarms, galaxyTags)
|
||||
{
|
||||
EquipmentTags = equipmentTags,
|
||||
EquipmentVirtualTags = equipmentVirtualTags,
|
||||
EquipmentScriptedAlarms = equipmentScriptedAlarms,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -28,13 +28,21 @@ public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink
|
||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc)
|
||||
=> _nodeManager.WriteValue(nodeId, value, quality, sourceTimestampUtc);
|
||||
|
||||
/// <summary>Writes alarm state to the OPC UA address space.</summary>
|
||||
/// <summary>Writes the full Part 9 alarm-condition state to the OPC UA address space.</summary>
|
||||
/// <param name="alarmNodeId">The alarm node identifier.</param>
|
||||
/// <param name="active">Whether the alarm is active.</param>
|
||||
/// <param name="acknowledged">Whether the alarm is acknowledged.</param>
|
||||
/// <param name="state">The full condition state to project onto the node.</param>
|
||||
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
||||
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
|
||||
=> _nodeManager.WriteAlarmState(alarmNodeId, active, acknowledged, sourceTimestampUtc);
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc)
|
||||
=> _nodeManager.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc);
|
||||
|
||||
/// <summary>Materialises a real Part 9 alarm-condition node in the address space.</summary>
|
||||
/// <param name="alarmNodeId">The alarm node identifier (== ScriptedAlarmId).</param>
|
||||
/// <param name="equipmentNodeId">The equipment folder node identifier the condition parents under.</param>
|
||||
/// <param name="displayName">The human-readable condition name.</param>
|
||||
/// <param name="alarmType">The domain alarm type.</param>
|
||||
/// <param name="severity">The domain severity.</param>
|
||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity)
|
||||
=> _nodeManager.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity);
|
||||
|
||||
/// <summary>Ensures a folder node exists in the address space.</summary>
|
||||
/// <param name="folderNodeId">The folder node identifier.</param>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical string constants for the OPC UA <b>data-plane</b> roles the LDAP group→role map
|
||||
/// produces and <see cref="RoleCarryingUserIdentity.Roles"/> carries onto the session identity.
|
||||
/// These are distinct from the control-plane <c>AdminRole</c> enum (Admin UI capabilities) — the
|
||||
/// two planes share zero runtime code path by design.
|
||||
/// <para>
|
||||
/// Across the codebase these data-plane roles (<c>ReadOnly</c>, <c>WriteOperate</c>,
|
||||
/// <c>WriteTune</c>, <c>WriteConfigure</c>, <c>AlarmAck</c>, …) are used as bare strings
|
||||
/// (they originate as LDAP group names mapped through <c>RoleMapper</c>). T18 introduced this
|
||||
/// single shared const for the one role the inbound alarm-method gate reads, so the gate and
|
||||
/// its tests reference one symbol instead of a re-typed literal. Comparison is case-insensitive
|
||||
/// (the role set is built with <see cref="System.StringComparer.OrdinalIgnoreCase"/>), so the
|
||||
/// gate matches with that comparer too.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class OpcUaDataPlaneRoles
|
||||
{
|
||||
/// <summary>The role that grants OPC UA Part 9 alarm acknowledge / confirm / shelve / comment
|
||||
/// authority. A session must carry this role for the inbound alarm-condition method handlers to
|
||||
/// route the command to the engine; absent it, the call is denied with
|
||||
/// <c>BadUserAccessDenied</c>.</summary>
|
||||
public const string AlarmAck = "AlarmAck";
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using Opc.Ua;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="UserIdentity"/> that additionally carries the LDAP-resolved roles
|
||||
/// (<see cref="OpcUaUserAuthResult.Roles"/>) for the authenticated session. The SDK stores the
|
||||
/// instance we assign to <c>ImpersonateEventArgs.Identity</c> by reference onto
|
||||
/// <c>Session.Identity</c>/<c>Session.EffectiveIdentity</c> (no wrapping or copying), so a method
|
||||
/// handler can later recover the roles via
|
||||
/// <c>(context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity</c> — the
|
||||
/// seam the inbound alarm ack/shelve <c>AlarmAck</c> gate (T18) reads. This type only carries the
|
||||
/// roles; it makes no auth decision.
|
||||
/// </summary>
|
||||
public sealed class RoleCarryingUserIdentity : UserIdentity
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance, building the base identity from the same UserName token the
|
||||
/// SDK decrypted and attaching the authenticator's resolved roles.
|
||||
/// </summary>
|
||||
/// <param name="token">The user identity token the SDK handed to the impersonation handler.</param>
|
||||
/// <param name="roles">The roles resolved by the authenticator (e.g. <c>ReadOnly</c>, <c>AlarmAck</c>).</param>
|
||||
public RoleCarryingUserIdentity(UserIdentityToken token, IReadOnlyList<string> roles)
|
||||
: base(token)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(roles);
|
||||
// Freeze to a defensive copy so a caller mutating the source list can never retroactively
|
||||
// alter this session's granted roles, and so the gate's per-ack `.Contains(...)` runs over a
|
||||
// known-small frozen array.
|
||||
Roles = [.. roles];
|
||||
}
|
||||
|
||||
/// <summary>The roles the authenticator granted this session, used by downstream permission gates.</summary>
|
||||
public IReadOnlyList<string> Roles { get; }
|
||||
}
|
||||
@@ -195,11 +195,13 @@ public static class DeploymentArtifact
|
||||
var galaxyTags = BuildGalaxyTagPlans(root, drivers);
|
||||
var equipmentTags = BuildEquipmentTagPlans(root);
|
||||
var equipmentVirtualTags = BuildEquipmentVirtualTagPlans(root, equipmentTags);
|
||||
var equipmentScriptedAlarms = BuildEquipmentScriptedAlarmPlans(root);
|
||||
|
||||
return new Phase7CompositionResult(areas, lines, equipment, drivers, alarms, galaxyTags)
|
||||
{
|
||||
EquipmentTags = equipmentTags,
|
||||
EquipmentVirtualTags = equipmentVirtualTags,
|
||||
EquipmentScriptedAlarms = equipmentScriptedAlarms,
|
||||
};
|
||||
}
|
||||
catch (JsonException)
|
||||
@@ -261,6 +263,7 @@ public static class DeploymentArtifact
|
||||
{
|
||||
EquipmentTags = full.EquipmentTags.Where(t => sets.DriverIds.Contains(t.DriverInstanceId)).ToArray(),
|
||||
EquipmentVirtualTags = full.EquipmentVirtualTags.Where(v => sets.EquipmentIds.Contains(v.EquipmentId)).ToArray(),
|
||||
EquipmentScriptedAlarms = full.EquipmentScriptedAlarms.Where(a => sets.EquipmentIds.Contains(a.EquipmentId)).ToArray(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -606,6 +609,91 @@ public static class DeploymentArtifact
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Join the artifact's ScriptedAlarms array to its Scripts array (by PredicateScriptId) to emit
|
||||
/// one <see cref="EquipmentScriptedAlarmPlan"/> per alarm. The artifact-decode mirror of
|
||||
/// <c>Phase7Composer.Compose</c>'s scripted-alarm producer — so the compose-side + artifact-decode
|
||||
/// plans agree byte-for-byte. An alarm whose <c>PredicateScriptId</c> has no matching Script row is
|
||||
/// SKIPPED (matching the composer's skip behaviour) to preserve parity. <c>PredicateSource</c> = the
|
||||
/// joined script source ("" when missing — but such alarms are skipped above); <c>DependencyRefs</c>
|
||||
/// = the shared <see cref="EquipmentScriptPaths.ExtractAlarmDependencyRefs"/> merge of the predicate's
|
||||
/// distinct <c>ctx.GetTag("…")</c> reads UNION the message template's <c>{TagPath}</c> tokens. Scripted
|
||||
/// alarms do NOT use <c>{{equip}}</c> substitution (only virtual tags do) — the predicate source is
|
||||
/// used as-is. Ordered by EquipmentId then ScriptedAlarmId to match the composer's deterministic order.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<EquipmentScriptedAlarmPlan> BuildEquipmentScriptedAlarmPlans(JsonElement root)
|
||||
{
|
||||
if (!root.TryGetProperty("ScriptedAlarms", out var alarmsArr) || alarmsArr.ValueKind != JsonValueKind.Array)
|
||||
return Array.Empty<EquipmentScriptedAlarmPlan>();
|
||||
|
||||
// scriptId → SourceCode (the predicate source the alarm host evaluates). Same join the
|
||||
// VirtualTag builder uses; an alarm whose PredicateScriptId is absent here is skipped below.
|
||||
var scriptSourceById = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
if (root.TryGetProperty("Scripts", out var scriptsArr) && scriptsArr.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var el in scriptsArr.EnumerateArray())
|
||||
{
|
||||
if (el.ValueKind != JsonValueKind.Object) continue;
|
||||
var sid = el.TryGetProperty("ScriptId", out var sidEl) ? sidEl.GetString() : null;
|
||||
if (string.IsNullOrWhiteSpace(sid)) continue;
|
||||
var src = el.TryGetProperty("SourceCode", out var srcEl) && srcEl.ValueKind == JsonValueKind.String
|
||||
? srcEl.GetString() : null;
|
||||
scriptSourceById[sid!] = src ?? string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
var result = new List<EquipmentScriptedAlarmPlan>(alarmsArr.GetArrayLength());
|
||||
foreach (var el in alarmsArr.EnumerateArray())
|
||||
{
|
||||
if (el.ValueKind != JsonValueKind.Object) continue;
|
||||
var scriptedAlarmId = el.TryGetProperty("ScriptedAlarmId", out var idEl) ? idEl.GetString() : null;
|
||||
var equipmentId = el.TryGetProperty("EquipmentId", out var eqEl) ? eqEl.GetString() : null;
|
||||
var name = el.TryGetProperty("Name", out var nmEl) ? nmEl.GetString() : null;
|
||||
var alarmType = el.TryGetProperty("AlarmType", out var atEl) ? atEl.GetString() : null;
|
||||
var severity = el.TryGetProperty("Severity", out var svEl) && svEl.TryGetInt32(out var sv) ? sv : 0;
|
||||
var messageTemplate = el.TryGetProperty("MessageTemplate", out var mtEl) ? mtEl.GetString() : null;
|
||||
var predicateScriptId = el.TryGetProperty("PredicateScriptId", out var psEl) ? psEl.GetString() : null;
|
||||
// Booleans default to the entity defaults (true) when absent / null / non-boolean, so a
|
||||
// partial blob decodes the same as the composer's view of a default-constructed
|
||||
// ScriptedAlarm — preserving byte-parity. GetBoolean only runs for a genuine true/false
|
||||
// token (a non-bool token would otherwise throw InvalidOperationException, uncaught here).
|
||||
bool ReadBool(string prop, bool dflt) =>
|
||||
el.TryGetProperty(prop, out var b) && b.ValueKind is JsonValueKind.True or JsonValueKind.False
|
||||
? b.GetBoolean() : dflt;
|
||||
var historize = ReadBool("HistorizeToAveva", true);
|
||||
var retain = ReadBool("Retain", true);
|
||||
var enabled = ReadBool("Enabled", true);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(scriptedAlarmId)) continue;
|
||||
|
||||
// Skip alarms whose predicate script is missing — matching Phase7Composer's skip behaviour
|
||||
// so both sides emit the same set (byte-parity).
|
||||
if (predicateScriptId is null || !scriptSourceById.TryGetValue(predicateScriptId, out var source))
|
||||
continue;
|
||||
|
||||
result.Add(new EquipmentScriptedAlarmPlan(
|
||||
ScriptedAlarmId: scriptedAlarmId!,
|
||||
EquipmentId: equipmentId ?? string.Empty,
|
||||
Name: name ?? string.Empty,
|
||||
AlarmType: alarmType ?? string.Empty,
|
||||
Severity: severity,
|
||||
MessageTemplate: messageTemplate ?? string.Empty,
|
||||
PredicateScriptId: predicateScriptId,
|
||||
PredicateSource: source,
|
||||
DependencyRefs: EquipmentScriptPaths.ExtractAlarmDependencyRefs(source, messageTemplate),
|
||||
HistorizeToAveva: historize,
|
||||
Retain: retain,
|
||||
Enabled: enabled));
|
||||
}
|
||||
|
||||
result.Sort((a, b) =>
|
||||
{
|
||||
var byEquipment = string.CompareOrdinal(a.EquipmentId, b.EquipmentId);
|
||||
return byEquipment != 0 ? byEquipment : string.CompareOrdinal(a.ScriptedAlarmId, b.ScriptedAlarmId);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extract the driver-side full reference from a tag's TagConfig JSON (top-level "FullName"
|
||||
/// field). The artifact-decode mirror of <c>Phase7Composer.ExtractTagFullName</c> /
|
||||
|
||||
@@ -3,6 +3,8 @@ using Akka.Actor;
|
||||
using Akka.Cluster.Tools.PublishSubscribe;
|
||||
using Akka.Event;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
|
||||
@@ -14,7 +16,10 @@ using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
|
||||
using CommonsNodeId = ZB.MOM.WW.OtOpcUa.Commons.Types.NodeId;
|
||||
|
||||
@@ -56,6 +61,9 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
private readonly IDriverHealthPublisher _healthPublisher;
|
||||
private readonly IVirtualTagEvaluator _virtualTagEvaluator;
|
||||
private readonly IActorRef? _virtualTagHostOverride;
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private readonly ScriptRootLogger? _scriptRootLogger;
|
||||
private readonly IActorRef? _scriptedAlarmHostOverride;
|
||||
private readonly ILoggingAdapter _log = Context.GetLogger();
|
||||
|
||||
/// <summary>The single VirtualTag-host child that spawns/reconciles Equipment-namespace
|
||||
@@ -64,6 +72,14 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
/// <see cref="VirtualTagHostActor.ApplyVirtualTags"/> from <see cref="PushDesiredSubscriptions"/>.</summary>
|
||||
private IActorRef? _virtualTagHost;
|
||||
|
||||
/// <summary>The single ScriptedAlarm-host child that owns the per-node
|
||||
/// <see cref="ScriptedAlarmEngine"/>, feeds it live tag values, and bridges its emissions onto the
|
||||
/// OPC UA publish actor + the cluster <c>alerts</c> topic. Spawned in <see cref="PreStart"/>
|
||||
/// alongside the VirtualTag host (requires both an OPC UA publish actor and a
|
||||
/// <see cref="ScriptRootLogger"/>); receives <see cref="ScriptedAlarmHostActor.ApplyScriptedAlarms"/>
|
||||
/// from <see cref="PushDesiredSubscriptions"/>.</summary>
|
||||
private IActorRef? _scriptedAlarmHost;
|
||||
|
||||
private RevisionHash? _currentRevision;
|
||||
private DeploymentId? _applyingDeploymentId;
|
||||
|
||||
@@ -103,6 +119,16 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
/// VirtualTag host instead of spawning a real <see cref="VirtualTagHostActor"/> child, so tests
|
||||
/// can intercept the <see cref="VirtualTagHostActor.ApplyVirtualTags"/> message. Null in
|
||||
/// production (the real host is spawned).</param>
|
||||
/// <param name="loggerFactory">Optional logger factory used to create the
|
||||
/// <see cref="EfAlarmConditionStateStore"/>'s logger when spawning the ScriptedAlarm host;
|
||||
/// defaults to <see cref="NullLoggerFactory"/> when not provided.</param>
|
||||
/// <param name="scriptRootLogger">Optional root script logger required to spawn the ScriptedAlarm
|
||||
/// host (the engine + its script logging hang off it). When null the ScriptedAlarm host is left
|
||||
/// unspawned — the graceful dev/None-deployment path.</param>
|
||||
/// <param name="scriptedAlarmHostOverride">Test seam: when supplied, this actor is used as the
|
||||
/// ScriptedAlarm host instead of spawning a real <see cref="ScriptedAlarmHostActor"/> child, so
|
||||
/// tests can intercept the <see cref="ScriptedAlarmHostActor.ApplyScriptedAlarms"/> message. Null
|
||||
/// in production (the real host is spawned).</param>
|
||||
public static Props Props(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
|
||||
CommonsNodeId localNode,
|
||||
@@ -113,10 +139,14 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
IActorRef? opcUaPublishActor = null,
|
||||
IDriverHealthPublisher? healthPublisher = null,
|
||||
IVirtualTagEvaluator? virtualTagEvaluator = null,
|
||||
IActorRef? virtualTagHostOverride = null) =>
|
||||
IActorRef? virtualTagHostOverride = null,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
ScriptRootLogger? scriptRootLogger = null,
|
||||
IActorRef? scriptedAlarmHostOverride = null) =>
|
||||
Akka.Actor.Props.Create(() => new DriverHostActor(
|
||||
dbFactory, localNode, coordinator, driverFactory, localRoles, dependencyMux, opcUaPublishActor,
|
||||
healthPublisher, virtualTagEvaluator, virtualTagHostOverride));
|
||||
healthPublisher, virtualTagEvaluator, virtualTagHostOverride,
|
||||
loggerFactory, scriptRootLogger, scriptedAlarmHostOverride));
|
||||
|
||||
/// <summary>Initializes a new DriverHostActor with the specified dependencies.</summary>
|
||||
/// <param name="dbFactory">Database context factory for configuration database access.</param>
|
||||
@@ -131,6 +161,12 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
/// defaults to <see cref="NullVirtualTagEvaluator"/>.</param>
|
||||
/// <param name="virtualTagHostOverride">Test seam: when supplied, used as the VirtualTag host
|
||||
/// instead of spawning a real <see cref="VirtualTagHostActor"/> child.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory used to create the
|
||||
/// <see cref="EfAlarmConditionStateStore"/>'s logger; defaults to <see cref="NullLoggerFactory"/>.</param>
|
||||
/// <param name="scriptRootLogger">Optional root script logger required to spawn the ScriptedAlarm
|
||||
/// host; when null the host is left unspawned.</param>
|
||||
/// <param name="scriptedAlarmHostOverride">Test seam: when supplied, used as the ScriptedAlarm host
|
||||
/// instead of spawning a real <see cref="ScriptedAlarmHostActor"/> child.</param>
|
||||
public DriverHostActor(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
|
||||
CommonsNodeId localNode,
|
||||
@@ -141,7 +177,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
IActorRef? opcUaPublishActor = null,
|
||||
IDriverHealthPublisher? healthPublisher = null,
|
||||
IVirtualTagEvaluator? virtualTagEvaluator = null,
|
||||
IActorRef? virtualTagHostOverride = null)
|
||||
IActorRef? virtualTagHostOverride = null,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
ScriptRootLogger? scriptRootLogger = null,
|
||||
IActorRef? scriptedAlarmHostOverride = null)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
_localNode = localNode;
|
||||
@@ -153,6 +192,9 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
_healthPublisher = healthPublisher ?? NullDriverHealthPublisher.Instance;
|
||||
_virtualTagEvaluator = virtualTagEvaluator ?? NullVirtualTagEvaluator.Instance;
|
||||
_virtualTagHostOverride = virtualTagHostOverride;
|
||||
_loggerFactory = loggerFactory ?? NullLoggerFactory.Instance;
|
||||
_scriptRootLogger = scriptRootLogger;
|
||||
_scriptedAlarmHostOverride = scriptedAlarmHostOverride;
|
||||
|
||||
// Default behavior is Steady — PreStart may flip to Stale or replay an orphan apply.
|
||||
Become(Steady);
|
||||
@@ -168,6 +210,9 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
// Spawn the VirtualTag host BEFORE Bootstrap so the bootstrap-restore path (which routes
|
||||
// through PushDesiredSubscriptions and Tells ApplyVirtualTags) has a live host to target.
|
||||
SpawnVirtualTagHost();
|
||||
// Same rationale for the ScriptedAlarm host — the bootstrap-restore path Tells it
|
||||
// ApplyScriptedAlarms through the same PushDesiredSubscriptions pass.
|
||||
SpawnScriptedAlarmHost();
|
||||
Bootstrap();
|
||||
}
|
||||
|
||||
@@ -199,6 +244,46 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
"virtual-tag-host");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Spawns the single <see cref="ScriptedAlarmHostActor"/> child that owns the per-node
|
||||
/// <see cref="ScriptedAlarmEngine"/>, feeds it live tag values from the dependency mux, and
|
||||
/// bridges its emissions onto the OPC UA publish actor + the cluster <c>alerts</c> topic. A
|
||||
/// test-supplied <c>scriptedAlarmHostOverride</c> short-circuits the spawn so a probe can
|
||||
/// intercept <see cref="ScriptedAlarmHostActor.ApplyScriptedAlarms"/>. The real host needs
|
||||
/// both a non-null <see cref="_opcUaPublishActor"/> (the emission sink) and a non-null
|
||||
/// <see cref="_scriptRootLogger"/> (the engine + its script logging hang off it); when either
|
||||
/// is missing (legacy ControlPlane test harnesses, dev/None deployments) the host is left
|
||||
/// null and ApplyScriptedAlarms becomes a no-op. The engine is built around a fresh
|
||||
/// <see cref="DependencyMuxTagUpstreamSource"/> + an <see cref="EfAlarmConditionStateStore"/>;
|
||||
/// the host (spawned as a child) owns + disposes the engine in its PostStop, so it stops with
|
||||
/// the driver host.
|
||||
/// </summary>
|
||||
private void SpawnScriptedAlarmHost()
|
||||
{
|
||||
if (_scriptedAlarmHostOverride is not null)
|
||||
{
|
||||
_scriptedAlarmHost = _scriptedAlarmHostOverride;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_opcUaPublishActor is null || _scriptRootLogger is null)
|
||||
{
|
||||
_log.Debug(
|
||||
"DriverHost {Node}: skipping ScriptedAlarm host spawn (no publish actor / root logger)",
|
||||
_localNode);
|
||||
return;
|
||||
}
|
||||
|
||||
var upstream = new DependencyMuxTagUpstreamSource();
|
||||
var store = new EfAlarmConditionStateStore(
|
||||
_dbFactory, _loggerFactory.CreateLogger<EfAlarmConditionStateStore>());
|
||||
var engine = new ScriptedAlarmEngine(
|
||||
upstream, store, new ScriptLoggerFactory(_scriptRootLogger.Logger), _scriptRootLogger.Logger);
|
||||
_scriptedAlarmHost = Context.ActorOf(
|
||||
ScriptedAlarmHostActor.Props(_opcUaPublishActor, _dependencyMux, upstream, engine),
|
||||
"scripted-alarm-host");
|
||||
}
|
||||
|
||||
private void Bootstrap()
|
||||
{
|
||||
// Read the most-recent NodeDeploymentState for this node; if it's Applied, jump
|
||||
@@ -535,6 +620,17 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
_log.Info("DriverHost {Node}: applied {Count} Equipment VirtualTag(s) to the VirtualTag host",
|
||||
_localNode, composition.EquipmentVirtualTags.Count);
|
||||
}
|
||||
|
||||
// Same pass for Equipment-namespace ScriptedAlarms: hand the plans to the host so it
|
||||
// (re)loads its engine + re-registers mux interest for the union of dependency refs. Covers
|
||||
// both the fresh-apply and bootstrap-restore paths (both call this method); the Stale-recovery
|
||||
// path deliberately does not, matching driver + VirtualTag recovery.
|
||||
_scriptedAlarmHost?.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(composition.EquipmentScriptedAlarms));
|
||||
if (composition.EquipmentScriptedAlarms.Count > 0)
|
||||
{
|
||||
_log.Info("DriverHost {Node}: applied {Count} Equipment ScriptedAlarm(s) to the ScriptedAlarm host",
|
||||
_localNode, composition.EquipmentScriptedAlarms.Count);
|
||||
}
|
||||
}
|
||||
|
||||
private void SpawnChild(DriverInstanceSpec spec)
|
||||
|
||||
@@ -30,7 +30,15 @@ public sealed class OpcUaPublishActor : ReceiveActor
|
||||
public const string RedundancyStateTopic = "redundancy-state";
|
||||
|
||||
public sealed record AttributeValueUpdate(string NodeId, object? Value, OpcUaQuality Quality, DateTime TimestampUtc);
|
||||
public sealed record AlarmStateUpdate(string AlarmNodeId, bool Active, bool Acknowledged, DateTime TimestampUtc);
|
||||
|
||||
/// <summary>Carries the full Part 9 condition state for a scripted alarm to the sink. The
|
||||
/// <paramref name="State"/> snapshot is the Commons projection the Runtime host maps from the engine's
|
||||
/// Core <c>AlarmConditionState</c> + severity/message — the actor stays decoupled from
|
||||
/// <c>Core.ScriptedAlarms</c>.</summary>
|
||||
/// <param name="AlarmNodeId">The alarm node id (== ScriptedAlarmId for materialised conditions).</param>
|
||||
/// <param name="State">The full condition state to project onto the node.</param>
|
||||
/// <param name="TimestampUtc">The source timestamp of the transition in UTC.</param>
|
||||
public sealed record AlarmStateUpdate(string AlarmNodeId, AlarmConditionSnapshot State, DateTime TimestampUtc);
|
||||
/// <summary>
|
||||
/// Triggers an address-space rebuild. <paramref name="DeploymentId"/> is the deployment
|
||||
/// just applied by the host; the rebuild loads THAT artifact so materialisation matches the
|
||||
@@ -167,13 +175,13 @@ public sealed class OpcUaPublishActor : ReceiveActor
|
||||
{
|
||||
try
|
||||
{
|
||||
_sink.WriteAlarmState(msg.AlarmNodeId, msg.Active, msg.Acknowledged, msg.TimestampUtc);
|
||||
_sink.WriteAlarmCondition(msg.AlarmNodeId, msg.State, msg.TimestampUtc);
|
||||
Interlocked.Increment(ref _writes);
|
||||
OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair<string, object?>("kind", "alarm"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Warning(ex, "OpcUaPublish: sink.WriteAlarmState threw for {Node}", msg.AlarmNodeId);
|
||||
_log.Warning(ex, "OpcUaPublish: sink.WriteAlarmCondition threw for {Node}", msg.AlarmNodeId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,6 +237,11 @@ public sealed class OpcUaPublishActor : ReceiveActor
|
||||
// clients see Area/Line/Equipment as proper folders. Idempotent; Phase7Applier
|
||||
// skips folders that already exist with the same node id.
|
||||
_applier.MaterialiseHierarchy(composition);
|
||||
// T14 — scripted alarms get their own pass right after the hierarchy so the equipment
|
||||
// folders they parent under already exist. Materialises real Part 9 AlarmConditionState
|
||||
// nodes (keyed by ScriptedAlarmId so AlarmStateUpdate writes target them); disabled
|
||||
// alarms are skipped.
|
||||
_applier.MaterialiseScriptedAlarms(composition);
|
||||
// Galaxy / SystemPlatform tags get their own pass: ensures their FolderPath folder
|
||||
// + Variable node exist so clients can browse them. The Galaxy driver fills values
|
||||
// on a future SubscribeBulk pass; until then variables show BadWaitingForInitialData.
|
||||
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
|
||||
|
||||
/// <summary>
|
||||
/// Thread-safe <see cref="ITagUpstreamSource"/> the scripted-alarm host actor pushes
|
||||
/// tag values INTO and the <see cref="ScriptedAlarmEngine"/> reads / subscribes FROM.
|
||||
/// In the live runtime the host actor translates each Akka
|
||||
/// <c>DependencyValueChanged</c> message into a <see cref="Push(string, DataValueSnapshot)"/>
|
||||
/// call; the engine sees those values synchronously through <see cref="ReadTag"/> and
|
||||
/// reactively through <see cref="SubscribeTag"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A <see cref="ConcurrentDictionary{TKey, TValue}"/> caches the latest snapshot
|
||||
/// per path so <see cref="ReadTag"/> can answer synchronously (the engine's
|
||||
/// startup-recovery + read-cache-refill paths both call it). Per-path observer
|
||||
/// lists are held as an immutable list inside a <see cref="ConcurrentDictionary{TKey, TValue}"/>
|
||||
/// so subscribe / unsubscribe mutate via atomic compare-and-swap and
|
||||
/// <see cref="Push(string, DataValueSnapshot)"/> can capture-then-invoke a stable
|
||||
/// snapshot — a concurrent unsubscribe can never corrupt an in-flight fan-out.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class DependencyMuxTagUpstreamSource : ITagUpstreamSource
|
||||
{
|
||||
// OPC UA Part 4 StatusCode for an outright Bad value (severity 10, bit 31 set). The
|
||||
// codebase has no shared Core.Abstractions constant — concrete producers (GalaxyDriver,
|
||||
// the Wonderware historian client) inline this same 0x80000000u, and the engine's own
|
||||
// AreInputsReady gate tests exactly this bit — so an "unknown path" snapshot uses it too.
|
||||
private const uint StatusBad = 0x80000000u;
|
||||
|
||||
// Intentionally never pruned: cold-start semantics depend on retained last values so
|
||||
// ReadTag can answer synchronously for any path that has ever been pushed.
|
||||
private readonly ConcurrentDictionary<string, DataValueSnapshot> _cache
|
||||
= new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, ImmutableList<Action<string, DataValueSnapshot>>> _observers
|
||||
= new(StringComparer.Ordinal);
|
||||
private readonly Func<DateTime> _clock;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new <see cref="DependencyMuxTagUpstreamSource"/>.
|
||||
/// </summary>
|
||||
/// <param name="clock">
|
||||
/// Optional function supplying the current UTC time, used to stamp the Bad-quality
|
||||
/// snapshot returned for an unknown path. Defaults to <see cref="DateTime.UtcNow"/>,
|
||||
/// mirroring how <see cref="ScriptedAlarmEngine"/> takes its clock.
|
||||
/// </param>
|
||||
public DependencyMuxTagUpstreamSource(Func<DateTime>? clock = null)
|
||||
=> _clock = clock ?? (() => DateTime.UtcNow);
|
||||
|
||||
/// <summary>
|
||||
/// Update the cached snapshot for <paramref name="path"/> and then notify every
|
||||
/// observer currently subscribed to that path. NOT part of
|
||||
/// <see cref="ITagUpstreamSource"/> — the host actor calls this from its
|
||||
/// <c>DependencyValueChanged</c> handler.
|
||||
/// </summary>
|
||||
/// <param name="path">The tag path whose value changed.</param>
|
||||
/// <param name="snapshot">The new value snapshot.</param>
|
||||
public void Push(string path, DataValueSnapshot snapshot)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(path);
|
||||
ArgumentNullException.ThrowIfNull(snapshot);
|
||||
|
||||
// Cache first so any observer that re-reads the path inside its callback sees the
|
||||
// value it is being notified about, not the prior one.
|
||||
_cache[path] = snapshot;
|
||||
|
||||
// Capture the immutable observer snapshot, then invoke outside any lock: a
|
||||
// concurrent Subscribe/Dispose swaps the dictionary entry atomically and cannot
|
||||
// mutate the list we are iterating.
|
||||
if (_observers.TryGetValue(path, out var observers))
|
||||
{
|
||||
foreach (var observer in observers)
|
||||
{
|
||||
try { observer(path, snapshot); }
|
||||
catch { /* one misbehaving observer must not silence the rest */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public DataValueSnapshot ReadTag(string path)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(path);
|
||||
if (_cache.TryGetValue(path, out var snapshot))
|
||||
return snapshot;
|
||||
|
||||
// No value has been pushed for this path yet — return a Bad-quality placeholder so
|
||||
// the engine's cold-start guard holds the prior condition rather than NRE-ing on a
|
||||
// null value cast.
|
||||
var now = _clock();
|
||||
return new DataValueSnapshot(Value: null, StatusCode: StatusBad, SourceTimestampUtc: null, ServerTimestampUtc: now);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>
|
||||
/// <b>No-replay contract</b>: a new subscriber does NOT receive a synthetic initial
|
||||
/// notification for the current cached value. Callers that need the current value must
|
||||
/// call <see cref="ReadTag"/> immediately after subscribing. The engine's cold-start path
|
||||
/// (startup-recovery + read-cache-refill) already does this.
|
||||
/// </remarks>
|
||||
public IDisposable SubscribeTag(string path, Action<string, DataValueSnapshot> observer)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(path);
|
||||
ArgumentNullException.ThrowIfNull(observer);
|
||||
|
||||
_observers.AddOrUpdate(
|
||||
path,
|
||||
_ => ImmutableList.Create(observer),
|
||||
(_, existing) => existing.Add(observer));
|
||||
|
||||
return new Subscription(this, path, observer);
|
||||
}
|
||||
|
||||
private void Unsubscribe(string path, Action<string, DataValueSnapshot> observer)
|
||||
{
|
||||
// Atomically remove exactly this observer. Robust to dispose-after-already-removed
|
||||
// (the path entry may be gone, or the observer may already have been pulled).
|
||||
while (_observers.TryGetValue(path, out var existing))
|
||||
{
|
||||
var updated = existing.Remove(observer);
|
||||
if (ReferenceEquals(updated, existing))
|
||||
return; // observer not present — nothing to do
|
||||
|
||||
if (updated.IsEmpty)
|
||||
{
|
||||
// Drop the empty entry, but only if it hasn't changed under us. The
|
||||
// KeyValuePair overload removes atomically iff both key AND value still match.
|
||||
if (_observers.TryRemove(new KeyValuePair<string, ImmutableList<Action<string, DataValueSnapshot>>>(path, existing)))
|
||||
return;
|
||||
// Lost the race — another mutation swapped the entry; retry.
|
||||
continue;
|
||||
}
|
||||
|
||||
if (_observers.TryUpdate(path, updated, existing))
|
||||
return;
|
||||
// Lost the CAS — another subscribe/unsubscribe won; retry with the fresh list.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-observer subscription handle. <see cref="Dispose"/> deregisters exactly the
|
||||
/// observer it was created for and is idempotent — calling it after the observer has
|
||||
/// already been removed is a no-op.
|
||||
/// </summary>
|
||||
private sealed class Subscription : IDisposable
|
||||
{
|
||||
private DependencyMuxTagUpstreamSource? _owner;
|
||||
private readonly string _path;
|
||||
private readonly Action<string, DataValueSnapshot> _observer;
|
||||
|
||||
public Subscription(DependencyMuxTagUpstreamSource owner, string path, Action<string, DataValueSnapshot> observer)
|
||||
{
|
||||
_owner = owner;
|
||||
_path = path;
|
||||
_observer = observer;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
// Swap _owner to null first so a double-dispose can't deregister twice (the
|
||||
// second call sees null). Interlocked makes the guard safe under concurrent
|
||||
// Dispose calls on the same handle.
|
||||
var owner = Interlocked.Exchange(ref _owner, null);
|
||||
owner?.Unsubscribe(_path, _observer);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
|
||||
|
||||
/// <summary>
|
||||
/// Production-side <see cref="IAlarmActorStateStore"/> backed by the
|
||||
/// <see cref="ScriptedAlarmState"/> table in the central config DB. The actor's
|
||||
/// 3-state enum projects into the table's two persisted dimensions: Acked + an
|
||||
/// internal "_lastActiveState" recorded via a synthetic mapping (Inactive ⇒ Acked,
|
||||
/// Active ⇒ Unacked, Acknowledged ⇒ Acked). ActiveState itself is deliberately NOT
|
||||
/// persisted — re-derives from the evaluator on startup (Phase 7 decision #14).
|
||||
/// </summary>
|
||||
public sealed class EfAlarmActorStateStore : IAlarmActorStateStore
|
||||
{
|
||||
private readonly IDbContextFactory<OtOpcUaConfigDbContext> _dbFactory;
|
||||
private readonly ILogger<EfAlarmActorStateStore> _logger;
|
||||
|
||||
/// <summary>Initializes a new instance of the EfAlarmActorStateStore.</summary>
|
||||
/// <param name="dbFactory">The factory for creating database contexts.</param>
|
||||
/// <param name="logger">The logger instance.</param>
|
||||
public EfAlarmActorStateStore(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
|
||||
ILogger<EfAlarmActorStateStore> logger)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>Loads the alarm state snapshot from the database.</summary>
|
||||
/// <param name="alarmId">The identifier of the alarm.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>The alarm state snapshot, or null if not found.</returns>
|
||||
public async Task<AlarmActorStateSnapshot?> LoadAsync(string alarmId, CancellationToken ct)
|
||||
{
|
||||
using var db = await _dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false);
|
||||
var row = await db.ScriptedAlarmStates.AsNoTracking()
|
||||
.FirstOrDefaultAsync(r => r.ScriptedAlarmId == alarmId, ct)
|
||||
.ConfigureAwait(false);
|
||||
if (row is null) return null;
|
||||
|
||||
var state = MapAckedToActorState(row.AckedState);
|
||||
return new AlarmActorStateSnapshot(
|
||||
AlarmId: alarmId,
|
||||
State: state,
|
||||
LastTransitionUtc: row.UpdatedAtUtc,
|
||||
LastAckUser: row.LastAckUser);
|
||||
}
|
||||
|
||||
/// <summary>Saves the alarm state snapshot to the database.</summary>
|
||||
/// <param name="snapshot">The alarm state snapshot to save.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
public async Task SaveAsync(AlarmActorStateSnapshot snapshot, CancellationToken ct)
|
||||
{
|
||||
using var db = await _dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false);
|
||||
var row = await db.ScriptedAlarmStates
|
||||
.FirstOrDefaultAsync(r => r.ScriptedAlarmId == snapshot.AlarmId, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var ackedState = MapActorStateToAcked(snapshot.State);
|
||||
if (row is null)
|
||||
{
|
||||
db.ScriptedAlarmStates.Add(new ScriptedAlarmState
|
||||
{
|
||||
ScriptedAlarmId = snapshot.AlarmId,
|
||||
EnabledState = "Enabled",
|
||||
AckedState = ackedState,
|
||||
ConfirmedState = "Confirmed",
|
||||
ShelvingState = "Unshelved",
|
||||
LastAckUser = snapshot.LastAckUser,
|
||||
LastAckUtc = string.Equals(snapshot.State, "Acknowledged", StringComparison.Ordinal)
|
||||
? snapshot.LastTransitionUtc
|
||||
: null,
|
||||
UpdatedAtUtc = snapshot.LastTransitionUtc,
|
||||
CommentsJson = "[]",
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
row.AckedState = ackedState;
|
||||
row.LastAckUser = snapshot.LastAckUser ?? row.LastAckUser;
|
||||
if (string.Equals(snapshot.State, "Acknowledged", StringComparison.Ordinal))
|
||||
row.LastAckUtc = snapshot.LastTransitionUtc;
|
||||
row.UpdatedAtUtc = snapshot.LastTransitionUtc;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException ex)
|
||||
{
|
||||
// Two actors racing to save the same alarm is benign — the last writer wins on
|
||||
// UpdatedAtUtc, and the next transition on either side will write again. Log
|
||||
// + drop so a race doesn't crash the dispatcher.
|
||||
_logger.LogDebug(ex, "EfAlarmActorStateStore: concurrency conflict for {AlarmId}; dropping save",
|
||||
snapshot.AlarmId);
|
||||
}
|
||||
}
|
||||
|
||||
private static string MapActorStateToAcked(string actorState) => actorState switch
|
||||
{
|
||||
"Active" => "Unacknowledged",
|
||||
"Acknowledged" => "Acknowledged",
|
||||
// Inactive maps to Acknowledged — when an alarm clears, nothing is left to ack.
|
||||
_ => "Acknowledged",
|
||||
};
|
||||
|
||||
private static string MapAckedToActorState(string ackedState)
|
||||
{
|
||||
// Only Active distinguishes from Acked — Inactive comes from a re-eval, not from
|
||||
// the table. Persisted "Unacknowledged" implies the actor was last Active +
|
||||
// un-acked; we restore it to Active so a restart doesn't drop pending operator work.
|
||||
return string.Equals(ackedState, "Unacknowledged", StringComparison.Ordinal)
|
||||
? "Active"
|
||||
: "Acknowledged";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
|
||||
|
||||
/// <summary>
|
||||
/// Production-side <see cref="IAlarmStateStore"/> backed by the
|
||||
/// <see cref="ScriptedAlarmState"/> table in the central config DB. This store maps the
|
||||
/// full Part 9 <see cref="AlarmConditionState"/> — Enabled / Acked / Confirmed / Shelving
|
||||
/// + the ack/confirm audit trail + operator comments.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>ActiveState is NOT persisted</b> — the entity has no Active column. On
|
||||
/// <see cref="LoadAsync"/> it is restored as <see cref="AlarmActiveState.Inactive"/>;
|
||||
/// the engine re-derives it from the live predicate on startup (Phase 7 decision #14).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>LastTransitionUtc ↔ UpdatedAtUtc</b>: the table has no dedicated transition
|
||||
/// column, so <c>LastTransitionUtc</c> is written into the row-write
|
||||
/// <see cref="ScriptedAlarmState.UpdatedAtUtc"/> on save and read back from it on load.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>LastActiveUtc / LastClearedUtc are transient</b> — they have no columns and
|
||||
/// default to <c>null</c> on load (they re-derive from the predicate alongside Active).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="AlarmConditionState.Comments"/> serializes to/from
|
||||
/// <see cref="ScriptedAlarmState.CommentsJson"/> via System.Text.Json. An empty list
|
||||
/// round-trips as <c>"[]"</c> (matching the entity default).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class EfAlarmConditionStateStore : IAlarmStateStore
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
private readonly IDbContextFactory<OtOpcUaConfigDbContext> _dbFactory;
|
||||
private readonly ILogger<EfAlarmConditionStateStore> _logger;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="EfAlarmConditionStateStore"/>.</summary>
|
||||
/// <param name="dbFactory">The factory for creating config database contexts.</param>
|
||||
/// <param name="logger">The logger instance.</param>
|
||||
public EfAlarmConditionStateStore(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
|
||||
ILogger<EfAlarmConditionStateStore> logger)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<AlarmConditionState?> LoadAsync(string alarmId, CancellationToken ct)
|
||||
{
|
||||
using var db = await _dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false);
|
||||
var row = await db.ScriptedAlarmStates.AsNoTracking()
|
||||
.FirstOrDefaultAsync(r => r.ScriptedAlarmId == alarmId, ct)
|
||||
.ConfigureAwait(false);
|
||||
return row is null ? null : MapToState(row);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<AlarmConditionState>> LoadAllAsync(CancellationToken ct)
|
||||
{
|
||||
using var db = await _dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false);
|
||||
var rows = await db.ScriptedAlarmStates.AsNoTracking()
|
||||
.ToListAsync(ct)
|
||||
.ConfigureAwait(false);
|
||||
return rows.Select(MapToState).ToArray();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// <b>Concurrency assumption</b>: saves for a given <c>alarmId</c> are serialized by the
|
||||
/// owning host actor (one actor owns the engine per equipment). The check-then-insert
|
||||
/// pattern is therefore safe under that guarantee — two concurrent inserts for the same
|
||||
/// alarm cannot occur in the live
|
||||
/// runtime. The <see cref="DbUpdateConcurrencyException"/> catch handles the edge case of a
|
||||
/// racing concurrent restart during crash recovery.
|
||||
/// </remarks>
|
||||
public async Task SaveAsync(AlarmConditionState state, CancellationToken ct)
|
||||
{
|
||||
using var db = await _dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false);
|
||||
var row = await db.ScriptedAlarmStates
|
||||
.FirstOrDefaultAsync(r => r.ScriptedAlarmId == state.AlarmId, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (row is null)
|
||||
{
|
||||
// Required members are set here to satisfy the compiler; ApplyState is the single
|
||||
// source of truth for all columns and immediately overwrites these values below.
|
||||
row = new ScriptedAlarmState
|
||||
{
|
||||
ScriptedAlarmId = state.AlarmId,
|
||||
EnabledState = MapEnabledToColumn(state.Enabled),
|
||||
AckedState = MapAckedToColumn(state.Acked),
|
||||
ConfirmedState = MapConfirmedToColumn(state.Confirmed),
|
||||
ShelvingState = MapShelvingToColumn(state.Shelving.Kind),
|
||||
};
|
||||
ApplyState(row, state);
|
||||
db.ScriptedAlarmStates.Add(row);
|
||||
}
|
||||
else
|
||||
{
|
||||
ApplyState(row, state);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException ex)
|
||||
{
|
||||
// Two writers racing to save the same alarm is benign — last writer wins on
|
||||
// UpdatedAtUtc and the next transition writes again. Log + drop so a race never
|
||||
// crashes the engine.
|
||||
_logger.LogDebug(ex,
|
||||
"EfAlarmConditionStateStore: concurrency conflict for {AlarmId}; dropping save",
|
||||
state.AlarmId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RemoveAsync(string alarmId, CancellationToken ct)
|
||||
{
|
||||
using var db = await _dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false);
|
||||
var row = await db.ScriptedAlarmStates
|
||||
.FirstOrDefaultAsync(r => r.ScriptedAlarmId == alarmId, ct)
|
||||
.ConfigureAwait(false);
|
||||
if (row is null) return;
|
||||
|
||||
db.ScriptedAlarmStates.Remove(row);
|
||||
await db.SaveChangesAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static void ApplyState(ScriptedAlarmState row, AlarmConditionState state)
|
||||
{
|
||||
row.EnabledState = MapEnabledToColumn(state.Enabled);
|
||||
row.AckedState = MapAckedToColumn(state.Acked);
|
||||
row.ConfirmedState = MapConfirmedToColumn(state.Confirmed);
|
||||
row.ShelvingState = MapShelvingToColumn(state.Shelving.Kind);
|
||||
row.ShelvingExpiresUtc = state.Shelving.UnshelveAtUtc;
|
||||
row.LastAckUser = state.LastAckUser;
|
||||
row.LastAckComment = state.LastAckComment;
|
||||
row.LastAckUtc = state.LastAckUtc;
|
||||
row.LastConfirmUser = state.LastConfirmUser;
|
||||
row.LastConfirmComment = state.LastConfirmComment;
|
||||
row.LastConfirmUtc = state.LastConfirmUtc;
|
||||
row.CommentsJson = SerializeComments(state.Comments);
|
||||
// No dedicated transition column — persist LastTransitionUtc into UpdatedAtUtc.
|
||||
row.UpdatedAtUtc = state.LastTransitionUtc;
|
||||
}
|
||||
|
||||
private static AlarmConditionState MapToState(ScriptedAlarmState row) => new(
|
||||
AlarmId: row.ScriptedAlarmId,
|
||||
Enabled: string.Equals(row.EnabledState, "Disabled", StringComparison.Ordinal)
|
||||
? AlarmEnabledState.Disabled
|
||||
: AlarmEnabledState.Enabled, // unknown string → Enabled (safe default)
|
||||
// Active is not persisted — the engine re-derives it from the predicate at startup.
|
||||
Active: AlarmActiveState.Inactive,
|
||||
Acked: string.Equals(row.AckedState, "Acknowledged", StringComparison.Ordinal)
|
||||
? AlarmAckedState.Acknowledged
|
||||
: AlarmAckedState.Unacknowledged, // unknown string → Unacknowledged (safe default)
|
||||
Confirmed: string.Equals(row.ConfirmedState, "Confirmed", StringComparison.Ordinal)
|
||||
? AlarmConfirmedState.Confirmed
|
||||
: AlarmConfirmedState.Unconfirmed, // unknown string → Unconfirmed (safe default)
|
||||
Shelving: new ShelvingState(MapShelvingFromColumn(row.ShelvingState), row.ShelvingExpiresUtc),
|
||||
// No transition column — UpdatedAtUtc carries the last transition timestamp.
|
||||
LastTransitionUtc: row.UpdatedAtUtc,
|
||||
// LastActiveUtc / LastClearedUtc have no columns — they re-derive with Active, so null on load.
|
||||
LastActiveUtc: null,
|
||||
LastClearedUtc: null,
|
||||
LastAckUtc: row.LastAckUtc,
|
||||
LastAckUser: row.LastAckUser,
|
||||
LastAckComment: row.LastAckComment,
|
||||
LastConfirmUtc: row.LastConfirmUtc,
|
||||
LastConfirmUser: row.LastConfirmUser,
|
||||
LastConfirmComment: row.LastConfirmComment,
|
||||
Comments: DeserializeComments(row.CommentsJson));
|
||||
|
||||
private static string MapEnabledToColumn(AlarmEnabledState enabled)
|
||||
=> enabled == AlarmEnabledState.Enabled ? "Enabled" : "Disabled";
|
||||
|
||||
private static string MapAckedToColumn(AlarmAckedState acked)
|
||||
=> acked == AlarmAckedState.Acknowledged ? "Acknowledged" : "Unacknowledged";
|
||||
|
||||
private static string MapConfirmedToColumn(AlarmConfirmedState confirmed)
|
||||
=> confirmed == AlarmConfirmedState.Confirmed ? "Confirmed" : "Unconfirmed";
|
||||
|
||||
private static string MapShelvingToColumn(ShelvingKind kind) => kind switch
|
||||
{
|
||||
ShelvingKind.OneShot => "OneShotShelved",
|
||||
ShelvingKind.Timed => "TimedShelved",
|
||||
_ => "Unshelved",
|
||||
};
|
||||
|
||||
private static ShelvingKind MapShelvingFromColumn(string column) => column switch
|
||||
{
|
||||
"OneShotShelved" => ShelvingKind.OneShot,
|
||||
"TimedShelved" => ShelvingKind.Timed,
|
||||
_ => ShelvingKind.Unshelved, // unknown string → Unshelved (safe default)
|
||||
};
|
||||
|
||||
private static string SerializeComments(ImmutableList<AlarmComment> comments)
|
||||
{
|
||||
if (comments.IsEmpty) return "[]";
|
||||
var dtos = comments.Select(c => new CommentDto
|
||||
{
|
||||
// AlarmComment.TimestampUtc must be DateTimeKind.Utc for correct ISO-8601 round-trip;
|
||||
// the engine always creates AlarmComment instances with Utc kind.
|
||||
TimestampUtc = c.TimestampUtc,
|
||||
User = c.User,
|
||||
Kind = c.Kind,
|
||||
Text = c.Text,
|
||||
});
|
||||
return JsonSerializer.Serialize(dtos, JsonOptions);
|
||||
}
|
||||
|
||||
private static ImmutableList<AlarmComment> DeserializeComments(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return ImmutableList<AlarmComment>.Empty;
|
||||
var dtos = JsonSerializer.Deserialize<List<CommentDto>>(json, JsonOptions);
|
||||
if (dtos is null || dtos.Count == 0) return ImmutableList<AlarmComment>.Empty;
|
||||
return dtos
|
||||
.Select(d => new AlarmComment(d.TimestampUtc, d.User ?? string.Empty, d.Kind ?? string.Empty, d.Text ?? string.Empty))
|
||||
.ToImmutableList();
|
||||
}
|
||||
|
||||
/// <summary>Stable on-disk shape for a persisted <see cref="AlarmComment"/> in <c>CommentsJson</c>.</summary>
|
||||
private sealed class CommentDto
|
||||
{
|
||||
/// <summary>When the comment was recorded (UTC).</summary>
|
||||
public DateTime TimestampUtc { get; set; }
|
||||
|
||||
/// <summary>Identity of the actor that wrote the comment.</summary>
|
||||
public string? User { get; set; }
|
||||
|
||||
/// <summary>Human-readable classification of the comment (Acknowledge, Confirm, …).</summary>
|
||||
public string? Kind { get; set; }
|
||||
|
||||
/// <summary>Operator-supplied or engine-generated comment text.</summary>
|
||||
public string? Text { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,254 +0,0 @@
|
||||
using Akka.Actor;
|
||||
using Akka.Cluster.Tools.PublishSubscribe;
|
||||
using Akka.Event;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
|
||||
|
||||
public enum ScriptedAlarmActorState { Inactive, Active, Acknowledged }
|
||||
|
||||
/// <summary>
|
||||
/// One scripted alarm. Receives dependency value updates, runs the predicate via an
|
||||
/// injected <see cref="IScriptedAlarmEvaluator"/>, and on transitions publishes both
|
||||
/// an <see cref="AlarmTransitionEvent"/> on the cluster <c>alerts</c> DPS topic and a
|
||||
/// <see cref="ScriptLogEntry"/> on <c>script-logs</c>. Manual <see cref="AcknowledgeAlarm"/>
|
||||
/// + <see cref="ConditionCleared"/> still flow through the same state machine so the
|
||||
/// legacy callers keep working.
|
||||
/// </summary>
|
||||
public sealed class ScriptedAlarmActor : ReceiveActor
|
||||
{
|
||||
public const string AlertsTopic = "alerts";
|
||||
public const string ScriptLogsTopic = "script-logs";
|
||||
|
||||
public sealed record DependencyValueChanged(string TagId, object? Value, DateTime TimestampUtc);
|
||||
public sealed record ConditionMet(string Reason);
|
||||
public sealed record AcknowledgeAlarm(string Actor);
|
||||
public sealed record ConditionCleared;
|
||||
public sealed record StateChanged(string AlarmId, ScriptedAlarmActorState State, DateTime AtUtc);
|
||||
|
||||
public sealed record AlarmConfig(
|
||||
string AlarmId,
|
||||
string AlarmName,
|
||||
string EquipmentPath,
|
||||
int Severity,
|
||||
string? Predicate);
|
||||
|
||||
private readonly AlarmConfig _config;
|
||||
private readonly IScriptedAlarmEvaluator _evaluator;
|
||||
private readonly IAlarmActorStateStore _stateStore;
|
||||
private readonly Func<DPSPublisher>? _publisherFactory;
|
||||
private readonly ILoggingAdapter _log = Context.GetLogger();
|
||||
private readonly Dictionary<string, object?> _dependencies = new(StringComparer.Ordinal);
|
||||
|
||||
private ScriptedAlarmActorState _state = ScriptedAlarmActorState.Inactive;
|
||||
private string? _lastAckUser;
|
||||
|
||||
public sealed record StateRestored(ScriptedAlarmActorState State, string? LastAckUser);
|
||||
|
||||
/// <summary>Creates a new Props for a ScriptedAlarmActor with the given configuration and optional dependencies.</summary>
|
||||
/// <param name="config">The alarm configuration.</param>
|
||||
/// <param name="evaluator">The alarm evaluator; defaults to null evaluator if not provided.</param>
|
||||
/// <param name="publisherFactory">Optional factory for creating DPS publishers.</param>
|
||||
/// <param name="stateStore">Optional state store for persistence; defaults to null store if not provided.</param>
|
||||
/// <returns>Akka Props for creating the actor.</returns>
|
||||
public static Props Props(
|
||||
AlarmConfig config,
|
||||
IScriptedAlarmEvaluator? evaluator = null,
|
||||
Func<DPSPublisher>? publisherFactory = null,
|
||||
IAlarmActorStateStore? stateStore = null) =>
|
||||
Akka.Actor.Props.Create(() => new ScriptedAlarmActor(
|
||||
config,
|
||||
evaluator ?? NullScriptedAlarmEvaluator.Instance,
|
||||
publisherFactory,
|
||||
stateStore ?? NullAlarmActorStateStore.Instance));
|
||||
|
||||
/// <summary>Legacy single-arg ctor kept for callers that only care about the state machine
|
||||
/// (no engine evaluation, no DPS fan-out, no persistence). Equivalent to <c>Props(new AlarmConfig(...))</c>.</summary>
|
||||
/// <param name="alarmId">The alarm identifier, used as both alarm ID and name.</param>
|
||||
/// <returns>Akka Props for creating the actor with minimal configuration.</returns>
|
||||
public static Props Props(string alarmId) =>
|
||||
Props(new AlarmConfig(alarmId, alarmId, EquipmentPath: "", Severity: 500, Predicate: null));
|
||||
|
||||
/// <summary>Initializes a new ScriptedAlarmActor with the given configuration and dependencies.</summary>
|
||||
/// <param name="config">The alarm configuration.</param>
|
||||
/// <param name="evaluator">The alarm predicate evaluator.</param>
|
||||
/// <param name="publisherFactory">Optional factory for creating DPS publishers.</param>
|
||||
/// <param name="stateStore">The state store for loading and saving alarm state.</param>
|
||||
public ScriptedAlarmActor(
|
||||
AlarmConfig config,
|
||||
IScriptedAlarmEvaluator evaluator,
|
||||
Func<DPSPublisher>? publisherFactory,
|
||||
IAlarmActorStateStore stateStore)
|
||||
{
|
||||
_config = config;
|
||||
_evaluator = evaluator;
|
||||
_publisherFactory = publisherFactory;
|
||||
_stateStore = stateStore;
|
||||
|
||||
Receive<DependencyValueChanged>(OnDependencyChanged);
|
||||
Receive<ConditionMet>(_ => { if (_state == ScriptedAlarmActorState.Inactive) Transition(ScriptedAlarmActorState.Active, user: "system"); });
|
||||
Receive<AcknowledgeAlarm>(msg => { if (_state == ScriptedAlarmActorState.Active) Transition(ScriptedAlarmActorState.Acknowledged, user: msg.Actor); });
|
||||
Receive<ConditionCleared>(_ => { if (_state != ScriptedAlarmActorState.Inactive) Transition(ScriptedAlarmActorState.Inactive, user: "system"); });
|
||||
Receive<StateRestored>(OnStateRestored);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void PreStart()
|
||||
{
|
||||
// Load persisted state — when the store has a row, restore in-memory state before the
|
||||
// first dependency-change arrives. Async I/O is piped back as StateRestored so we don't
|
||||
// block the message-loop thread; until it arrives the actor stays at the default Inactive.
|
||||
var self = Self;
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var snapshot = await _stateStore.LoadAsync(_config.AlarmId, CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
if (snapshot is null) return;
|
||||
if (!Enum.TryParse<ScriptedAlarmActorState>(snapshot.State, ignoreCase: true, out var parsed))
|
||||
return;
|
||||
self.Tell(new StateRestored(parsed, snapshot.LastAckUser));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Warning(ex, "ScriptedAlarm {Id}: state-store load failed; booting Inactive",
|
||||
_config.AlarmId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void OnStateRestored(StateRestored msg)
|
||||
{
|
||||
// Active is re-derived from the evaluator at the next DependencyValueChanged — we still
|
||||
// restore Active here so operators don't lose the in-flight transition if a restart races
|
||||
// ahead of the next eval. The first evaluator tick will correct it if the condition cleared.
|
||||
_state = msg.State;
|
||||
_lastAckUser = msg.LastAckUser;
|
||||
_log.Info("ScriptedAlarm {Id}: restored persisted state {State} (lastAck={User})",
|
||||
_config.AlarmId, _state, _lastAckUser ?? "(none)");
|
||||
}
|
||||
|
||||
private void OnDependencyChanged(DependencyValueChanged msg)
|
||||
{
|
||||
_dependencies[msg.TagId] = msg.Value;
|
||||
|
||||
if (string.IsNullOrEmpty(_config.Predicate)) return;
|
||||
|
||||
ScriptedAlarmEvalResult result;
|
||||
try
|
||||
{
|
||||
result = _evaluator.Evaluate(_config.AlarmId, _config.Predicate, _dependencies);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Warning(ex, "ScriptedAlarm {Id}: evaluator threw", _config.AlarmId);
|
||||
PublishLog("Error", $"evaluator threw: {ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
PublishLog("Warning", result.Reason ?? "evaluator failure");
|
||||
return;
|
||||
}
|
||||
|
||||
// Active condition wins regardless of ack state — re-firing is suppressed because
|
||||
// _state already == Active. Cleared moves Active OR Acknowledged → Inactive.
|
||||
if (result.Active && _state == ScriptedAlarmActorState.Inactive)
|
||||
{
|
||||
Transition(ScriptedAlarmActorState.Active, user: "system");
|
||||
}
|
||||
else if (!result.Active && _state != ScriptedAlarmActorState.Inactive)
|
||||
{
|
||||
Transition(ScriptedAlarmActorState.Inactive, user: "system");
|
||||
}
|
||||
}
|
||||
|
||||
private void Transition(ScriptedAlarmActorState next, string user)
|
||||
{
|
||||
var prev = _state;
|
||||
_state = next;
|
||||
if (next == ScriptedAlarmActorState.Acknowledged) _lastAckUser = user;
|
||||
_log.Info("ScriptedAlarm {Id}: {From} → {To}", _config.AlarmId, prev, next);
|
||||
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
Context.Parent.Tell(new StateChanged(_config.AlarmId, next, nowUtc));
|
||||
PersistStateAsync(nowUtc);
|
||||
|
||||
var kind = next switch
|
||||
{
|
||||
ScriptedAlarmActorState.Active => "Activated",
|
||||
ScriptedAlarmActorState.Acknowledged => "Acknowledged",
|
||||
ScriptedAlarmActorState.Inactive => "Cleared",
|
||||
_ => next.ToString(),
|
||||
};
|
||||
|
||||
OtOpcUaTelemetry.ScriptedAlarmTransition.Add(1,
|
||||
new KeyValuePair<string, object?>("state", kind.ToLowerInvariant()));
|
||||
|
||||
var evt = new AlarmTransitionEvent(
|
||||
AlarmId: _config.AlarmId,
|
||||
EquipmentPath: _config.EquipmentPath,
|
||||
AlarmName: _config.AlarmName,
|
||||
TransitionKind: kind,
|
||||
Severity: _config.Severity,
|
||||
Message: $"{_config.AlarmName} {kind}",
|
||||
User: user,
|
||||
TimestampUtc: nowUtc);
|
||||
|
||||
PublishOrFallback(AlertsTopic, evt);
|
||||
PublishLog("Information", $"{_config.AlarmName} {kind} (by {user})");
|
||||
}
|
||||
|
||||
private void PublishLog(string level, string message)
|
||||
{
|
||||
var entry = new ScriptLogEntry(
|
||||
ScriptId: _config.AlarmId,
|
||||
Level: level,
|
||||
Message: message,
|
||||
TimestampUtc: DateTime.UtcNow,
|
||||
VirtualTagId: null,
|
||||
AlarmId: _config.AlarmId,
|
||||
EquipmentId: null);
|
||||
PublishOrFallback(ScriptLogsTopic, entry);
|
||||
}
|
||||
|
||||
private void PublishOrFallback(string topic, object payload)
|
||||
{
|
||||
if (_publisherFactory is not null)
|
||||
{
|
||||
_publisherFactory().Publish(topic, payload);
|
||||
return;
|
||||
}
|
||||
DistributedPubSub.Get(Context.System).Mediator.Tell(new Publish(topic, payload));
|
||||
}
|
||||
|
||||
private void PersistStateAsync(DateTime nowUtc)
|
||||
{
|
||||
var snapshot = new AlarmActorStateSnapshot(
|
||||
AlarmId: _config.AlarmId,
|
||||
State: _state.ToString(),
|
||||
LastTransitionUtc: nowUtc,
|
||||
LastAckUser: _lastAckUser);
|
||||
|
||||
// Fire-and-forget. Save failures get logged but don't block the message loop —
|
||||
// the worst case is a restart loses one transition, which then re-derives from
|
||||
// the evaluator's next tick anyway.
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await _stateStore.SaveAsync(snapshot, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Warning(ex, "ScriptedAlarm {Id}: state-store save failed", _config.AlarmId);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
using Akka.Actor;
|
||||
using Akka.Cluster.Tools.PublishSubscribe;
|
||||
using Akka.Event;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
|
||||
|
||||
/// <summary>
|
||||
/// Akka host that owns one <see cref="ScriptedAlarmEngine"/> for an Equipment-namespace
|
||||
/// driver node, feeds it live tag values, and bridges its emissions to OPC UA publish + the
|
||||
/// cluster <c>alerts</c> DistributedPubSub topic.
|
||||
///
|
||||
/// <para>
|
||||
/// The host is the engine's lifecycle owner: the caller (the driver-role startup) builds
|
||||
/// the <see cref="DependencyMuxTagUpstreamSource"/>, constructs a <see cref="ScriptedAlarmEngine"/>
|
||||
/// <b>around that same upstream</b>, and passes both here. The host disposes the engine in
|
||||
/// <see cref="PostStop"/>.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Data flow.</b> <see cref="DependencyMuxActor"/> delivers a
|
||||
/// <see cref="VirtualTagActor.DependencyValueChanged"/> for every tag the loaded alarms
|
||||
/// depend on; the host pushes each into the upstream. The engine self-evaluates: its own
|
||||
/// <see cref="ITagUpstreamSource.SubscribeTag"/> observer re-runs the predicates and raises
|
||||
/// <see cref="ScriptedAlarmEngine.OnEvent"/>. The host does NOT call evaluate — it only
|
||||
/// feeds values in and fans emissions out.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Thread-safety.</b> <see cref="ScriptedAlarmEngine.OnEvent"/> fires on the engine's
|
||||
/// background worker thread (its fire-and-forget re-evaluation), NOT on the actor thread.
|
||||
/// The ctor subscribes a handler that does nothing but <c>Self.Tell(new EngineEmission(e))</c>
|
||||
/// — <see cref="ICanTell.Tell"/> is thread-safe. ALL sink work (touching <see cref="Context"/>,
|
||||
/// the publish actor ref, the DPS mediator) then happens on the actor thread in
|
||||
/// <see cref="OnEngineEmission"/>. No <see cref="Context"/> or actor state is ever read or
|
||||
/// written from the OnEvent callback.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Historization.</b> The host publishes each transition as an
|
||||
/// <see cref="AlarmTransitionEvent"/> to the <c>alerts</c> topic ONLY. That topic is the
|
||||
/// historization path: <c>HistorianAdapterActor</c>'s upstream and the Admin UI Alerts page
|
||||
/// both consume <c>alerts</c>. The host deliberately does NOT also <c>Tell</c> the historian
|
||||
/// adapter directly — doing so would double-historize every transition. (T9 plan called for a
|
||||
/// "direct historian tell"; that was dropped because the alerts topic already feeds the
|
||||
/// historian path, so a direct tell would duplicate every row.)
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class ScriptedAlarmHostActor : ReceiveActor
|
||||
{
|
||||
/// <summary>The cluster DistributedPubSub topic every alarm transition is published to. Matches
|
||||
/// the constant the (retired) <c>ScriptedAlarmActor</c> used so subscribers stay wired.</summary>
|
||||
public const string AlertsTopic = "alerts";
|
||||
|
||||
/// <summary>The cluster DistributedPubSub topic inbound OPC UA Part 9 alarm method calls
|
||||
/// (Acknowledge / Confirm / Shelve / AddComment) are routed onto as <see cref="AlarmCommand"/>s.
|
||||
/// The OPC UA node manager's condition handlers build the command (after the <c>AlarmAck</c> role
|
||||
/// gate, T18) or the AdminUI path republishes via <c>AdminOperationsActor</c> (T21); this host's
|
||||
/// boot wiring subscribes; T19's engine-side handler consumes it. Re-exports the single Commons
|
||||
/// const so every publisher/subscriber shares one literal.</summary>
|
||||
public const string AlarmCommandsTopic = ZB.MOM.WW.OtOpcUa.Commons.OpcUa.AlarmCommandsTopic.Name;
|
||||
|
||||
/// <summary>Reconcile the loaded alarm set to exactly the enabled subset of <paramref name="Plans"/>:
|
||||
/// builds <see cref="ScriptedAlarmDefinition"/>s (skipping disabled plans), reloads the engine, and
|
||||
/// re-registers mux interest for the union of dependency refs.</summary>
|
||||
/// <param name="Plans">The desired Equipment-namespace scripted-alarm plans.</param>
|
||||
public sealed record ApplyScriptedAlarms(IReadOnlyList<EquipmentScriptedAlarmPlan> Plans);
|
||||
|
||||
/// <summary>Marshals an engine emission off the engine's worker thread onto the actor thread.
|
||||
/// Carries the <see cref="ScriptedAlarmEvent"/> the engine raised on <c>OnEvent</c>.</summary>
|
||||
private sealed record EngineEmission(ScriptedAlarmEvent Event);
|
||||
|
||||
/// <summary>Pipe-back completion of an in-flight <see cref="ScriptedAlarmEngine.LoadAsync"/>:
|
||||
/// carries the union of dependency refs to register mux interest for AFTER the load completed
|
||||
/// (so the engine's upstream subscriptions exist before any value can arrive). <see cref="Generation"/>
|
||||
/// is the load generation captured when <see cref="OnApply"/> kicked the load off; a stale
|
||||
/// continuation (an earlier generation completing after a newer apply) is discarded in
|
||||
/// <see cref="OnAlarmsLoaded"/> so out-of-order completions can't register stale dep refs.</summary>
|
||||
private sealed record AlarmsLoaded(IReadOnlyList<string> DepRefs, int Generation);
|
||||
|
||||
/// <summary>Pipe-back marker for a <see cref="ScriptedAlarmEngine.LoadAsync"/> that was cancelled
|
||||
/// because the actor is stopping (its <see cref="_cts"/> fired in <see cref="PostStop"/>). It is a
|
||||
/// no-op — there is nothing to register and no fault to log — and exists only so a cancelled load
|
||||
/// pipes back a quiet message instead of a <see cref="Status.Failure"/> that would log a spurious
|
||||
/// Warning on every clean shutdown.</summary>
|
||||
private sealed record AlarmsLoadCanceled;
|
||||
|
||||
private readonly IActorRef _publishActor;
|
||||
private readonly IActorRef? _mux;
|
||||
private readonly DependencyMuxTagUpstreamSource _upstream;
|
||||
private readonly ScriptedAlarmEngine _engine;
|
||||
private readonly ILoggingAdapter _log = Context.GetLogger();
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private readonly EventHandler<ScriptedAlarmEvent> _onEngineEvent;
|
||||
|
||||
/// <summary>Monotonic load generation, bumped on every <see cref="OnApply"/>. The continuation that
|
||||
/// pipes back an <see cref="AlarmsLoaded"/> captures the generation it was started under; a stale
|
||||
/// completion (an earlier generation arriving after a newer apply) is discarded in
|
||||
/// <see cref="OnAlarmsLoaded"/> so out-of-order loads never register superseded dep refs.</summary>
|
||||
private int _loadGeneration;
|
||||
|
||||
/// <summary>Cached cluster DistributedPubSub mediator, resolved once in <see cref="PreStart"/> (on the
|
||||
/// actor thread) and reused for every emission instead of re-resolving it per-publish.</summary>
|
||||
private IActorRef _mediator = null!;
|
||||
|
||||
/// <summary>Factory method to create Props for a <see cref="ScriptedAlarmHostActor"/>.</summary>
|
||||
/// <param name="publishActor">The OPC UA publish actor that consumes
|
||||
/// <see cref="OpcUaPublishActor.AlarmStateUpdate"/> bridged from engine emissions.</param>
|
||||
/// <param name="mux">Optional dependency multiplexer the host registers interest with so it
|
||||
/// receives a <see cref="VirtualTagActor.DependencyValueChanged"/> per dependency tag. Null on the
|
||||
/// dev/Mac path (no live values).</param>
|
||||
/// <param name="upstream">The mux-fed upstream the engine reads + subscribes from. MUST be the
|
||||
/// same instance the <paramref name="engine"/> was constructed around.</param>
|
||||
/// <param name="engine">The scripted-alarm engine this host owns + disposes.</param>
|
||||
public static Props Props(
|
||||
IActorRef publishActor,
|
||||
IActorRef? mux,
|
||||
DependencyMuxTagUpstreamSource upstream,
|
||||
ScriptedAlarmEngine engine) =>
|
||||
Akka.Actor.Props.Create(() => new ScriptedAlarmHostActor(publishActor, mux, upstream, engine));
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="ScriptedAlarmHostActor"/> class.</summary>
|
||||
/// <param name="publishActor">The OPC UA publish actor emissions are bridged to.</param>
|
||||
/// <param name="mux">Optional dependency multiplexer the host registers dependency interest with.</param>
|
||||
/// <param name="upstream">The mux-fed upstream the engine reads + subscribes from (same instance the engine wraps).</param>
|
||||
/// <param name="engine">The scripted-alarm engine this host owns + disposes.</param>
|
||||
public ScriptedAlarmHostActor(
|
||||
IActorRef publishActor,
|
||||
IActorRef? mux,
|
||||
DependencyMuxTagUpstreamSource upstream,
|
||||
ScriptedAlarmEngine engine)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(publishActor);
|
||||
ArgumentNullException.ThrowIfNull(upstream);
|
||||
ArgumentNullException.ThrowIfNull(engine);
|
||||
_publishActor = publishActor;
|
||||
_mux = mux;
|
||||
_upstream = upstream;
|
||||
_engine = engine;
|
||||
|
||||
// OnEvent fires on the engine's worker thread. NEVER touch Context / actor state here —
|
||||
// marshal onto the actor thread via the thread-safe Self.Tell. Keep the handler in a field
|
||||
// so PostStop can unsubscribe it. (Self is captured once; it is stable for the actor's life.)
|
||||
var self = Self;
|
||||
_onEngineEvent = (_, e) => self.Tell(new EngineEmission(e));
|
||||
_engine.OnEvent += _onEngineEvent;
|
||||
|
||||
Receive<ApplyScriptedAlarms>(OnApply);
|
||||
Receive<AlarmsLoaded>(OnAlarmsLoaded);
|
||||
Receive<VirtualTagActor.DependencyValueChanged>(OnDependencyChanged);
|
||||
Receive<EngineEmission>(OnEngineEmission);
|
||||
// Inbound OPC UA Part 9 alarm method calls arrive as AlarmCommands on the cluster
|
||||
// `alarm-commands` DPS topic (T18 publishes them after the AlarmAck role gate). The topic is a
|
||||
// cluster-wide broadcast — every host node receives every command — so OnAlarmCommand filters to
|
||||
// the alarms THIS host's engine owns before driving the matching engine op. The engine ops are
|
||||
// async, and this project's Akka analyzer (AK2003) forbids an async-void Receive delegate, so
|
||||
// the handler is a Task-returning ReceiveAsync: Akka suspends the mailbox until the op completes
|
||||
// (ordered, awaited on the actor thread) and routes any escaped fault through supervision.
|
||||
ReceiveAsync<AlarmCommand>(OnAlarmCommand);
|
||||
// A faulted LoadAsync pipes back a Status.Failure (see OnApply) — log it and stay inert so the
|
||||
// failure doesn't hit the dead-letter log.
|
||||
Receive<Status.Failure>(OnLoadFailed);
|
||||
// A LoadAsync cancelled by PostStop's _cts pipes back this marker. The actor is stopping, so
|
||||
// there's nothing to do — swallow it quietly (no Warning, no dead letter).
|
||||
Receive<AlarmsLoadCanceled>(_ => { });
|
||||
// DPS Subscribe (PreStart) acks back here once the mediator has registered Self on the topic.
|
||||
// No-op — the subscription is live the moment the ack arrives; we only need to keep it off the
|
||||
// dead-letter log. Matches OpcUaPublishActor / DriverHostActor's SubscribeAck convention.
|
||||
Receive<SubscribeAck>(_ => { /* PubSub ack */ });
|
||||
}
|
||||
|
||||
private void OnApply(ApplyScriptedAlarms msg)
|
||||
{
|
||||
// Bump the load generation and capture it into this apply's continuation. Rapid back-to-back
|
||||
// applies kick off concurrent LoadAsyncs whose continuations may complete out of order; tagging
|
||||
// each with its generation lets OnAlarmsLoaded discard any but the latest.
|
||||
var gen = ++_loadGeneration;
|
||||
|
||||
// Skip disabled plans entirely — the engine has no Enabled flag, so a disabled alarm is simply
|
||||
// not loaded (no predicate, no upstream subscription, no events).
|
||||
var enabled = msg.Plans.Where(p => p.Enabled).ToList();
|
||||
|
||||
var defs = enabled.Select(ToDefinition).ToList();
|
||||
|
||||
// Union of dependency refs across the loaded (enabled) alarms — interest is registered with
|
||||
// the mux only AFTER LoadAsync completes (see OnAlarmsLoaded), because the engine establishes
|
||||
// its upstream SubscribeTag subscriptions inside LoadAsync; values must not arrive before then.
|
||||
var depRefs = enabled
|
||||
.SelectMany(p => p.DependencyRefs)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
// PipeTo marshals the completion back onto the actor thread. The continuation runs with
|
||||
// CancellationToken.None so it ALWAYS executes (even after _cts fired on stop) and branches on
|
||||
// the load's outcome: a clean cancel (actor stopping) pipes back a quiet AlarmsLoadCanceled; a
|
||||
// genuine fault pipes back a Status.Failure we log as Warning (the prior generation's mux
|
||||
// subscription stays — a failed reload should not silently drop live values); success pipes back
|
||||
// AlarmsLoaded carrying the refs + the generation it was loaded under.
|
||||
_engine.LoadAsync(defs, _cts.Token)
|
||||
.ContinueWith(
|
||||
t => t.IsCanceled ? (object)new AlarmsLoadCanceled()
|
||||
: t.IsFaulted ? new Status.Failure(t.Exception!.GetBaseException())
|
||||
: new AlarmsLoaded(depRefs, gen),
|
||||
CancellationToken.None,
|
||||
TaskContinuationOptions.ExecuteSynchronously,
|
||||
TaskScheduler.Default)
|
||||
.PipeTo(Self);
|
||||
|
||||
_log.Debug("ScriptedAlarmHost: applying (enabled={Enabled}/{Total}, depRefs={Refs})",
|
||||
enabled.Count, msg.Plans.Count, depRefs.Count);
|
||||
}
|
||||
|
||||
private void OnLoadFailed(Status.Failure msg)
|
||||
=> _log.Warning(msg.Cause, "ScriptedAlarmHost: engine LoadAsync failed — alarms not (re)loaded");
|
||||
|
||||
private void OnAlarmsLoaded(AlarmsLoaded msg)
|
||||
{
|
||||
// Discard a stale completion: if a newer apply has since bumped the generation, this load's
|
||||
// dep refs are superseded and registering them would re-introduce the old set. The latest
|
||||
// generation's continuation will (or already did) register the current interest set.
|
||||
if (msg.Generation != _loadGeneration)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Register mux interest only now that LoadAsync has returned — the engine's upstream
|
||||
// subscriptions exist, so any DependencyValueChanged the mux delivers will be observed by the
|
||||
// engine. RegisterInterest replaces a subscriber's prior interest set (see DependencyMuxActor),
|
||||
// so a re-Apply with a fresh union simply supersedes the old one — no explicit unregister needed.
|
||||
_mux?.Tell(new DependencyMuxActor.RegisterInterest(msg.DepRefs, Self));
|
||||
_log.Debug("ScriptedAlarmHost: loaded; registered mux interest for {Count} dep refs", msg.DepRefs.Count);
|
||||
}
|
||||
|
||||
private void OnDependencyChanged(VirtualTagActor.DependencyValueChanged msg)
|
||||
{
|
||||
// Feed the live value into the upstream the engine subscribes from. StatusCode 0 = Good; the
|
||||
// mux only forwards values it received from a driver publish, so we treat them as Good-quality.
|
||||
_upstream.Push(msg.TagId, new DataValueSnapshot(msg.Value, 0u, msg.TimestampUtc, msg.TimestampUtc));
|
||||
}
|
||||
|
||||
private void OnEngineEmission(EngineEmission msg)
|
||||
{
|
||||
var e = msg.Event;
|
||||
|
||||
// None = no meaningful change; Suppressed = shelving ate the emission. Neither should reach a
|
||||
// sink. (The engine already filters these out of BuildEmission, but guard defensively.)
|
||||
if (e.Emission is EmissionKind.None or EmissionKind.Suppressed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Bridge to OPC UA: project the FULL Part 9 condition state (enabled/active/acked/confirmed/
|
||||
// shelving/severity/message) onto the materialised condition node via the Commons snapshot.
|
||||
// e.AlarmId is the materialised condition's NodeId (T14 aligned it to the ScriptedAlarmId).
|
||||
_publishActor.Tell(new OpcUaPublishActor.AlarmStateUpdate(
|
||||
AlarmNodeId: e.AlarmId,
|
||||
State: ToSnapshot(e),
|
||||
TimestampUtc: e.TimestampUtc));
|
||||
|
||||
// Publish the transition to the cluster `alerts` topic — the single historization + live
|
||||
// fan-out path. The mediator was cached on the ACTOR thread in PreStart; we only Tell it here.
|
||||
var evt = new AlarmTransitionEvent(
|
||||
AlarmId: e.AlarmId,
|
||||
EquipmentPath: e.EquipmentPath,
|
||||
AlarmName: e.AlarmName,
|
||||
TransitionKind: e.Emission.ToString(),
|
||||
Severity: SeverityToInt(e.Severity),
|
||||
Message: e.Message,
|
||||
User: TransitionUser(e),
|
||||
TimestampUtc: e.TimestampUtc);
|
||||
|
||||
_mediator.Tell(new Publish(AlertsTopic, evt));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drives an inbound OPC UA Part 9 alarm method call (delivered as an <see cref="AlarmCommand"/>
|
||||
/// on the cluster <c>alarm-commands</c> topic) onto the matching <see cref="ScriptedAlarmEngine"/>
|
||||
/// operation.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Ownership filter.</b> The topic is a cluster-wide broadcast; every host node receives
|
||||
/// every command, but each owns a disjoint subset of alarms (its engine's loaded set). A
|
||||
/// command for an alarm this engine does NOT own is a no-op — the owning node will act on it.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>No re-projection.</b> The engine op raises <see cref="ScriptedAlarmEngine.OnEvent"/> on
|
||||
/// success, which already marshals back to <see cref="OnEngineEmission"/> and re-projects the
|
||||
/// condition to the OPC UA node + the alerts topic. So this handler just calls the op and
|
||||
/// awaits; it never touches the publish actor directly.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Async on the actor thread.</b> The handler is a <c>Task</c>-returning
|
||||
/// <c>ReceiveAsync</c> (this project's AK2003 analyzer forbids an async-void Receive
|
||||
/// delegate). Akka suspends the actor's mailbox until the returned task completes, so the op
|
||||
/// runs ordered + awaited on the actor thread — never overlapping the next message. The engine
|
||||
/// also serialises every operation behind its own <c>_evalGate</c> and marshals every emission
|
||||
/// back via <c>Self.Tell</c> (never touching <see cref="Context"/> off-thread). The whole body
|
||||
/// is wrapped in a try/catch so a faulting op can never escape the handler and fault the actor
|
||||
/// — failures are logged like <see cref="OnLoadFailed"/> and swallowed.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="cmd">The inbound alarm command.</param>
|
||||
private async Task OnAlarmCommand(AlarmCommand cmd)
|
||||
{
|
||||
// Ownership filter FIRST: ignore commands for alarms this engine doesn't own. The topic is a
|
||||
// cluster-wide broadcast, so the same command lands on every host — only the owner acts.
|
||||
// Note: a concurrent LoadAsync can momentarily clear the loaded set, so a racing command may
|
||||
// surface a spurious ArgumentException from the engine below — the outer catch absorbs it (benign).
|
||||
if (!_engine.LoadedAlarmIds.Contains(cmd.AlarmId))
|
||||
{
|
||||
_log.Debug("ScriptedAlarmHost: ignoring AlarmCommand {Op} for unowned alarm {AlarmId}",
|
||||
cmd.Operation, cmd.AlarmId);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
switch (cmd.Operation)
|
||||
{
|
||||
case "Acknowledge":
|
||||
await _engine.AcknowledgeAsync(cmd.AlarmId, cmd.User, cmd.Comment, CancellationToken.None);
|
||||
break;
|
||||
case "Confirm":
|
||||
await _engine.ConfirmAsync(cmd.AlarmId, cmd.User, cmd.Comment, CancellationToken.None);
|
||||
break;
|
||||
case "OneShotShelve":
|
||||
await _engine.OneShotShelveAsync(cmd.AlarmId, cmd.User, CancellationToken.None);
|
||||
break;
|
||||
case "TimedShelve":
|
||||
// A timed shelve needs the absolute unshelve instant. T18 derives it from the OPC UA
|
||||
// Duration (UtcNow + shelvingTime); a command missing it is malformed — log + reject
|
||||
// rather than throw (a fault escaping the async Task would trigger a supervision restart).
|
||||
if (cmd.UnshelveAtUtc is not { } unshelveAt)
|
||||
{
|
||||
_log.Warning("ScriptedAlarmHost: rejecting TimedShelve for {AlarmId} — missing UnshelveAtUtc",
|
||||
cmd.AlarmId);
|
||||
return;
|
||||
}
|
||||
await _engine.TimedShelveAsync(cmd.AlarmId, cmd.User, unshelveAt, CancellationToken.None);
|
||||
break;
|
||||
case "Unshelve":
|
||||
await _engine.UnshelveAsync(cmd.AlarmId, cmd.User, CancellationToken.None);
|
||||
break;
|
||||
case "Enable":
|
||||
await _engine.EnableAsync(cmd.AlarmId, cmd.User, CancellationToken.None);
|
||||
break;
|
||||
case "Disable":
|
||||
await _engine.DisableAsync(cmd.AlarmId, cmd.User, CancellationToken.None);
|
||||
break;
|
||||
case "AddComment":
|
||||
if (string.IsNullOrWhiteSpace(cmd.Comment))
|
||||
{
|
||||
_log.Warning("ScriptedAlarmHost: rejecting AddComment for {AlarmId} — empty comment text", cmd.AlarmId);
|
||||
return;
|
||||
}
|
||||
await _engine.AddCommentAsync(cmd.AlarmId, cmd.User, cmd.Comment, CancellationToken.None);
|
||||
break;
|
||||
default:
|
||||
_log.Warning("ScriptedAlarmHost: ignoring AlarmCommand with unknown operation {Op} for {AlarmId}",
|
||||
cmd.Operation, cmd.AlarmId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// A failing engine op must not crash the actor — mirror OnLoadFailed's log-and-stay-inert style.
|
||||
_log.Warning(ex, "ScriptedAlarmHost: engine op {Op} failed for alarm {AlarmId}",
|
||||
cmd.Operation, cmd.AlarmId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void PreStart()
|
||||
{
|
||||
// Resolve the cluster DPS mediator once, on the actor thread, so emissions only Tell it.
|
||||
_mediator = DistributedPubSub.Get(Context.System).Mediator;
|
||||
// Subscribe to the `alarm-commands` topic so inbound OPC UA Part 9 method calls (published by
|
||||
// the node manager's condition handlers, T18) land here as AlarmCommands. The Subscribe is sent
|
||||
// from Self so the SubscribeAck returns to this actor (handled as a no-op in the ctor wiring).
|
||||
_mediator.Tell(new Subscribe(AlarmCommandsTopic, Self));
|
||||
base.PreStart();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void PostStop()
|
||||
{
|
||||
// Unregister mux interest first so no further DependencyValueChanged arrives while we tear down.
|
||||
_mux?.Tell(new DependencyMuxActor.UnregisterInterest(Self));
|
||||
// Cancel any in-flight LoadAsync, detach the OnEvent handler (so a late engine emission can't
|
||||
// Tell a stopping actor), then dispose the engine (which drains its background work + clears).
|
||||
_cts.Cancel();
|
||||
_engine.OnEvent -= _onEngineEvent;
|
||||
_engine.Dispose();
|
||||
_cts.Dispose();
|
||||
base.PostStop();
|
||||
}
|
||||
|
||||
/// <summary>Maps an <see cref="EquipmentScriptedAlarmPlan"/> to the engine's
|
||||
/// <see cref="ScriptedAlarmDefinition"/>. <see cref="EquipmentScriptedAlarmPlan.AlarmType"/> is a
|
||||
/// string that must parse to a defined <see cref="AlarmKind"/> NAME; an unrecognised type — including
|
||||
/// a numeric string like "5" or "-1", which <see cref="Enum.TryParse{TEnum}(string, out TEnum)"/>
|
||||
/// would otherwise accept as an undefined value — falls back to <see cref="AlarmKind.AlarmCondition"/>
|
||||
/// rather than dropping the alarm.</summary>
|
||||
private static ScriptedAlarmDefinition ToDefinition(EquipmentScriptedAlarmPlan p) => new(
|
||||
AlarmId: p.ScriptedAlarmId,
|
||||
EquipmentPath: p.EquipmentId,
|
||||
AlarmName: p.Name,
|
||||
Kind: Enum.TryParse<AlarmKind>(p.AlarmType, out var k) && Enum.IsDefined(k) ? k : AlarmKind.AlarmCondition,
|
||||
Severity: SeverityFromInt(p.Severity),
|
||||
MessageTemplate: p.MessageTemplate,
|
||||
PredicateScriptSource: p.PredicateSource,
|
||||
HistorizeToAveva: p.HistorizeToAveva,
|
||||
Retain: p.Retain);
|
||||
|
||||
/// <summary>Maps a <see cref="ScriptedAlarmEvent"/>'s Core <see cref="AlarmConditionState"/> +
|
||||
/// severity/message down to the Commons <see cref="AlarmConditionSnapshot"/> the SDK sink projects.
|
||||
/// Severity is the OPC UA 1..1000 value <see cref="SeverityToInt"/> derives from the coarse engine
|
||||
/// bucket, cast to the <c>ushort</c> the SDK <c>SetSeverity</c> expects. Shelving's 3-way Core kind
|
||||
/// maps 1:1 onto the Commons <see cref="AlarmShelvingKind"/>.</summary>
|
||||
private static AlarmConditionSnapshot ToSnapshot(ScriptedAlarmEvent e) => new(
|
||||
Active: e.Condition.Active == AlarmActiveState.Active,
|
||||
Acknowledged: e.Condition.Acked == AlarmAckedState.Acknowledged,
|
||||
Confirmed: e.Condition.Confirmed == AlarmConfirmedState.Confirmed,
|
||||
Enabled: e.Condition.Enabled == AlarmEnabledState.Enabled,
|
||||
Shelving: MapShelving(e.Condition.Shelving.Kind),
|
||||
Severity: (ushort)SeverityToInt(e.Severity),
|
||||
Message: e.Message);
|
||||
|
||||
/// <summary>Maps the Core <see cref="ShelvingKind"/> onto the Commons <see cref="AlarmShelvingKind"/>
|
||||
/// mirror (the Commons assembly can't see the Core enum).</summary>
|
||||
private static AlarmShelvingKind MapShelving(ShelvingKind kind) => kind switch
|
||||
{
|
||||
ShelvingKind.OneShot => AlarmShelvingKind.OneShot,
|
||||
ShelvingKind.Timed => AlarmShelvingKind.Timed,
|
||||
_ => AlarmShelvingKind.Unshelved,
|
||||
};
|
||||
|
||||
/// <summary>The acting user for an <see cref="AlarmTransitionEvent"/>. Engine-driven
|
||||
/// Activated / Cleared transitions are <c>"system"</c>; operator Acknowledged / Confirmed carry the
|
||||
/// recorded user from the condition state, falling back to <c>"system"</c> when none was recorded.</summary>
|
||||
private static string TransitionUser(ScriptedAlarmEvent e) => e.Emission switch
|
||||
{
|
||||
EmissionKind.Acknowledged => e.Condition.LastAckUser ?? "system",
|
||||
EmissionKind.Confirmed => e.Condition.LastConfirmUser ?? "system",
|
||||
_ => "system",
|
||||
};
|
||||
|
||||
// Severity conversion convention: the engine + plan disagree on type (engine = AlarmSeverity enum,
|
||||
// plan + AlarmTransitionEvent = OPC UA 1–1000 int). We bucket the int into quartiles on the way IN
|
||||
// and emit the quartile ceiling on the way OUT, so a round-trip is stable within a bucket.
|
||||
|
||||
/// <summary>Buckets an OPC UA 1–1000 severity into the engine's coarse <see cref="AlarmSeverity"/>
|
||||
/// enum: ≤250 Low, ≤500 Medium, ≤750 High, else Critical.</summary>
|
||||
private static AlarmSeverity SeverityFromInt(int s) =>
|
||||
s <= 250 ? AlarmSeverity.Low
|
||||
: s <= 500 ? AlarmSeverity.Medium
|
||||
: s <= 750 ? AlarmSeverity.High
|
||||
: AlarmSeverity.Critical;
|
||||
|
||||
/// <summary>Maps the engine's coarse <see cref="AlarmSeverity"/> back to an OPC UA 1–1000 severity
|
||||
/// at each bucket's ceiling: Low=250, Medium=500, High=750, Critical=1000.</summary>
|
||||
private static int SeverityToInt(AlarmSeverity s) => s switch
|
||||
{
|
||||
AlarmSeverity.Low => 250,
|
||||
AlarmSeverity.Medium => 500,
|
||||
AlarmSeverity.High => 750,
|
||||
AlarmSeverity.Critical => 1000,
|
||||
_ => 500,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using Akka.Actor;
|
||||
using Akka.Cluster.Tools.PublishSubscribe;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Scripting;
|
||||
|
||||
/// <summary>
|
||||
/// Concrete <see cref="IScriptLogPublisher"/> that routes each <see cref="ScriptLogEntry"/>
|
||||
/// onto the Akka DistributedPubSub <c>script-logs</c> topic
|
||||
/// (<see cref="VirtualTagActor.ScriptLogsTopic"/>). <c>ScriptLogSignalRBridge</c> subscribes
|
||||
/// to that topic so entries reach the live Script-log Admin UI page.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The <see cref="ActorSystem"/> is resolved lazily through a <see cref="Func{ActorSystem}"/>
|
||||
/// so constructing the publisher never races Akka startup — the system only needs to exist
|
||||
/// by the time the first script logs, not at DI-registration time.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This type sits behind a Serilog sink (<see cref="ScriptLogTopicSink"/>); a sink must
|
||||
/// never throw back into the logging pipeline, so every failure path here is swallowed
|
||||
/// (best-effort logged at Debug to the main Serilog logger).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class DpsScriptLogPublisher : IScriptLogPublisher
|
||||
{
|
||||
private readonly Func<ActorSystem> _system;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="DpsScriptLogPublisher"/> class.</summary>
|
||||
/// <param name="system">
|
||||
/// Lazy accessor for the running <see cref="ActorSystem"/>. Invoked on each
|
||||
/// <see cref="Publish"/> so registration does not depend on Akka having started yet.
|
||||
/// </param>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="system"/> is <c>null</c>.</exception>
|
||||
public DpsScriptLogPublisher(Func<ActorSystem> system) =>
|
||||
_system = system ?? throw new ArgumentNullException(nameof(system));
|
||||
|
||||
/// <summary>
|
||||
/// Publishes <paramref name="entry"/> onto the DPS <c>script-logs</c> topic. Any failure
|
||||
/// (system not yet ready, mediator unavailable) is swallowed so the logging pipeline is
|
||||
/// never disrupted by a transient cluster condition.
|
||||
/// </summary>
|
||||
/// <param name="entry">The entry to publish.</param>
|
||||
public void Publish(ScriptLogEntry entry)
|
||||
{
|
||||
try
|
||||
{
|
||||
var mediator = DistributedPubSub.Get(_system()).Mediator;
|
||||
mediator.Tell(new Publish(VirtualTagActor.ScriptLogsTopic, entry));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// A logging sink must never throw into the logging pipeline. Best-effort note to
|
||||
// the main log at Debug; nothing actionable for the script author here.
|
||||
Serilog.Log.Debug(ex, "DpsScriptLogPublisher could not route a script log entry to the cluster bus.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Health;
|
||||
@@ -90,6 +91,10 @@ public static class ServiceCollectionExtensions
|
||||
var serviceLevel = resolver.GetService<IServiceLevelPublisher>() ?? NullServiceLevelPublisher.Instance;
|
||||
var loggerFactory = resolver.GetService<ILoggerFactory>() ?? NullLoggerFactory.Instance;
|
||||
var healthPublisher = resolver.GetService<IDriverHealthPublisher>() ?? NullDriverHealthPublisher.Instance;
|
||||
// Root script logger backs the ScriptedAlarm host's engine + script logging. Registered in
|
||||
// Host DI inside the hasDriver block; may be absent in some role configs / test harnesses,
|
||||
// in which case the DriverHostActor gracefully skips spawning the ScriptedAlarm host.
|
||||
var scriptRootLogger = resolver.GetService<ScriptRootLogger>();
|
||||
// Production evaluator is the Host's RoslynVirtualTagEvaluator (registered as
|
||||
// IVirtualTagEvaluator); fall back to the null evaluator for test harnesses that don't
|
||||
// register one (VirtualTagActor children then evaluate to nothing).
|
||||
@@ -131,7 +136,9 @@ public static class ServiceCollectionExtensions
|
||||
dependencyMux: mux,
|
||||
opcUaPublishActor: publishActor,
|
||||
healthPublisher: healthPublisher,
|
||||
virtualTagEvaluator: virtualTagEvaluator),
|
||||
virtualTagEvaluator: virtualTagEvaluator,
|
||||
loggerFactory: loggerFactory,
|
||||
scriptRootLogger: scriptRootLogger),
|
||||
DriverHostActorName);
|
||||
registry.Register<DriverHostActorKey>(driverHost);
|
||||
|
||||
|
||||
@@ -25,6 +25,13 @@
|
||||
-->
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.csproj"/>
|
||||
<!-- ITagUpstreamSource (the scripted-alarm engine's value-feed seam) lives here;
|
||||
DependencyMuxTagUpstreamSource implements it so the host actor can push
|
||||
DependencyValueChanged values into the engine. -->
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms\ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.csproj"/>
|
||||
<!-- IScriptLogPublisher lives in Core.Scripting; DpsScriptLogPublisher implements it
|
||||
here so the concrete Akka DPS routing stays out of the Core layer. -->
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Scripting\ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Security.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Dev/test flag: when <see cref="DisableLogin"/> is true the AdminUI bypasses the login
|
||||
/// form entirely and auto-authenticates every request as <see cref="User"/> with all roles.
|
||||
/// Default OFF. Never enable in production.
|
||||
/// </summary>
|
||||
public sealed class AuthDisableLoginOptions
|
||||
{
|
||||
/// <summary>Configuration section name (<c>Security:Auth</c>).</summary>
|
||||
public const string SectionName = "Security:Auth";
|
||||
|
||||
/// <summary>When true, disable login and auto-authenticate every request. Default false.</summary>
|
||||
public bool DisableLogin { get; set; }
|
||||
|
||||
/// <summary>The username the auto-login principal is minted with. Default "multi-role-test".</summary>
|
||||
public string User { get; set; } = "multi-role-test";
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.Auth.AspNetCore; // ZbClaimTypes — same source AuthEndpoints mints claims from.
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Security.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Auth handler used ONLY when <see cref="AuthDisableLoginOptions.DisableLogin"/> is true.
|
||||
/// Registered under the cookie scheme name, it authenticates EVERY request as the configured
|
||||
/// dev user with all <see cref="DevAuthRoles.All"/> roles — no credential check, no cookie.
|
||||
/// The minted principal mirrors the shape the real login (AuthEndpoints) produces.
|
||||
/// </summary>
|
||||
public sealed class AutoLoginAuthenticationHandler
|
||||
: AuthenticationHandler<AuthenticationSchemeOptions>, IAuthenticationSignInHandler
|
||||
{
|
||||
private readonly AuthDisableLoginOptions _opts;
|
||||
|
||||
/// <summary>Initializes the handler with the scheme plumbing and the disable-login options.</summary>
|
||||
/// <param name="options">The per-scheme authentication options monitor.</param>
|
||||
/// <param name="logger">The logger factory the base handler uses.</param>
|
||||
/// <param name="encoder">The URL encoder the base handler uses.</param>
|
||||
/// <param name="disableLoginOptions">The disable-login dev flag and configured username.</param>
|
||||
public AutoLoginAuthenticationHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder,
|
||||
IOptions<AuthDisableLoginOptions> disableLoginOptions)
|
||||
: base(options, logger, encoder)
|
||||
=> _opts = disableLoginOptions.Value;
|
||||
|
||||
/// <summary>No-op: auto-login writes no cookie, so an explicit sign-in has nothing to persist.</summary>
|
||||
public Task SignInAsync(ClaimsPrincipal user, AuthenticationProperties? properties) => Task.CompletedTask;
|
||||
|
||||
/// <summary>No-op: there is no auth cookie to clear; the next request re-authenticates via this handler.</summary>
|
||||
public Task SignOutAsync(AuthenticationProperties? properties) => Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
var user = string.IsNullOrWhiteSpace(_opts.User) ? "multi-role-test" : _opts.User;
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
// ZbClaimTypes.Name = ClaimTypes.Name — populates Identity.Name canonically.
|
||||
new(ZbClaimTypes.Name, user),
|
||||
new(ZbClaimTypes.Username, user),
|
||||
new(ZbClaimTypes.DisplayName, user),
|
||||
};
|
||||
foreach (var role in DevAuthRoles.All)
|
||||
// ZbClaimTypes.Role = ClaimTypes.Role — framework [Authorize(Roles=...)] + IsInRole work.
|
||||
claims.Add(new Claim(ZbClaimTypes.Role, role));
|
||||
|
||||
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
var ticket = new AuthenticationTicket(principal, Scheme.Name);
|
||||
return Task.FromResult(AuthenticateResult.Success(ticket));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Security.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// The full canonical role set granted to the auto-login dev principal: every
|
||||
/// <see cref="AdminRole"/> plus the appsettings-only control-plane role "Operator"
|
||||
/// (required by the DriverOperator policy). Centralised so adding an AdminRole
|
||||
/// automatically widens the grant.
|
||||
/// </summary>
|
||||
public static class DevAuthRoles
|
||||
{
|
||||
/// <summary>Operator role string — not an <see cref="AdminRole"/> enum member; used by the DriverOperator policy.</summary>
|
||||
public const string Operator = "Operator";
|
||||
|
||||
/// <summary>All roles granted to the auto-login principal.</summary>
|
||||
public static readonly string[] All = [.. Enum.GetNames<AdminRole>(), Operator];
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
@@ -11,6 +12,7 @@ using ZB.MOM.WW.Auth.AspNetCore;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Roles;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Audit;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Auth;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Jwt;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||
|
||||
@@ -36,6 +38,7 @@ public static class ServiceCollectionExtensions
|
||||
services.AddOptions<JwtOptions>().Bind(configuration.GetSection(JwtOptions.SectionName));
|
||||
services.AddOptions<OtOpcUaCookieOptions>().Bind(configuration.GetSection(OtOpcUaCookieOptions.SectionName));
|
||||
services.AddOptions<LdapOptions>().Bind(configuration.GetSection(LdapOptions.SectionName));
|
||||
services.AddOptions<AuthDisableLoginOptions>().Bind(configuration.GetSection(AuthDisableLoginOptions.SectionName));
|
||||
|
||||
services.AddSingleton<JwtTokenService>();
|
||||
|
||||
@@ -69,8 +72,32 @@ public static class ServiceCollectionExtensions
|
||||
.PersistKeysToDbContext<OtOpcUaConfigDbContext>()
|
||||
.SetApplicationName("OtOpcUa");
|
||||
|
||||
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
.AddCookie(o =>
|
||||
var disableLogin = configuration
|
||||
.GetSection(AuthDisableLoginOptions.SectionName)
|
||||
.GetValue<bool>(nameof(AuthDisableLoginOptions.DisableLogin));
|
||||
|
||||
var authBuilder = services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
if (disableLogin)
|
||||
{
|
||||
// DEV/TEST ONLY: replace the cookie handler with an always-succeeding handler registered
|
||||
// UNDER the cookie scheme name, so FallbackPolicy + FleetAdmin + DriverOperator (which all
|
||||
// name this scheme) authenticate through it and pass with all roles — zero policy changes.
|
||||
authBuilder.AddScheme<AuthenticationSchemeOptions, AutoLoginAuthenticationHandler>(
|
||||
CookieAuthenticationDefaults.AuthenticationScheme, _ => { });
|
||||
|
||||
// Loud, once-at-first-resolve warning (mirrors the cookie RequireHttps warning idiom below).
|
||||
services.AddOptions<AuthDisableLoginOptions>()
|
||||
.PostConfigure<ILoggerFactory>((opts, lf) =>
|
||||
{
|
||||
lf.CreateLogger("ZB.MOM.WW.OtOpcUa.Security").LogWarning(
|
||||
"AdminUI LOGIN DISABLED (Security:Auth:DisableLogin=true) — every request is " +
|
||||
"authenticated as '{User}' with FULL permissions ({Roles}). Dev/test only; never " +
|
||||
"enable in production.", opts.User, string.Join(",", DevAuthRoles.All));
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
authBuilder.AddCookie(o =>
|
||||
{
|
||||
// Static fields only — Name / ExpireTimeSpan / SecurePolicy / SlidingExpiration /
|
||||
// HttpOnly / SameSite are applied from OtOpcUaCookieOptions via ZbCookieDefaults
|
||||
@@ -80,6 +107,7 @@ public static class ServiceCollectionExtensions
|
||||
// No OnRedirectToLogin / OnRedirectToAccessDenied overrides — let the framework's
|
||||
// built-in IsAjaxRequest heuristic do its thing (302 for browsers, 401 for AJAX).
|
||||
});
|
||||
}
|
||||
|
||||
// Externalised cookie config — mirrors ScadaBridge's PostConfigure pattern. Fixes a
|
||||
// pre-existing latent bug where OtOpcUaCookieOptions was bound but ignored.
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
using CliFx.Exceptions;
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.CLI.Commands;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.CLI.Tests.Fakes;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Client.CLI.Tests;
|
||||
|
||||
public class AcknowledgeCommandTests
|
||||
{
|
||||
private static string HexOf(byte[] bytes) => Convert.ToHexString(bytes);
|
||||
|
||||
/// <summary>Verifies that a successful ack prints a success message.</summary>
|
||||
[Fact]
|
||||
public async Task Execute_Success_PrintsSuccessMessage()
|
||||
{
|
||||
var fakeService = new FakeOpcUaClientService
|
||||
{
|
||||
AcknowledgeAlarmResult = StatusCodes.Good
|
||||
};
|
||||
var factory = new FakeOpcUaClientServiceFactory(fakeService);
|
||||
var eventId = new byte[] { 0x01, 0x02, 0x03 };
|
||||
var command = new AcknowledgeCommand(factory)
|
||||
{
|
||||
Url = "opc.tcp://localhost:4840",
|
||||
NodeId = "ns=2;s=MyAlarm",
|
||||
EventId = HexOf(eventId),
|
||||
Comment = "test comment"
|
||||
};
|
||||
|
||||
using var console = TestConsoleHelper.CreateConsole();
|
||||
await command.ExecuteAsync(console);
|
||||
|
||||
var output = TestConsoleHelper.GetOutput(console);
|
||||
output.ShouldContain("Acknowledge successful");
|
||||
fakeService.AcknowledgeAlarmCalls.Count.ShouldBe(1);
|
||||
fakeService.AcknowledgeAlarmCalls[0].ConditionNodeId.ShouldBe("ns=2;s=MyAlarm");
|
||||
fakeService.AcknowledgeAlarmCalls[0].EventId.ShouldBe(eventId);
|
||||
fakeService.AcknowledgeAlarmCalls[0].Comment.ShouldBe("test comment");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a bad StatusCode from the service prints a failure message.</summary>
|
||||
[Fact]
|
||||
public async Task Execute_BadStatus_PrintsFailureMessage()
|
||||
{
|
||||
var fakeService = new FakeOpcUaClientService
|
||||
{
|
||||
AcknowledgeAlarmResult = StatusCodes.BadEventNotAcknowledgeable
|
||||
};
|
||||
var factory = new FakeOpcUaClientServiceFactory(fakeService);
|
||||
var command = new AcknowledgeCommand(factory)
|
||||
{
|
||||
Url = "opc.tcp://localhost:4840",
|
||||
NodeId = "ns=2;s=MyAlarm",
|
||||
EventId = HexOf(new byte[] { 0xAB }),
|
||||
Comment = ""
|
||||
};
|
||||
|
||||
using var console = TestConsoleHelper.CreateConsole();
|
||||
await command.ExecuteAsync(console);
|
||||
|
||||
var output = TestConsoleHelper.GetOutput(console);
|
||||
output.ShouldContain("Acknowledge failed");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an invalid hex event-id throws CommandException.</summary>
|
||||
[Fact]
|
||||
public async Task Execute_InvalidEventId_ThrowsCommandException()
|
||||
{
|
||||
var fakeService = new FakeOpcUaClientService();
|
||||
var factory = new FakeOpcUaClientServiceFactory(fakeService);
|
||||
var command = new AcknowledgeCommand(factory)
|
||||
{
|
||||
Url = "opc.tcp://localhost:4840",
|
||||
NodeId = "ns=2;s=MyAlarm",
|
||||
EventId = "not-hex!",
|
||||
Comment = ""
|
||||
};
|
||||
|
||||
using var console = TestConsoleHelper.CreateConsole();
|
||||
var ex = await Should.ThrowAsync<CommandException>(async () => await command.ExecuteAsync(console));
|
||||
ex.Message.ShouldContain("event-id", Case.Insensitive);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the command disconnects in the finally block.</summary>
|
||||
[Fact]
|
||||
public async Task Execute_DisconnectsInFinally()
|
||||
{
|
||||
var fakeService = new FakeOpcUaClientService();
|
||||
var factory = new FakeOpcUaClientServiceFactory(fakeService);
|
||||
var command = new AcknowledgeCommand(factory)
|
||||
{
|
||||
Url = "opc.tcp://localhost:4840",
|
||||
NodeId = "ns=2;s=MyAlarm",
|
||||
EventId = HexOf(new byte[] { 0x01 }),
|
||||
Comment = ""
|
||||
};
|
||||
|
||||
using var console = TestConsoleHelper.CreateConsole();
|
||||
await command.ExecuteAsync(console);
|
||||
|
||||
fakeService.DisconnectCalled.ShouldBeTrue();
|
||||
fakeService.DisposeCalled.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using CliFx.Exceptions;
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.CLI.Commands;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.CLI.Tests.Fakes;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Client.CLI.Tests;
|
||||
|
||||
public class ConfirmCommandTests
|
||||
{
|
||||
private static string HexOf(byte[] bytes) => Convert.ToHexString(bytes);
|
||||
|
||||
/// <summary>Verifies that a successful confirm prints a success message and passes correct arguments to the service.</summary>
|
||||
[Fact]
|
||||
public async Task Execute_Success_PrintsSuccessMessage()
|
||||
{
|
||||
var fakeService = new FakeOpcUaClientService
|
||||
{
|
||||
ConfirmAlarmResult = StatusCodes.Good
|
||||
};
|
||||
var factory = new FakeOpcUaClientServiceFactory(fakeService);
|
||||
var eventId = new byte[] { 0x0A, 0x1B, 0x2C };
|
||||
var command = new ConfirmCommand(factory)
|
||||
{
|
||||
Url = "opc.tcp://localhost:4840",
|
||||
NodeId = "ns=2;s=MyAlarm",
|
||||
EventId = HexOf(eventId),
|
||||
Comment = "confirmed by operator"
|
||||
};
|
||||
|
||||
using var console = TestConsoleHelper.CreateConsole();
|
||||
await command.ExecuteAsync(console);
|
||||
|
||||
var output = TestConsoleHelper.GetOutput(console);
|
||||
output.ShouldContain("Confirm successful");
|
||||
fakeService.ConfirmAlarmCalls.Count.ShouldBe(1);
|
||||
fakeService.ConfirmAlarmCalls[0].ConditionNodeId.ShouldBe("ns=2;s=MyAlarm");
|
||||
fakeService.ConfirmAlarmCalls[0].EventId.ShouldBe(eventId);
|
||||
fakeService.ConfirmAlarmCalls[0].Comment.ShouldBe("confirmed by operator");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a bad StatusCode from the service prints a failure message.</summary>
|
||||
[Fact]
|
||||
public async Task Execute_BadStatus_PrintsFailureMessage()
|
||||
{
|
||||
var fakeService = new FakeOpcUaClientService
|
||||
{
|
||||
ConfirmAlarmResult = StatusCodes.BadConditionBranchAlreadyConfirmed
|
||||
};
|
||||
var factory = new FakeOpcUaClientServiceFactory(fakeService);
|
||||
var command = new ConfirmCommand(factory)
|
||||
{
|
||||
Url = "opc.tcp://localhost:4840",
|
||||
NodeId = "ns=2;s=MyAlarm",
|
||||
EventId = HexOf(new byte[] { 0xFF }),
|
||||
Comment = ""
|
||||
};
|
||||
|
||||
using var console = TestConsoleHelper.CreateConsole();
|
||||
await command.ExecuteAsync(console);
|
||||
|
||||
var output = TestConsoleHelper.GetOutput(console);
|
||||
output.ShouldContain("Confirm failed");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an invalid hex event-id throws CommandException.</summary>
|
||||
[Fact]
|
||||
public async Task Execute_InvalidEventId_ThrowsCommandException()
|
||||
{
|
||||
var fakeService = new FakeOpcUaClientService();
|
||||
var factory = new FakeOpcUaClientServiceFactory(fakeService);
|
||||
var command = new ConfirmCommand(factory)
|
||||
{
|
||||
Url = "opc.tcp://localhost:4840",
|
||||
NodeId = "ns=2;s=MyAlarm",
|
||||
EventId = "ZZZ-not-hex",
|
||||
Comment = ""
|
||||
};
|
||||
|
||||
using var console = TestConsoleHelper.CreateConsole();
|
||||
var ex = await Should.ThrowAsync<CommandException>(async () => await command.ExecuteAsync(console));
|
||||
ex.Message.ShouldContain("event-id", Case.Insensitive);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the command disconnects in the finally block.</summary>
|
||||
[Fact]
|
||||
public async Task Execute_DisconnectsInFinally()
|
||||
{
|
||||
var fakeService = new FakeOpcUaClientService();
|
||||
var factory = new FakeOpcUaClientServiceFactory(fakeService);
|
||||
var command = new ConfirmCommand(factory)
|
||||
{
|
||||
Url = "opc.tcp://localhost:4840",
|
||||
NodeId = "ns=2;s=MyAlarm",
|
||||
EventId = HexOf(new byte[] { 0x01 }),
|
||||
Comment = ""
|
||||
};
|
||||
|
||||
using var console = TestConsoleHelper.CreateConsole();
|
||||
await command.ExecuteAsync(console);
|
||||
|
||||
fakeService.DisconnectCalled.ShouldBeTrue();
|
||||
fakeService.DisposeCalled.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
@@ -220,11 +220,46 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Gets the list of (conditionNodeId, eventId, comment) tuples from AcknowledgeAlarmAsync calls.</summary>
|
||||
public List<(string ConditionNodeId, byte[] EventId, string Comment)> AcknowledgeAlarmCalls { get; } = [];
|
||||
|
||||
/// <summary>Gets or sets the status code returned by AcknowledgeAlarmAsync.</summary>
|
||||
public StatusCode AcknowledgeAlarmResult { get; set; } = StatusCodes.Good;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<StatusCode> AcknowledgeAlarmAsync(string conditionNodeId, byte[] eventId, string comment,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return Task.FromResult(new StatusCode(StatusCodes.Good));
|
||||
AcknowledgeAlarmCalls.Add((conditionNodeId, eventId, comment));
|
||||
return Task.FromResult(AcknowledgeAlarmResult);
|
||||
}
|
||||
|
||||
/// <summary>Gets the list of (conditionNodeId, eventId, comment) tuples from ConfirmAlarmAsync calls.</summary>
|
||||
public List<(string ConditionNodeId, byte[] EventId, string Comment)> ConfirmAlarmCalls { get; } = [];
|
||||
|
||||
/// <summary>Gets or sets the status code returned by ConfirmAlarmAsync.</summary>
|
||||
public StatusCode ConfirmAlarmResult { get; set; } = StatusCodes.Good;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<StatusCode> ConfirmAlarmAsync(string conditionNodeId, byte[] eventId, string comment,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ConfirmAlarmCalls.Add((conditionNodeId, eventId, comment));
|
||||
return Task.FromResult(ConfirmAlarmResult);
|
||||
}
|
||||
|
||||
/// <summary>Gets the list of (conditionNodeId, kind, durationSeconds) tuples from ShelveAlarmAsync calls.</summary>
|
||||
public List<(string ConditionNodeId, ShelveKind Kind, double DurationSeconds)> ShelveAlarmCalls { get; } = [];
|
||||
|
||||
/// <summary>Gets or sets the status code returned by ShelveAlarmAsync.</summary>
|
||||
public StatusCode ShelveAlarmResult { get; set; } = StatusCodes.Good;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<StatusCode> ShelveAlarmAsync(string conditionNodeId, ShelveKind kind, double shelvingTimeSeconds = 0,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ShelveAlarmCalls.Add((conditionNodeId, kind, shelvingTimeSeconds));
|
||||
return Task.FromResult(ShelveAlarmResult);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
using CliFx.Exceptions;
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.CLI.Commands;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.CLI.Tests.Fakes;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Client.CLI.Tests;
|
||||
|
||||
public class ShelveCommandTests
|
||||
{
|
||||
/// <summary>Verifies that OneShot shelving produces the correct kind and zero duration.</summary>
|
||||
[Fact]
|
||||
public async Task Execute_OneShotKind_PassesOneShotToService()
|
||||
{
|
||||
var fakeService = new FakeOpcUaClientService { ShelveAlarmResult = StatusCodes.Good };
|
||||
var factory = new FakeOpcUaClientServiceFactory(fakeService);
|
||||
var command = new ShelveCommand(factory)
|
||||
{
|
||||
Url = "opc.tcp://localhost:4840",
|
||||
NodeId = "ns=2;s=MyAlarm",
|
||||
Kind = "OneShot",
|
||||
DurationSeconds = 0
|
||||
};
|
||||
|
||||
using var console = TestConsoleHelper.CreateConsole();
|
||||
await command.ExecuteAsync(console);
|
||||
|
||||
fakeService.ShelveAlarmCalls.Count.ShouldBe(1);
|
||||
fakeService.ShelveAlarmCalls[0].Kind.ShouldBe(ShelveKind.OneShot);
|
||||
fakeService.ShelveAlarmCalls[0].DurationSeconds.ShouldBe(0);
|
||||
fakeService.ShelveAlarmCalls[0].ConditionNodeId.ShouldBe("ns=2;s=MyAlarm");
|
||||
var output = TestConsoleHelper.GetOutput(console);
|
||||
output.ShouldContain("successful");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Timed shelving passes the correct duration to the service.</summary>
|
||||
[Fact]
|
||||
public async Task Execute_TimedKind_PassesDurationToService()
|
||||
{
|
||||
var fakeService = new FakeOpcUaClientService { ShelveAlarmResult = StatusCodes.Good };
|
||||
var factory = new FakeOpcUaClientServiceFactory(fakeService);
|
||||
var command = new ShelveCommand(factory)
|
||||
{
|
||||
Url = "opc.tcp://localhost:4840",
|
||||
NodeId = "ns=2;s=MyAlarm",
|
||||
Kind = "Timed",
|
||||
DurationSeconds = 300
|
||||
};
|
||||
|
||||
using var console = TestConsoleHelper.CreateConsole();
|
||||
await command.ExecuteAsync(console);
|
||||
|
||||
fakeService.ShelveAlarmCalls.Count.ShouldBe(1);
|
||||
fakeService.ShelveAlarmCalls[0].Kind.ShouldBe(ShelveKind.Timed);
|
||||
fakeService.ShelveAlarmCalls[0].DurationSeconds.ShouldBe(300);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Unshelve produces the Unshelve kind.</summary>
|
||||
[Fact]
|
||||
public async Task Execute_UnshelveKind_PassesUnshelveToService()
|
||||
{
|
||||
var fakeService = new FakeOpcUaClientService { ShelveAlarmResult = StatusCodes.Good };
|
||||
var factory = new FakeOpcUaClientServiceFactory(fakeService);
|
||||
var command = new ShelveCommand(factory)
|
||||
{
|
||||
Url = "opc.tcp://localhost:4840",
|
||||
NodeId = "ns=2;s=MyAlarm",
|
||||
Kind = "Unshelve",
|
||||
DurationSeconds = 0
|
||||
};
|
||||
|
||||
using var console = TestConsoleHelper.CreateConsole();
|
||||
await command.ExecuteAsync(console);
|
||||
|
||||
fakeService.ShelveAlarmCalls.Count.ShouldBe(1);
|
||||
fakeService.ShelveAlarmCalls[0].Kind.ShouldBe(ShelveKind.Unshelve);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that kind parsing is case-insensitive (e.g. "oneshot" works).</summary>
|
||||
[Fact]
|
||||
public async Task Execute_KindCaseInsensitive_ParsesCorrectly()
|
||||
{
|
||||
var fakeService = new FakeOpcUaClientService { ShelveAlarmResult = StatusCodes.Good };
|
||||
var factory = new FakeOpcUaClientServiceFactory(fakeService);
|
||||
var command = new ShelveCommand(factory)
|
||||
{
|
||||
Url = "opc.tcp://localhost:4840",
|
||||
NodeId = "ns=2;s=MyAlarm",
|
||||
Kind = "oneshot",
|
||||
DurationSeconds = 0
|
||||
};
|
||||
|
||||
using var console = TestConsoleHelper.CreateConsole();
|
||||
await command.ExecuteAsync(console);
|
||||
|
||||
fakeService.ShelveAlarmCalls.Count.ShouldBe(1);
|
||||
fakeService.ShelveAlarmCalls[0].Kind.ShouldBe(ShelveKind.OneShot);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an invalid Kind value throws CommandException.</summary>
|
||||
[Fact]
|
||||
public async Task Execute_InvalidKind_ThrowsCommandException()
|
||||
{
|
||||
var fakeService = new FakeOpcUaClientService();
|
||||
var factory = new FakeOpcUaClientServiceFactory(fakeService);
|
||||
var command = new ShelveCommand(factory)
|
||||
{
|
||||
Url = "opc.tcp://localhost:4840",
|
||||
NodeId = "ns=2;s=MyAlarm",
|
||||
Kind = "SuperShelve",
|
||||
DurationSeconds = 0
|
||||
};
|
||||
|
||||
using var console = TestConsoleHelper.CreateConsole();
|
||||
var ex = await Should.ThrowAsync<CommandException>(async () => await command.ExecuteAsync(console));
|
||||
ex.Message.ShouldContain("kind", Case.Insensitive);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Timed with zero duration throws CommandException (missing --duration).</summary>
|
||||
[Fact]
|
||||
public async Task Execute_TimedWithZeroDuration_ThrowsCommandException()
|
||||
{
|
||||
var fakeService = new FakeOpcUaClientService();
|
||||
var factory = new FakeOpcUaClientServiceFactory(fakeService);
|
||||
var command = new ShelveCommand(factory)
|
||||
{
|
||||
Url = "opc.tcp://localhost:4840",
|
||||
NodeId = "ns=2;s=MyAlarm",
|
||||
Kind = "Timed",
|
||||
DurationSeconds = 0
|
||||
};
|
||||
|
||||
using var console = TestConsoleHelper.CreateConsole();
|
||||
var ex = await Should.ThrowAsync<CommandException>(async () => await command.ExecuteAsync(console));
|
||||
ex.Message.ShouldContain("duration", Case.Insensitive);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a bad status code from the service prints a failure message.</summary>
|
||||
[Fact]
|
||||
public async Task Execute_BadStatus_PrintsFailureMessage()
|
||||
{
|
||||
var fakeService = new FakeOpcUaClientService { ShelveAlarmResult = StatusCodes.BadNotSupported };
|
||||
var factory = new FakeOpcUaClientServiceFactory(fakeService);
|
||||
var command = new ShelveCommand(factory)
|
||||
{
|
||||
Url = "opc.tcp://localhost:4840",
|
||||
NodeId = "ns=2;s=MyAlarm",
|
||||
Kind = "OneShot",
|
||||
DurationSeconds = 0
|
||||
};
|
||||
|
||||
using var console = TestConsoleHelper.CreateConsole();
|
||||
await command.ExecuteAsync(console);
|
||||
|
||||
var output = TestConsoleHelper.GetOutput(console);
|
||||
output.ShouldContain("failed");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the command disconnects in the finally block.</summary>
|
||||
[Fact]
|
||||
public async Task Execute_DisconnectsInFinally()
|
||||
{
|
||||
var fakeService = new FakeOpcUaClientService();
|
||||
var factory = new FakeOpcUaClientServiceFactory(fakeService);
|
||||
var command = new ShelveCommand(factory)
|
||||
{
|
||||
Url = "opc.tcp://localhost:4840",
|
||||
NodeId = "ns=2;s=MyAlarm",
|
||||
Kind = "OneShot",
|
||||
DurationSeconds = 0
|
||||
};
|
||||
|
||||
using var console = TestConsoleHelper.CreateConsole();
|
||||
await command.ExecuteAsync(console);
|
||||
|
||||
fakeService.DisconnectCalled.ShouldBeTrue();
|
||||
fakeService.DisposeCalled.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
@@ -202,11 +202,18 @@ internal sealed class FakeSessionAdapter : ISessionAdapter
|
||||
/// </summary>
|
||||
public Exception? CallMethodException { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Records the input arguments passed to each <see cref="CallMethodAsync"/> invocation.
|
||||
/// Index 0 is the first call, index 1 the second, etc.
|
||||
/// </summary>
|
||||
public List<object[]> CallMethodInputArgs { get; } = [];
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IList<object>?> CallMethodAsync(NodeId objectId, NodeId methodId, object[] inputArguments,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
CallMethodCount++;
|
||||
CallMethodInputArgs.Add(inputArguments);
|
||||
if (CallMethodException != null)
|
||||
throw CallMethodException;
|
||||
return Task.FromResult<IList<object>?>(null);
|
||||
|
||||
@@ -1057,6 +1057,123 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
session.CallMethodCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
// --- ConfirmAlarm tests ---
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a successful confirm call returns <see cref="StatusCodes.Good"/>
|
||||
/// and reaches the session adapter's CallMethodAsync exactly once.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ConfirmAlarmAsync_OnSuccess_ReturnsGood()
|
||||
{
|
||||
var session = new FakeSessionAdapter();
|
||||
_sessionFactory.EnqueueSession(session);
|
||||
await _service.ConnectAsync(ValidSettings());
|
||||
|
||||
var result = await _service.ConfirmAlarmAsync("ns=2;s=Cond", new byte[] { 1, 2 }, "confirmed");
|
||||
|
||||
result.ShouldBe(StatusCodes.Good);
|
||||
session.CallMethodCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a <see cref="ServiceResultException"/> from the session is captured and returned
|
||||
/// as a bad <see cref="StatusCode"/> rather than propagating to the caller.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ConfirmAlarmAsync_OnServiceResultException_ReturnsBadStatusCode()
|
||||
{
|
||||
var session = new FakeSessionAdapter
|
||||
{
|
||||
CallMethodException = new ServiceResultException(
|
||||
StatusCodes.BadConditionAlreadyEnabled, "already confirmed")
|
||||
};
|
||||
_sessionFactory.EnqueueSession(session);
|
||||
await _service.ConnectAsync(ValidSettings());
|
||||
|
||||
var result = await _service.ConfirmAlarmAsync("ns=2;s=Cond", new byte[] { 1, 2 }, "confirmed");
|
||||
|
||||
StatusCode.IsBad(result).ShouldBeTrue();
|
||||
result.Code.ShouldBe(StatusCodes.BadConditionAlreadyEnabled);
|
||||
}
|
||||
|
||||
// --- ShelveAlarm tests ---
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that OneShot shelving calls the session adapter once with no input arguments.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ShelveAlarmAsync_OneShot_CallsMethodWithNoArgs()
|
||||
{
|
||||
var session = new FakeSessionAdapter();
|
||||
_sessionFactory.EnqueueSession(session);
|
||||
await _service.ConnectAsync(ValidSettings());
|
||||
|
||||
var result = await _service.ShelveAlarmAsync("ns=2;s=Cond", ShelveKind.OneShot);
|
||||
|
||||
result.ShouldBe(StatusCodes.Good);
|
||||
session.CallMethodCount.ShouldBe(1);
|
||||
session.CallMethodInputArgs[0].ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that Timed shelving passes the duration converted to milliseconds (seconds × 1000)
|
||||
/// as the first input argument — regression guard for the Important 1 units bug.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ShelveAlarmAsync_Timed_PassesDurationInMilliseconds()
|
||||
{
|
||||
var session = new FakeSessionAdapter();
|
||||
_sessionFactory.EnqueueSession(session);
|
||||
await _service.ConnectAsync(ValidSettings());
|
||||
|
||||
const double durationSeconds = 30.0;
|
||||
var result = await _service.ShelveAlarmAsync("ns=2;s=Cond", ShelveKind.Timed, durationSeconds);
|
||||
|
||||
result.ShouldBe(StatusCodes.Good);
|
||||
session.CallMethodCount.ShouldBe(1);
|
||||
session.CallMethodInputArgs[0].Length.ShouldBe(1);
|
||||
session.CallMethodInputArgs[0][0].ShouldBe(durationSeconds * 1000.0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that Unshelve calls the session adapter once with no input arguments.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ShelveAlarmAsync_Unshelve_CallsMethodWithNoArgs()
|
||||
{
|
||||
var session = new FakeSessionAdapter();
|
||||
_sessionFactory.EnqueueSession(session);
|
||||
await _service.ConnectAsync(ValidSettings());
|
||||
|
||||
var result = await _service.ShelveAlarmAsync("ns=2;s=Cond", ShelveKind.Unshelve);
|
||||
|
||||
result.ShouldBe(StatusCodes.Good);
|
||||
session.CallMethodCount.ShouldBe(1);
|
||||
session.CallMethodInputArgs[0].ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a <see cref="ServiceResultException"/> from the session is captured and returned
|
||||
/// as a bad <see cref="StatusCode"/> rather than propagating to the caller.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ShelveAlarmAsync_OnServiceResultException_ReturnsBadStatusCode()
|
||||
{
|
||||
var session = new FakeSessionAdapter
|
||||
{
|
||||
CallMethodException = new ServiceResultException(
|
||||
StatusCodes.BadConditionAlreadyShelved, "already shelved")
|
||||
};
|
||||
_sessionFactory.EnqueueSession(session);
|
||||
await _service.ConnectAsync(ValidSettings());
|
||||
|
||||
var result = await _service.ShelveAlarmAsync("ns=2;s=Cond", ShelveKind.OneShot);
|
||||
|
||||
StatusCode.IsBad(result).ShouldBeTrue();
|
||||
result.Code.ShouldBe(StatusCodes.BadConditionAlreadyShelved);
|
||||
}
|
||||
|
||||
// --- Alarm fallback path (Client.Shared-011) ---
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -263,6 +263,26 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
|
||||
return Task.FromResult(AcknowledgeResult);
|
||||
}
|
||||
|
||||
/// <summary>Gets or sets the status code returned by confirmation operations in UI tests.</summary>
|
||||
public StatusCode ConfirmResult { get; set; } = StatusCodes.Good;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<StatusCode> ConfirmAlarmAsync(string conditionNodeId, byte[] eventId, string comment,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return Task.FromResult(ConfirmResult);
|
||||
}
|
||||
|
||||
/// <summary>Gets or sets the status code returned by shelve operations in UI tests.</summary>
|
||||
public StatusCode ShelveResult { get; set; } = StatusCodes.Good;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<StatusCode> ShelveAlarmAsync(string conditionNodeId, ShelveKind kind, double shelvingTimeSeconds = 0,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return Task.FromResult(ShelveResult);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<DataValue>> HistoryReadRawAsync(NodeId nodeId, DateTime startTime, DateTime endTime,
|
||||
int maxValues = 1000, CancellationToken ct = default)
|
||||
|
||||
@@ -6,6 +6,33 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Tests;
|
||||
|
||||
public class EquipmentScriptPathsTests
|
||||
{
|
||||
// ---- ExtractAlarmDependencyRefs (scripted-alarm dep graph; byte-parity seam) ----
|
||||
|
||||
[Fact]
|
||||
public void ExtractAlarmDependencyRefs_predicate_reads_first_then_template_tokens_deduped()
|
||||
{
|
||||
// A template token identical to a predicate read appears once (predicate-first); a distinct
|
||||
// template token is appended after. This dedup/order is the parity contract both the composer
|
||||
// and the artifact decode rely on.
|
||||
EquipmentScriptPaths
|
||||
.ExtractAlarmDependencyRefs(
|
||||
predicateSource: "return ctx.GetTag(\"Mach.Temp\").Value > 90;",
|
||||
messageTemplate: "Temp {Mach.Temp} over {Mach.Limit}")
|
||||
.ShouldBe(["Mach.Temp", "Mach.Limit"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractAlarmDependencyRefs_excludes_reserved_equip_double_brace_token()
|
||||
{
|
||||
// The reserved {{equip}} double-brace form must NOT be picked up as a single-brace {TagPath}
|
||||
// token; only the genuine {Line.Temp} token is extracted.
|
||||
EquipmentScriptPaths
|
||||
.ExtractAlarmDependencyRefs(
|
||||
predicateSource: null,
|
||||
messageTemplate: "{{equip}} too hot: {Line.Temp}")
|
||||
.ShouldBe(["Line.Temp"]);
|
||||
}
|
||||
|
||||
// ---- DeriveEquipmentBase ----
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -136,6 +136,36 @@ public sealed class ScriptLogCompanionSinkTests
|
||||
Should.NotThrow(() => companion.Emit(ev));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that an Error event carrying only ScriptId (no ScriptName) is mirrored
|
||||
/// with the ScriptId value — not the "unknown" fallback.
|
||||
/// Evaluators built by ScriptLoggerFactory bind ScriptId, not ScriptName.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ScriptId_used_as_fallback_when_ScriptName_absent()
|
||||
{
|
||||
var mainSink = new CapturingSink();
|
||||
var mainLogger = new LoggerConfiguration()
|
||||
.MinimumLevel.Verbose().WriteTo.Sink(mainSink).CreateLogger();
|
||||
|
||||
var scriptLogger = new LoggerConfiguration()
|
||||
.MinimumLevel.Verbose()
|
||||
.WriteTo.Sink(new ScriptLogCompanionSink(mainLogger))
|
||||
.CreateLogger();
|
||||
|
||||
// Bind ScriptId only — no ScriptName — as the evaluator path does.
|
||||
scriptLogger.ForContext(ScriptLoggerFactory.ScriptIdProperty, "script-abc-123")
|
||||
.Error("evaluator error");
|
||||
|
||||
mainSink.Events.Count.ShouldBe(1);
|
||||
var forwarded = mainSink.Events[0];
|
||||
// ScriptName property in the forwarded event must be the ScriptId value, not "unknown".
|
||||
forwarded.Properties.ShouldContainKey("ScriptName");
|
||||
((ScalarValue)forwarded.Properties["ScriptName"]).Value
|
||||
.ShouldBe("script-abc-123",
|
||||
"companion sink should fall back to ScriptId when ScriptName is absent");
|
||||
}
|
||||
|
||||
/// <summary>Tests that null main logger is rejected.</summary>
|
||||
[Fact]
|
||||
public void Null_main_logger_rejected()
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
using Serilog.Events;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies <see cref="ScriptLogTopicSink"/> converts Serilog <see cref="LogEvent"/>
|
||||
/// instances into <see cref="ScriptLogEntry"/> records and hands them to the
|
||||
/// <see cref="IScriptLogPublisher"/>. Covers level filtering, property extraction,
|
||||
/// fallback for missing identity props, and message template rendering.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ScriptLogTopicSinkTests
|
||||
{
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private sealed class FakePublisher : IScriptLogPublisher
|
||||
{
|
||||
/// <summary>Gets the entries that have been published.</summary>
|
||||
public List<ScriptLogEntry> Published { get; } = [];
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Publish(ScriptLogEntry entry) => Published.Add(entry);
|
||||
}
|
||||
|
||||
/// <summary>Builds a <see cref="LogEvent"/> with an optional set of string properties.</summary>
|
||||
private static LogEvent Evt(
|
||||
LogEventLevel level,
|
||||
string template,
|
||||
params (string Key, string Val)[] props)
|
||||
{
|
||||
var parser = new Serilog.Parsing.MessageTemplateParser();
|
||||
var tmpl = parser.Parse(template);
|
||||
var logProps = props.Select(p => new LogEventProperty(p.Key, new ScalarValue(p.Val)));
|
||||
return new LogEvent(DateTimeOffset.UtcNow, level, exception: null, tmpl, logProps);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// An Information event with all identity props present → publisher receives
|
||||
/// exactly one entry with correctly mapped fields and null AlarmId.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Information_event_with_all_identity_props_is_published_correctly()
|
||||
{
|
||||
var publisher = new FakePublisher();
|
||||
var sink = new ScriptLogTopicSink(publisher);
|
||||
var evt = Evt(LogEventLevel.Information, "hello",
|
||||
(ScriptLoggerFactory.ScriptIdProperty, "S1"),
|
||||
(ScriptLoggerFactory.VirtualTagIdProperty, "V1"),
|
||||
(ScriptLoggerFactory.EquipmentIdProperty, "EQ1"));
|
||||
|
||||
sink.Emit(evt);
|
||||
|
||||
publisher.Published.Count.ShouldBe(1);
|
||||
var entry = publisher.Published[0];
|
||||
entry.ScriptId.ShouldBe("S1");
|
||||
entry.Level.ShouldBe("Information");
|
||||
entry.Message.ShouldBe("hello");
|
||||
entry.VirtualTagId.ShouldBe("V1");
|
||||
entry.EquipmentId.ShouldBe("EQ1");
|
||||
entry.AlarmId.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An AlarmId property on the event is mapped to <see cref="ScriptLogEntry.AlarmId"/>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AlarmId_prop_is_mapped_to_entry_AlarmId()
|
||||
{
|
||||
var publisher = new FakePublisher();
|
||||
var sink = new ScriptLogTopicSink(publisher);
|
||||
var evt = Evt(LogEventLevel.Information, "alarm fired",
|
||||
(ScriptLoggerFactory.ScriptIdProperty, "S2"),
|
||||
(ScriptLoggerFactory.AlarmIdProperty, "A1"));
|
||||
|
||||
sink.Emit(evt);
|
||||
|
||||
publisher.Published[0].AlarmId.ShouldBe("A1");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When no identity properties are present all nullable fields are null and
|
||||
/// ScriptId falls back to <c>"unknown"</c>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Absent_identity_props_yield_null_fields_and_unknown_ScriptId()
|
||||
{
|
||||
var publisher = new FakePublisher();
|
||||
var sink = new ScriptLogTopicSink(publisher);
|
||||
var evt = Evt(LogEventLevel.Warning, "bare message");
|
||||
|
||||
sink.Emit(evt);
|
||||
|
||||
publisher.Published.Count.ShouldBe(1);
|
||||
var entry = publisher.Published[0];
|
||||
entry.ScriptId.ShouldBe("unknown");
|
||||
entry.VirtualTagId.ShouldBeNull();
|
||||
entry.AlarmId.ShouldBeNull();
|
||||
entry.EquipmentId.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When only a <c>ScriptName</c> property (no <c>ScriptId</c>) is present,
|
||||
/// <see cref="ScriptLogEntry.ScriptId"/> falls back to the ScriptName value.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ScriptName_fallback_used_when_ScriptId_absent()
|
||||
{
|
||||
var publisher = new FakePublisher();
|
||||
var sink = new ScriptLogTopicSink(publisher);
|
||||
var evt = Evt(LogEventLevel.Information, "using name fallback",
|
||||
(ScriptLoggerFactory.ScriptNameProperty, "NameFallback"));
|
||||
|
||||
sink.Emit(evt);
|
||||
|
||||
publisher.Published[0].ScriptId.ShouldBe("NameFallback");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A Debug event is filtered by the default minimum level (Information) and
|
||||
/// the publisher receives nothing.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Debug_event_filtered_by_default_minimum_level()
|
||||
{
|
||||
var publisher = new FakePublisher();
|
||||
var sink = new ScriptLogTopicSink(publisher); // default min = Information
|
||||
var evt = Evt(LogEventLevel.Debug, "debug noise",
|
||||
(ScriptLoggerFactory.ScriptIdProperty, "S3"));
|
||||
|
||||
sink.Emit(evt);
|
||||
|
||||
publisher.Published.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When the sink is constructed with <c>min: Debug</c>, a Debug event reaches
|
||||
/// the publisher with <c>Level=="Debug"</c>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Debug_event_published_when_sink_min_level_is_Debug()
|
||||
{
|
||||
var publisher = new FakePublisher();
|
||||
var sink = new ScriptLogTopicSink(publisher, min: LogEventLevel.Debug);
|
||||
var evt = Evt(LogEventLevel.Debug, "debug detail",
|
||||
(ScriptLoggerFactory.ScriptIdProperty, "S4"));
|
||||
|
||||
sink.Emit(evt);
|
||||
|
||||
publisher.Published.Count.ShouldBe(1);
|
||||
publisher.Published[0].Level.ShouldBe("Debug");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A templated message is fully rendered before being stored so the consumer
|
||||
/// sees a plain string rather than a raw template.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Templated_message_is_rendered_before_publishing()
|
||||
{
|
||||
var publisher = new FakePublisher();
|
||||
var sink = new ScriptLogTopicSink(publisher);
|
||||
|
||||
// Build the event manually to include a numeric property (can't be passed as string
|
||||
// in the helper since we need an integer ScalarValue for realistic rendering).
|
||||
var parser = new Serilog.Parsing.MessageTemplateParser();
|
||||
var tmpl = parser.Parse("v={V}");
|
||||
var props = new[] { new LogEventProperty("V", new ScalarValue(3)) };
|
||||
var evt = new LogEvent(DateTimeOffset.UtcNow, LogEventLevel.Information, null, tmpl, props);
|
||||
|
||||
sink.Emit(evt);
|
||||
|
||||
publisher.Published[0].Message.ShouldBe("v=3");
|
||||
}
|
||||
|
||||
/// <summary>Passing a null publisher to the constructor throws <see cref="ArgumentNullException"/>.</summary>
|
||||
[Fact]
|
||||
public void Null_publisher_throws_ArgumentNullException()
|
||||
{
|
||||
Should.Throw<ArgumentNullException>(() => new ScriptLogTopicSink(null!));
|
||||
}
|
||||
}
|
||||
@@ -101,4 +101,64 @@ public sealed class ScriptLoggerFactoryTests
|
||||
// this exact string. If it changes, the filter breaks silently.
|
||||
ScriptLoggerFactory.ScriptNameProperty.ShouldBe("ScriptName");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A logger from the identity overload, when written through a
|
||||
/// <see cref="ScriptLogTopicSink"/>, produces an entry carrying every bound id.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Create_identity_overload_binds_ids_onto_published_entry()
|
||||
{
|
||||
var publisher = new FakePublisher();
|
||||
var root = new LoggerConfiguration()
|
||||
.MinimumLevel.Verbose()
|
||||
.WriteTo.Sink(new ScriptLogTopicSink(publisher, LogEventLevel.Information))
|
||||
.CreateLogger();
|
||||
var factory = new ScriptLoggerFactory(root);
|
||||
|
||||
var logger = factory.Create(
|
||||
scriptId: "S9", virtualTagId: "V9", alarmId: "A9", equipmentId: "EQ9");
|
||||
logger.Information("typed identity");
|
||||
|
||||
publisher.Published.Count.ShouldBe(1);
|
||||
var entry = publisher.Published[0];
|
||||
entry.ScriptId.ShouldBe("S9");
|
||||
entry.VirtualTagId.ShouldBe("V9");
|
||||
entry.AlarmId.ShouldBe("A9");
|
||||
entry.EquipmentId.ShouldBe("EQ9");
|
||||
entry.Message.ShouldBe("typed identity");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The identity overload leaves optional ids unbound (null on the entry) when not
|
||||
/// supplied, binding only <c>ScriptId</c>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Create_identity_overload_leaves_optional_ids_null_when_absent()
|
||||
{
|
||||
var publisher = new FakePublisher();
|
||||
var root = new LoggerConfiguration()
|
||||
.MinimumLevel.Verbose()
|
||||
.WriteTo.Sink(new ScriptLogTopicSink(publisher, LogEventLevel.Information))
|
||||
.CreateLogger();
|
||||
var factory = new ScriptLoggerFactory(root);
|
||||
|
||||
factory.Create(scriptId: "S10").Information("only script id");
|
||||
|
||||
var entry = publisher.Published[0];
|
||||
entry.ScriptId.ShouldBe("S10");
|
||||
entry.VirtualTagId.ShouldBeNull();
|
||||
entry.AlarmId.ShouldBeNull();
|
||||
entry.EquipmentId.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Capturing publisher for the identity-overload sink tests.</summary>
|
||||
private sealed class FakePublisher : IScriptLogPublisher
|
||||
{
|
||||
/// <summary>Gets the entries published so far.</summary>
|
||||
public List<Commons.Messages.Logging.ScriptLogEntry> Published { get; } = [];
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Publish(Commons.Messages.Logging.ScriptLogEntry entry) => Published.Add(entry);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
using Serilog;
|
||||
using Serilog.Core;
|
||||
using Serilog.Events;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the root script-logger fan-out: events flowing through the composed
|
||||
/// pipeline (rolling file + companion mirror + topic sink) reach the
|
||||
/// <see cref="IScriptLogPublisher"/> topic sink only when they meet the configured
|
||||
/// minimum level, carrying their <c>ScriptId</c> identity property.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Builds the identical <see cref="LoggerConfiguration"/> that the Host's
|
||||
/// <c>ScriptRootLoggerFactory.Build</c> composes (file + <see cref="ScriptLogCompanionSink"/>
|
||||
/// + <see cref="ScriptLogTopicSink"/>). The factory itself is a thin shell over this
|
||||
/// exact wiring; testing the composition here keeps the suite a pure unit test with no
|
||||
/// dependency on the Host's Web SDK executable.
|
||||
/// </remarks>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ScriptRootLoggerFanoutTests : IDisposable
|
||||
{
|
||||
private readonly string _tempFile = Path.Combine(
|
||||
Path.GetTempPath(), $"scripts-fanout-{Guid.NewGuid():N}-.log");
|
||||
|
||||
private sealed class FakePublisher : IScriptLogPublisher
|
||||
{
|
||||
/// <summary>Gets the entries that have been published to the topic.</summary>
|
||||
public List<ScriptLogEntry> Published { get; } = [];
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Publish(ScriptLogEntry entry) => Published.Add(entry);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the same pipeline the Host's <c>ScriptRootLoggerFactory.Build</c> composes,
|
||||
/// so this test exercises the real production sink fan-out.
|
||||
/// </summary>
|
||||
private Logger BuildRootLogger(FakePublisher publisher, LogEventLevel topicMinLevel) =>
|
||||
new LoggerConfiguration()
|
||||
.MinimumLevel.Verbose()
|
||||
.WriteTo.File(_tempFile, rollingInterval: RollingInterval.Day)
|
||||
.WriteTo.Sink(new ScriptLogCompanionSink(Serilog.Log.Logger))
|
||||
.WriteTo.Sink(new ScriptLogTopicSink(publisher, topicMinLevel))
|
||||
.CreateLogger();
|
||||
|
||||
/// <summary>
|
||||
/// An Information event flows to the topic sink as a single <see cref="ScriptLogEntry"/>
|
||||
/// carrying the bound <c>ScriptId</c>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Information_event_reaches_the_topic_sink_with_its_ScriptId()
|
||||
{
|
||||
var publisher = new FakePublisher();
|
||||
using var root = BuildRootLogger(publisher, LogEventLevel.Information);
|
||||
|
||||
root.ForContext(ScriptLoggerFactory.ScriptIdProperty, "S1")
|
||||
.Information("virtual tag recomputed");
|
||||
|
||||
publisher.Published.Count.ShouldBe(1);
|
||||
publisher.Published[0].ScriptId.ShouldBe("S1");
|
||||
publisher.Published[0].Level.ShouldBe("Information");
|
||||
publisher.Published[0].Message.ShouldBe("virtual tag recomputed");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A Debug event is gated out by the topic sink's Information minimum level, so the
|
||||
/// publisher receives nothing (it still lands in the file sink, which we don't assert).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Debug_event_is_gated_out_by_the_topic_minimum_level()
|
||||
{
|
||||
var publisher = new FakePublisher();
|
||||
using var root = BuildRootLogger(publisher, LogEventLevel.Information);
|
||||
|
||||
root.ForContext(ScriptLoggerFactory.ScriptIdProperty, "S1")
|
||||
.Debug("verbose diagnostic noise");
|
||||
|
||||
publisher.Published.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>Disposes the temp log file created by the rolling file sink.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
// Serilog appends a date stamp where the trailing '-' sits; clean any matching file.
|
||||
var dir = Path.GetDirectoryName(_tempFile)!;
|
||||
var prefix = Path.GetFileNameWithoutExtension(_tempFile);
|
||||
try
|
||||
{
|
||||
foreach (var f in Directory.EnumerateFiles(dir, prefix + "*"))
|
||||
File.Delete(f);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Best-effort cleanup — a locked file on a flaky CI box must not fail the test.
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
@@ -13,6 +13,10 @@
|
||||
<PackageReference Include="xunit.v3"/>
|
||||
<PackageReference Include="Shouldly"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
|
||||
<!-- The root-logger fan-out test composes the same rolling-file + sink pipeline the
|
||||
Host's ScriptRootLoggerFactory builds; Core.Scripting references only Serilog core,
|
||||
so the File sink must be pulled in here for WriteTo.File. -->
|
||||
<PackageReference Include="Serilog.Sinks.File"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
using Akka.Actor;
|
||||
using Akka.Cluster.Tools.PublishSubscribe;
|
||||
using Akka.TestKit;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
|
||||
@@ -14,6 +16,136 @@ namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
|
||||
|
||||
public sealed class AdminOperationsActorTests : ControlPlaneActorTestBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Subscribes a probe to the cluster <c>alarm-commands</c> topic and waits for the
|
||||
/// <see cref="SubscribeAck"/> so the subscription is live before the actor under test
|
||||
/// publishes. Returns the subscribed probe.
|
||||
/// </summary>
|
||||
private TestProbe SubscribeAlarmCommandsProbe()
|
||||
{
|
||||
var probe = CreateTestProbe("alarm-cmds");
|
||||
var mediator = DistributedPubSub.Get(Sys).Mediator;
|
||||
// Send the Subscribe FROM the probe so the SubscribeAck routes back to it (the ack goes to the
|
||||
// message sender, not to the subscribed ref). Mirrors ScriptedAlarmHostActor's self-subscribe.
|
||||
probe.Send(mediator, new Subscribe(AlarmCommandsTopic.Name, probe.Ref));
|
||||
probe.ExpectMsg<SubscribeAck>(TimeSpan.FromSeconds(5));
|
||||
return probe;
|
||||
}
|
||||
|
||||
/// <summary>Verifies an <see cref="AcknowledgeAlarmCommand"/> publishes a correctly-mapped
|
||||
/// <see cref="AlarmCommand"/> (Operation="Acknowledge", AlarmId/User/Comment threaded, no
|
||||
/// UnshelveAtUtc) onto the <c>alarm-commands</c> topic and replies Ok.</summary>
|
||||
[Fact]
|
||||
public void AcknowledgeAlarm_publishes_mapped_command_and_replies_ok()
|
||||
{
|
||||
var dbFactory = NewInMemoryDbFactory();
|
||||
var coordinator = CreateTestProbe("coord");
|
||||
var actor = Sys.ActorOf(AdminOperationsActor.Props(dbFactory, coordinator.Ref, Enumerable.Empty<IDriverProbe>()));
|
||||
var topicProbe = SubscribeAlarmCommandsProbe();
|
||||
|
||||
var correlationId = Guid.NewGuid();
|
||||
actor.Tell(new AcknowledgeAlarmCommand("alarm-42", "operator-jo", "looking into it", correlationId));
|
||||
|
||||
var published = topicProbe.ExpectMsg<AlarmCommand>(TimeSpan.FromSeconds(3));
|
||||
published.AlarmId.ShouldBe("alarm-42");
|
||||
published.Operation.ShouldBe("Acknowledge");
|
||||
published.User.ShouldBe("operator-jo");
|
||||
published.Comment.ShouldBe("looking into it");
|
||||
published.UnshelveAtUtc.ShouldBeNull();
|
||||
|
||||
var reply = ExpectMsg<AcknowledgeAlarmResult>(TimeSpan.FromSeconds(3));
|
||||
reply.Ok.ShouldBeTrue();
|
||||
reply.Message.ShouldBeNull();
|
||||
reply.CorrelationId.ShouldBe(correlationId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies a <see cref="ShelveKind.OneShot"/> shelve maps to Operation="OneShotShelve"
|
||||
/// with no UnshelveAtUtc and replies Ok.</summary>
|
||||
[Fact]
|
||||
public void ShelveAlarm_oneshot_publishes_OneShotShelve()
|
||||
{
|
||||
var dbFactory = NewInMemoryDbFactory();
|
||||
var coordinator = CreateTestProbe("coord");
|
||||
var actor = Sys.ActorOf(AdminOperationsActor.Props(dbFactory, coordinator.Ref, Enumerable.Empty<IDriverProbe>()));
|
||||
var topicProbe = SubscribeAlarmCommandsProbe();
|
||||
|
||||
var correlationId = Guid.NewGuid();
|
||||
actor.Tell(new ShelveAlarmCommand("alarm-7", "op-kim", ShelveKind.OneShot, UnshelveAtUtc: null, Comment: null, correlationId));
|
||||
|
||||
var published = topicProbe.ExpectMsg<AlarmCommand>(TimeSpan.FromSeconds(3));
|
||||
published.AlarmId.ShouldBe("alarm-7");
|
||||
published.Operation.ShouldBe("OneShotShelve");
|
||||
published.User.ShouldBe("op-kim");
|
||||
published.UnshelveAtUtc.ShouldBeNull();
|
||||
|
||||
var reply = ExpectMsg<ShelveAlarmResult>(TimeSpan.FromSeconds(3));
|
||||
reply.Ok.ShouldBeTrue();
|
||||
reply.CorrelationId.ShouldBe(correlationId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies a <see cref="ShelveKind.Timed"/> shelve maps to Operation="TimedShelve" and
|
||||
/// threads the UnshelveAtUtc through to the published command.</summary>
|
||||
[Fact]
|
||||
public void ShelveAlarm_timed_publishes_TimedShelve_with_unshelve_time()
|
||||
{
|
||||
var dbFactory = NewInMemoryDbFactory();
|
||||
var coordinator = CreateTestProbe("coord");
|
||||
var actor = Sys.ActorOf(AdminOperationsActor.Props(dbFactory, coordinator.Ref, Enumerable.Empty<IDriverProbe>()));
|
||||
var topicProbe = SubscribeAlarmCommandsProbe();
|
||||
|
||||
var unshelveAt = DateTime.UtcNow.AddMinutes(15);
|
||||
actor.Tell(new ShelveAlarmCommand("alarm-9", "op-lee", ShelveKind.Timed, unshelveAt, Comment: "maint window", Guid.NewGuid()));
|
||||
|
||||
var published = topicProbe.ExpectMsg<AlarmCommand>(TimeSpan.FromSeconds(3));
|
||||
published.AlarmId.ShouldBe("alarm-9");
|
||||
published.Operation.ShouldBe("TimedShelve");
|
||||
published.UnshelveAtUtc.ShouldBe(unshelveAt);
|
||||
published.Comment.ShouldBe("maint window");
|
||||
|
||||
var reply = ExpectMsg<ShelveAlarmResult>(TimeSpan.FromSeconds(3));
|
||||
reply.Ok.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>Verifies a <see cref="ShelveKind.Unshelve"/> maps to Operation="Unshelve" with no
|
||||
/// UnshelveAtUtc and replies Ok.</summary>
|
||||
[Fact]
|
||||
public void ShelveAlarm_unshelve_publishes_Unshelve()
|
||||
{
|
||||
var dbFactory = NewInMemoryDbFactory();
|
||||
var coordinator = CreateTestProbe("coord");
|
||||
var actor = Sys.ActorOf(AdminOperationsActor.Props(dbFactory, coordinator.Ref, Enumerable.Empty<IDriverProbe>()));
|
||||
var topicProbe = SubscribeAlarmCommandsProbe();
|
||||
|
||||
actor.Tell(new ShelveAlarmCommand("alarm-3", "op-sam", ShelveKind.Unshelve, UnshelveAtUtc: null, Comment: null, Guid.NewGuid()));
|
||||
|
||||
var published = topicProbe.ExpectMsg<AlarmCommand>(TimeSpan.FromSeconds(3));
|
||||
published.Operation.ShouldBe("Unshelve");
|
||||
published.UnshelveAtUtc.ShouldBeNull();
|
||||
|
||||
ExpectMsg<ShelveAlarmResult>(TimeSpan.FromSeconds(3)).Ok.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>Verifies a <see cref="ShelveKind.Timed"/> shelve with a null UnshelveAtUtc is rejected
|
||||
/// at the singleton (no publish) with an attributable failure — the engine requires the instant.</summary>
|
||||
[Fact]
|
||||
public void ShelveAlarm_timed_without_unshelve_time_is_rejected_and_not_published()
|
||||
{
|
||||
var dbFactory = NewInMemoryDbFactory();
|
||||
var coordinator = CreateTestProbe("coord");
|
||||
var actor = Sys.ActorOf(AdminOperationsActor.Props(dbFactory, coordinator.Ref, Enumerable.Empty<IDriverProbe>()));
|
||||
var topicProbe = SubscribeAlarmCommandsProbe();
|
||||
|
||||
actor.Tell(new ShelveAlarmCommand("alarm-1", "op-zoe", ShelveKind.Timed, UnshelveAtUtc: null, Comment: null, Guid.NewGuid()));
|
||||
|
||||
var reply = ExpectMsg<ShelveAlarmResult>(TimeSpan.FromSeconds(3));
|
||||
reply.Ok.ShouldBeFalse();
|
||||
reply.Message.ShouldNotBeNull();
|
||||
reply.Message.ShouldContain("UnshelveAtUtc");
|
||||
|
||||
// No command should have been published.
|
||||
topicProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that starting a deployment inserts a row and dispatches to the coordinator.</summary>
|
||||
[Fact]
|
||||
public void StartDeployment_inserts_deployment_and_dispatches_to_coordinator()
|
||||
|
||||
-109
@@ -1,109 +0,0 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Engines;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// F9b — verifies <see cref="RoslynScriptedAlarmEvaluator"/> compiles alarm predicates,
|
||||
/// returns the bool result on success, surfaces compile/runtime errors as Failure (so the
|
||||
/// actor preserves prior state), and rejects predicates that try to ctx.SetVirtualTag (the
|
||||
/// AlarmPredicateContext throws on writes — predicates must stay pure).
|
||||
/// </summary>
|
||||
public sealed class RoslynScriptedAlarmEvaluatorTests
|
||||
{
|
||||
/// <summary>Verifies evaluation of predicate returning true reports Active.</summary>
|
||||
[Fact]
|
||||
public void Evaluate_predicate_returning_true_reports_Active()
|
||||
{
|
||||
using var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance);
|
||||
|
||||
var result = sut.Evaluate(
|
||||
alarmId: "alarm-hi",
|
||||
predicate: "return (int)ctx.GetTag(\"temp\").Value > 100;",
|
||||
dependencies: new Dictionary<string, object?> { ["temp"] = 150 });
|
||||
|
||||
result.Success.ShouldBeTrue(result.Reason);
|
||||
result.Active.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>Verifies evaluation of predicate returning false reports Inactive.</summary>
|
||||
[Fact]
|
||||
public void Evaluate_predicate_returning_false_reports_Inactive()
|
||||
{
|
||||
using var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance);
|
||||
|
||||
var result = sut.Evaluate(
|
||||
alarmId: "alarm-hi",
|
||||
predicate: "return (int)ctx.GetTag(\"temp\").Value > 100;",
|
||||
dependencies: new Dictionary<string, object?> { ["temp"] = 50 });
|
||||
|
||||
result.Success.ShouldBeTrue(result.Reason);
|
||||
result.Active.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Verifies compiled predicates are cached across calls.</summary>
|
||||
[Fact]
|
||||
public void Evaluate_caches_compiled_predicate_across_calls()
|
||||
{
|
||||
using var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance);
|
||||
const string predicate = "return (bool)ctx.GetTag(\"door_open\").Value;";
|
||||
|
||||
var first = sut.Evaluate("alarm-door", predicate, new Dictionary<string, object?> { ["door_open"] = true });
|
||||
var second = sut.Evaluate("alarm-door", predicate, new Dictionary<string, object?> { ["door_open"] = false });
|
||||
|
||||
first.Active.ShouldBeTrue();
|
||||
second.Active.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Verifies compile errors return Failure.</summary>
|
||||
[Fact]
|
||||
public void Evaluate_compile_error_returns_Failure()
|
||||
{
|
||||
using var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance);
|
||||
|
||||
var result = sut.Evaluate("alarm-bad", "this isn't C#;", new Dictionary<string, object?>());
|
||||
|
||||
result.Success.ShouldBeFalse();
|
||||
result.Reason!.ShouldContain("compile");
|
||||
}
|
||||
|
||||
/// <summary>Verifies predicate writing virtual tag returns Failure.</summary>
|
||||
[Fact]
|
||||
public void Evaluate_predicate_writing_virtual_tag_returns_Failure()
|
||||
{
|
||||
using var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance);
|
||||
|
||||
// AlarmPredicateContext.SetVirtualTag throws — wrapper catches + reports as Failure.
|
||||
var result = sut.Evaluate(
|
||||
alarmId: "alarm-bad-write",
|
||||
predicate: "ctx.SetVirtualTag(\"x\", 1); return true;",
|
||||
dependencies: new Dictionary<string, object?>());
|
||||
|
||||
result.Success.ShouldBeFalse();
|
||||
result.Reason!.ShouldContain("threw");
|
||||
}
|
||||
|
||||
/// <summary>Verifies empty predicate returns Failure.</summary>
|
||||
[Fact]
|
||||
public void Evaluate_empty_predicate_returns_Failure()
|
||||
{
|
||||
using var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance);
|
||||
|
||||
sut.Evaluate("alarm-empty", "", new Dictionary<string, object?>()).Success.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Verifies evaluation after dispose returns Failure.</summary>
|
||||
[Fact]
|
||||
public void Evaluate_after_dispose_returns_Failure()
|
||||
{
|
||||
var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance);
|
||||
sut.Dispose();
|
||||
|
||||
var result = sut.Evaluate("alarm", "return true;", new Dictionary<string, object?>());
|
||||
|
||||
result.Success.ShouldBeFalse();
|
||||
result.Reason!.ShouldContain("disposed");
|
||||
}
|
||||
}
|
||||
+62
-14
@@ -1,8 +1,12 @@
|
||||
using System.Collections;
|
||||
using System.Reflection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Serilog;
|
||||
using Serilog.Events;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Engines;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
||||
@@ -15,11 +19,25 @@ namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
||||
/// </summary>
|
||||
public sealed class RoslynVirtualTagEvaluatorTests
|
||||
{
|
||||
/// <summary>Captures published <see cref="ScriptLogEntry"/> records for assertion.</summary>
|
||||
private sealed class FakePublisher : IScriptLogPublisher
|
||||
{
|
||||
/// <summary>Gets the entries published so far.</summary>
|
||||
public List<ScriptLogEntry> Published { get; } = [];
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Publish(ScriptLogEntry entry) => Published.Add(entry);
|
||||
}
|
||||
|
||||
/// <summary>Builds a no-op <see cref="ScriptRootLogger"/> for tests that don't assert on logging.</summary>
|
||||
private static ScriptRootLogger NoOpScriptRoot() =>
|
||||
new(new LoggerConfiguration().CreateLogger());
|
||||
|
||||
/// <summary>Verifies that simple addition expression is evaluated correctly.</summary>
|
||||
[Fact]
|
||||
public void Evaluate_simple_addition_returns_summed_value()
|
||||
{
|
||||
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance);
|
||||
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance, NoOpScriptRoot());
|
||||
|
||||
var result = sut.Evaluate(
|
||||
virtualTagId: "vt-sum",
|
||||
@@ -34,7 +52,7 @@ public sealed class RoslynVirtualTagEvaluatorTests
|
||||
[Fact]
|
||||
public void Evaluate_caches_compiled_expression_across_calls()
|
||||
{
|
||||
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance);
|
||||
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance, NoOpScriptRoot());
|
||||
const string expr = "return (int)ctx.GetTag(\"x\").Value * 2;";
|
||||
|
||||
var first = sut.Evaluate("vt-cache", expr, new Dictionary<string, object?> { ["x"] = 5 });
|
||||
@@ -50,7 +68,7 @@ public sealed class RoslynVirtualTagEvaluatorTests
|
||||
[Fact]
|
||||
public void Evaluate_compile_error_returns_Failure_with_reason()
|
||||
{
|
||||
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance);
|
||||
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance, NoOpScriptRoot());
|
||||
|
||||
var result = sut.Evaluate("vt-bad", "this is not valid C#;", new Dictionary<string, object?>());
|
||||
|
||||
@@ -63,7 +81,7 @@ public sealed class RoslynVirtualTagEvaluatorTests
|
||||
[Fact]
|
||||
public void Evaluate_runtime_exception_returns_Failure_with_reason()
|
||||
{
|
||||
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance);
|
||||
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance, NoOpScriptRoot());
|
||||
|
||||
var result = sut.Evaluate(
|
||||
virtualTagId: "vt-div0",
|
||||
@@ -79,7 +97,7 @@ public sealed class RoslynVirtualTagEvaluatorTests
|
||||
[Fact]
|
||||
public void Evaluate_empty_expression_returns_Failure()
|
||||
{
|
||||
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance);
|
||||
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance, NoOpScriptRoot());
|
||||
|
||||
sut.Evaluate("vt-empty", "", new Dictionary<string, object?>()).Success.ShouldBeFalse();
|
||||
sut.Evaluate("vt-empty", " ", new Dictionary<string, object?>()).Success.ShouldBeFalse();
|
||||
@@ -89,7 +107,7 @@ public sealed class RoslynVirtualTagEvaluatorTests
|
||||
[Fact]
|
||||
public void Evaluate_after_dispose_returns_Failure()
|
||||
{
|
||||
var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance);
|
||||
var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance, NoOpScriptRoot());
|
||||
sut.Dispose();
|
||||
|
||||
var result = sut.Evaluate("vt", "return 1;", new Dictionary<string, object?>());
|
||||
@@ -105,7 +123,7 @@ public sealed class RoslynVirtualTagEvaluatorTests
|
||||
[Fact]
|
||||
public void Passthrough_returns_dependency_value_without_compiling()
|
||||
{
|
||||
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance);
|
||||
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance, NoOpScriptRoot());
|
||||
|
||||
var result = sut.Evaluate(
|
||||
virtualTagId: "vt-mirror",
|
||||
@@ -121,7 +139,7 @@ public sealed class RoslynVirtualTagEvaluatorTests
|
||||
[Fact]
|
||||
public void Passthrough_returns_same_object_reference_as_dependency()
|
||||
{
|
||||
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance);
|
||||
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance, NoOpScriptRoot());
|
||||
var payload = new object();
|
||||
|
||||
var result = sut.Evaluate(
|
||||
@@ -137,7 +155,7 @@ public sealed class RoslynVirtualTagEvaluatorTests
|
||||
[Fact]
|
||||
public void Passthrough_whitespace_variants_match()
|
||||
{
|
||||
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance);
|
||||
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance, NoOpScriptRoot());
|
||||
|
||||
var result = sut.Evaluate(
|
||||
virtualTagId: "vt-ws",
|
||||
@@ -152,7 +170,7 @@ public sealed class RoslynVirtualTagEvaluatorTests
|
||||
[Fact]
|
||||
public void Non_passthrough_falls_through_to_Roslyn()
|
||||
{
|
||||
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance);
|
||||
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance, NoOpScriptRoot());
|
||||
|
||||
var result = sut.Evaluate(
|
||||
virtualTagId: "vt-plus1",
|
||||
@@ -169,7 +187,7 @@ public sealed class RoslynVirtualTagEvaluatorTests
|
||||
[Fact]
|
||||
public void Passthrough_missing_dependency_matches_Roslyn_behaviour()
|
||||
{
|
||||
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance);
|
||||
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance, NoOpScriptRoot());
|
||||
const string mirror = "return ctx.GetTag(\"missing\").Value;";
|
||||
|
||||
// Roslyn baseline: same source, but force a near-miss that compiles, to capture the
|
||||
@@ -187,7 +205,7 @@ public sealed class RoslynVirtualTagEvaluatorTests
|
||||
[Fact]
|
||||
public void Roslyn_missing_dependency_also_returns_Ok_null()
|
||||
{
|
||||
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance);
|
||||
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance, NoOpScriptRoot());
|
||||
// `(object?)...Value` forces the compiled path (not the mirror shape) but reads the same
|
||||
// missing tag; result must match the passthrough missing-dep result above.
|
||||
var result = sut.Evaluate(
|
||||
@@ -209,7 +227,7 @@ public sealed class RoslynVirtualTagEvaluatorTests
|
||||
[Fact]
|
||||
public void Passthrough_exact_mirror_missing_dep_matches_Roslyn_baseline()
|
||||
{
|
||||
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance);
|
||||
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance, NoOpScriptRoot());
|
||||
|
||||
// Fast-path: exact mirror shape → passthrough returns Ok(null) for missing dep.
|
||||
var fastPath = sut.Evaluate(
|
||||
@@ -238,7 +256,7 @@ public sealed class RoslynVirtualTagEvaluatorTests
|
||||
[Fact]
|
||||
public void Passthrough_does_not_populate_the_compiled_script_cache()
|
||||
{
|
||||
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance);
|
||||
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance, NoOpScriptRoot());
|
||||
|
||||
// Mirror shape — must take the fast-path, leaving the Roslyn cache untouched.
|
||||
sut.Evaluate("vt-mirror", "return ctx.GetTag(\"a\").Value;",
|
||||
@@ -251,6 +269,36 @@ public sealed class RoslynVirtualTagEvaluatorTests
|
||||
CompiledCacheCount(sut).ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A script's <c>ctx.Logger.*</c> call flows through the injected root script logger and out
|
||||
/// the <see cref="ScriptLogTopicSink"/>, producing exactly one <see cref="ScriptLogEntry"/>
|
||||
/// carrying the message and the bound identity (<c>ScriptId</c> + <c>VirtualTagId</c> both
|
||||
/// equal the virtual-tag id in the live path).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Script_logger_call_publishes_entry_with_bound_identity()
|
||||
{
|
||||
var publisher = new FakePublisher();
|
||||
var root = new LoggerConfiguration()
|
||||
.MinimumLevel.Verbose()
|
||||
.WriteTo.Sink(new ScriptLogTopicSink(publisher, LogEventLevel.Information))
|
||||
.CreateLogger();
|
||||
using var sut = new RoslynVirtualTagEvaluator(
|
||||
NullLogger<RoslynVirtualTagEvaluator>.Instance, new ScriptRootLogger(root));
|
||||
|
||||
var result = sut.Evaluate(
|
||||
virtualTagId: "vt-log",
|
||||
expression: "ctx.Logger.Information(\"hi from script\"); return 1;",
|
||||
dependencies: new Dictionary<string, object?>());
|
||||
|
||||
result.Success.ShouldBeTrue(result.Reason);
|
||||
publisher.Published.Count.ShouldBe(1);
|
||||
var entry = publisher.Published[0];
|
||||
entry.Message.ShouldBe("hi from script");
|
||||
entry.ScriptId.ShouldBe("vt-log");
|
||||
entry.VirtualTagId.ShouldBe("vt-log");
|
||||
}
|
||||
|
||||
/// <summary>Reads the count of the private compiled-script cache via reflection.</summary>
|
||||
private static int CompiledCacheCount(RoslynVirtualTagEvaluator sut)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Akka.Actor;
|
||||
using Akka.Cluster.Tools.PublishSubscribe;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Moq;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// E2E integration coverage for the <c>ScriptLogSignalRBridge</c> actor → in-process
|
||||
/// broadcaster → Blazor <c>/script-log</c> page pipeline (Layer 0 of the script-log /
|
||||
/// scripted-alarm runtime work).
|
||||
///
|
||||
/// <para><b>Scope note:</b> mirrors <see cref="DriverStatusHubE2eTests"/>. The Blazor circuit
|
||||
/// itself can't be exercised from an integration test (it needs an HTTP listener, JWT auth, and
|
||||
/// a real WebSocket upgrade — covered by the manual runbook). This suite instead exercises the
|
||||
/// exact server-side delivery the page depends on: it spawns a
|
||||
/// <see cref="ScriptLogSignalRBridge"/> in the harness actor system, publishes a
|
||||
/// <see cref="ScriptLogEntry"/> to the <c>script-logs</c> DPS topic, and asserts that the entry
|
||||
/// reaches the <see cref="IInProcessBroadcaster{T}"/> singleton resolved from the SAME DI
|
||||
/// container the <c>/script-log</c> page injects from (plus the SignalR hub push). This is the
|
||||
/// link the existing <see cref="DpsScriptLogPublisherTests"/> (publisher → topic) and
|
||||
/// <see cref="InProcessBroadcasterTests"/> (broadcaster → subscriber) tests do not cover
|
||||
/// together: bridge subscription → broadcaster fan-out off the live topic.</para>
|
||||
/// </summary>
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class ScriptLogHubE2eTests
|
||||
{
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a <see cref="ScriptLogEntry"/> published to the <c>script-logs</c> DPS
|
||||
/// topic is forwarded by <see cref="ScriptLogSignalRBridge"/> to (a) the
|
||||
/// <see cref="IInProcessBroadcaster{T}"/> singleton the Blazor page reads and (b) the mock
|
||||
/// <see cref="IHubContext{ScriptLogHub}"/> via a <c>SendAsync</c> on the expected method.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ScriptLogBridge_ForwardsEntry_ToInProcessBroadcaster_FromDpsTopic()
|
||||
{
|
||||
await using var harness = await TwoNodeClusterHarness.StartAsync();
|
||||
|
||||
// The SAME singleton the Blazor /script-log page injects: AddAdminUI() →
|
||||
// AddOtOpcUaDriverStatusServices() registers IInProcessBroadcaster<> as an open-generic
|
||||
// singleton, so resolving the closed type here yields the page's instance.
|
||||
var broadcaster = harness.NodeA.Services.GetRequiredService<IInProcessBroadcaster<ScriptLogEntry>>();
|
||||
var received = new ConcurrentQueue<ScriptLogEntry>();
|
||||
void Handler(ScriptLogEntry e) => received.Enqueue(e);
|
||||
broadcaster.Received += Handler;
|
||||
|
||||
// Mock IHubContext<ScriptLogHub>: the bridge pushes to Clients.All in addition to the
|
||||
// broadcaster (for any out-of-process SignalR client).
|
||||
var hubCalls = new List<(string Method, object? Arg)>();
|
||||
var mockClients = new Mock<IHubClients>();
|
||||
var mockClientProxy = new Mock<IClientProxy>();
|
||||
mockClients.Setup(c => c.All).Returns(mockClientProxy.Object);
|
||||
mockClientProxy
|
||||
.Setup(p => p.SendCoreAsync(It.IsAny<string>(), It.IsAny<object?[]>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<string, object?[], CancellationToken>((method, args, _) =>
|
||||
hubCalls.Add((method, args.FirstOrDefault())))
|
||||
.Returns(Task.CompletedTask);
|
||||
var mockHub = new Mock<IHubContext<ScriptLogHub>>();
|
||||
mockHub.Setup(h => h.Clients).Returns(mockClients.Object);
|
||||
|
||||
var bridge = harness.NodeASystem.ActorOf(
|
||||
ScriptLogSignalRBridge.Props(mockHub.Object, broadcaster),
|
||||
$"test-script-log-bridge-{Guid.NewGuid():N}");
|
||||
|
||||
// Let the DPS subscription register + gossip before publishing.
|
||||
await Task.Delay(TimeSpan.FromSeconds(2), Ct);
|
||||
|
||||
var entry = new ScriptLogEntry(
|
||||
ScriptId: "script-log-e2e",
|
||||
Level: "Information",
|
||||
Message: "hello from e2e",
|
||||
TimestampUtc: DateTime.UtcNow,
|
||||
VirtualTagId: "vtag-e2e",
|
||||
AlarmId: null,
|
||||
EquipmentId: "EQ-e2e");
|
||||
|
||||
// DPS topic membership is eventually consistent — publish in a retry loop until delivered
|
||||
// (mirrors DpsScriptLogPublisherTests / DriverStatusHubE2eTests round-trip pattern).
|
||||
await WaitForAsync(() =>
|
||||
{
|
||||
DistributedPubSub.Get(harness.NodeASystem).Mediator.Tell(
|
||||
new Publish(ScriptLogSignalRBridge.TopicName, entry));
|
||||
return Task.FromResult(!received.IsEmpty);
|
||||
}, TimeSpan.FromSeconds(5));
|
||||
|
||||
received.ShouldNotBeEmpty(
|
||||
"The /script-log page's in-process broadcaster must receive the entry the bridge forwards off the DPS topic.");
|
||||
received.TryDequeue(out var got).ShouldBeTrue();
|
||||
got.ShouldBe(entry);
|
||||
|
||||
// The hub push also fired on the expected method name.
|
||||
hubCalls.ShouldNotBeEmpty("Hub mock should have received a SendAsync call.");
|
||||
hubCalls[0].Method.ShouldBe(ScriptLogHub.MethodName);
|
||||
|
||||
broadcaster.Received -= Handler;
|
||||
harness.NodeASystem.Stop(bridge);
|
||||
}
|
||||
|
||||
private static async Task WaitForAsync(Func<Task<bool>> condition, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (await condition()) return;
|
||||
await Task.Delay(100);
|
||||
}
|
||||
throw new TimeoutException($"Condition not met within {timeout}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Server;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// T18 — inbound OPC UA Part 9 alarm method handlers. Boots a real <see cref="OtOpcUaSdkServer"/>,
|
||||
/// materialises a condition, sets a capturing <see cref="OtOpcUaNodeManager.AlarmCommandRouter"/>,
|
||||
/// then drives the condition's public SDK handler delegates (<c>OnAcknowledge</c>/<c>OnShelve</c>)
|
||||
/// directly. Verifies (a) the <c>AlarmAck</c> role gate fails closed and (b) the routed
|
||||
/// <see cref="AlarmCommand"/> is mapped correctly.
|
||||
/// </summary>
|
||||
public sealed class AlarmCommandRouterTests : IDisposable
|
||||
{
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
private readonly string _pkiRoot = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"otopcua-alarmcmd-{Guid.NewGuid():N}");
|
||||
|
||||
/// <summary>With the <c>AlarmAck</c> role present, OnAcknowledge returns Good and routes exactly one
|
||||
/// correctly-mapped AlarmCommand (AlarmId = ScriptedAlarmId, Operation, User, Comment).</summary>
|
||||
[Fact]
|
||||
public async Task OnAcknowledge_with_AlarmAck_returns_good_and_routes_mapped_command()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
|
||||
var captured = new List<AlarmCommand>();
|
||||
nm.AlarmCommandRouter = captured.Add;
|
||||
|
||||
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1");
|
||||
nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", severity: 700);
|
||||
var condition = nm.TryGetAlarmCondition("alm-1");
|
||||
condition.ShouldNotBeNull();
|
||||
condition!.OnAcknowledge.ShouldNotBeNull();
|
||||
|
||||
var ctx = SessionContext(server, "alice", "ReadOnly", OpcUaDataPlaneRoles.AlarmAck);
|
||||
var result = condition.OnAcknowledge!(ctx, condition, EventIdBytes(), new LocalizedText("ack note"));
|
||||
|
||||
result.ShouldBe(ServiceResult.Good);
|
||||
captured.Count.ShouldBe(1);
|
||||
var cmd = captured[0];
|
||||
cmd.AlarmId.ShouldBe("alm-1"); // == ScriptedAlarmId (condition NodeId identifier)
|
||||
cmd.Operation.ShouldBe("Acknowledge");
|
||||
cmd.User.ShouldBe("alice");
|
||||
cmd.Comment.ShouldBe("ack note");
|
||||
cmd.UnshelveAtUtc.ShouldBeNull();
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>Without the <c>AlarmAck</c> role, OnAcknowledge is vetoed (BadUserAccessDenied) and the
|
||||
/// router is NOT invoked — the gate fails closed and never mutates state.</summary>
|
||||
[Fact]
|
||||
public async Task OnAcknowledge_without_AlarmAck_returns_denied_and_does_not_route()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
|
||||
var captured = new List<AlarmCommand>();
|
||||
nm.AlarmCommandRouter = captured.Add;
|
||||
|
||||
nm.EnsureFolder("eq-2", parentNodeId: null, displayName: "Equipment 2");
|
||||
nm.MaterialiseAlarmCondition("alm-2", "eq-2", "HighTemp", "OffNormalAlarm", severity: 700);
|
||||
var condition = nm.TryGetAlarmCondition("alm-2");
|
||||
condition.ShouldNotBeNull();
|
||||
|
||||
var ctx = SessionContext(server, "bob", "ReadOnly"); // no AlarmAck
|
||||
var result = condition!.OnAcknowledge!(ctx, condition, EventIdBytes(), new LocalizedText("nope"));
|
||||
|
||||
result.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
captured.ShouldBeEmpty();
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>A null identity (anonymous / no role-carrying identity on the context) is denied and does
|
||||
/// not route — the gate fails closed on a missing principal.</summary>
|
||||
[Fact]
|
||||
public async Task OnAcknowledge_with_null_identity_returns_denied_and_does_not_route()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
|
||||
var captured = new List<AlarmCommand>();
|
||||
nm.AlarmCommandRouter = captured.Add;
|
||||
|
||||
nm.EnsureFolder("eq-3", parentNodeId: null, displayName: "Equipment 3");
|
||||
nm.MaterialiseAlarmCondition("alm-3", "eq-3", "HighTemp", "OffNormalAlarm", severity: 700);
|
||||
var condition = nm.TryGetAlarmCondition("alm-3");
|
||||
condition.ShouldNotBeNull();
|
||||
|
||||
// ServerSystemContext with no UserIdentity set ⇒ (context as ISessionOperationContext).UserIdentity is null.
|
||||
var ctx = new ServerSystemContext(server.CurrentInstance);
|
||||
var result = condition!.OnAcknowledge!(ctx, condition, EventIdBytes(), new LocalizedText("x"));
|
||||
|
||||
result.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
captured.ShouldBeEmpty();
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>OnConfirm with AlarmAck maps to the Confirm operation.</summary>
|
||||
[Fact]
|
||||
public async Task OnConfirm_with_AlarmAck_routes_confirm_operation()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
var captured = new List<AlarmCommand>();
|
||||
nm.AlarmCommandRouter = captured.Add;
|
||||
|
||||
nm.EnsureFolder("eq-c", parentNodeId: null, displayName: "Equipment C");
|
||||
nm.MaterialiseAlarmCondition("alm-c", "eq-c", "HighTemp", "OffNormalAlarm", severity: 700);
|
||||
var condition = nm.TryGetAlarmCondition("alm-c");
|
||||
condition.ShouldNotBeNull();
|
||||
condition!.OnConfirm.ShouldNotBeNull();
|
||||
|
||||
var ctx = SessionContext(server, "carol", OpcUaDataPlaneRoles.AlarmAck);
|
||||
var result = condition.OnConfirm!(ctx, condition, EventIdBytes(), comment: null);
|
||||
|
||||
result.ShouldBe(ServiceResult.Good);
|
||||
captured.Count.ShouldBe(1);
|
||||
captured[0].Operation.ShouldBe("Confirm");
|
||||
captured[0].Comment.ShouldBeNull(); // null LocalizedText ⇒ null Comment
|
||||
captured[0].User.ShouldBe("carol");
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>OnAddComment with AlarmAck maps to the AddComment operation, carrying the comment text.</summary>
|
||||
[Fact]
|
||||
public async Task OnAddComment_with_AlarmAck_routes_add_comment_operation()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
var captured = new List<AlarmCommand>();
|
||||
nm.AlarmCommandRouter = captured.Add;
|
||||
|
||||
nm.EnsureFolder("eq-ac", parentNodeId: null, displayName: "Equipment AC");
|
||||
nm.MaterialiseAlarmCondition("alm-ac", "eq-ac", "HighTemp", "OffNormalAlarm", severity: 700);
|
||||
var condition = nm.TryGetAlarmCondition("alm-ac");
|
||||
condition.ShouldNotBeNull();
|
||||
condition!.OnAddComment.ShouldNotBeNull();
|
||||
|
||||
var ctx = SessionContext(server, "dave", OpcUaDataPlaneRoles.AlarmAck);
|
||||
var result = condition.OnAddComment!(ctx, condition, EventIdBytes(), new LocalizedText("look here"));
|
||||
|
||||
result.ShouldBe(ServiceResult.Good);
|
||||
captured.Count.ShouldBe(1);
|
||||
captured[0].Operation.ShouldBe("AddComment");
|
||||
captured[0].Comment.ShouldBe("look here");
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>OnShelve with oneShot=true maps to OneShotShelve with no UnshelveAtUtc.</summary>
|
||||
[Fact]
|
||||
public async Task OnShelve_oneshot_routes_one_shot_shelve_with_no_expiry()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
var captured = new List<AlarmCommand>();
|
||||
nm.AlarmCommandRouter = captured.Add;
|
||||
|
||||
nm.EnsureFolder("eq-s1", parentNodeId: null, displayName: "Equipment S1");
|
||||
nm.MaterialiseAlarmCondition("alm-s1", "eq-s1", "HighTemp", "OffNormalAlarm", severity: 700);
|
||||
var condition = nm.TryGetAlarmCondition("alm-s1");
|
||||
condition.ShouldNotBeNull();
|
||||
condition!.OnShelve.ShouldNotBeNull();
|
||||
|
||||
var ctx = SessionContext(server, "erin", OpcUaDataPlaneRoles.AlarmAck);
|
||||
// SDK OneShotShelve invokes OnShelve(ctx, alarm, shelving:true, oneShot:true, 0.0).
|
||||
var result = condition.OnShelve!(ctx, condition, shelving: true, oneShot: true, shelvingTime: 0.0);
|
||||
|
||||
result.ShouldBe(ServiceResult.Good);
|
||||
captured.Count.ShouldBe(1);
|
||||
captured[0].Operation.ShouldBe("OneShotShelve");
|
||||
captured[0].UnshelveAtUtc.ShouldBeNull();
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>OnShelve with oneShot=false and shelving=true maps to TimedShelve with UnshelveAtUtc
|
||||
/// derived from the millisecond Duration (UtcNow + ms).</summary>
|
||||
[Fact]
|
||||
public async Task OnShelve_timed_routes_timed_shelve_with_ms_derived_expiry()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
var captured = new List<AlarmCommand>();
|
||||
nm.AlarmCommandRouter = captured.Add;
|
||||
|
||||
nm.EnsureFolder("eq-s2", parentNodeId: null, displayName: "Equipment S2");
|
||||
nm.MaterialiseAlarmCondition("alm-s2", "eq-s2", "HighTemp", "OffNormalAlarm", severity: 700);
|
||||
var condition = nm.TryGetAlarmCondition("alm-s2");
|
||||
condition.ShouldNotBeNull();
|
||||
|
||||
var ctx = SessionContext(server, "frank", OpcUaDataPlaneRoles.AlarmAck);
|
||||
const double shelvingTimeMs = 60_000.0; // OPC UA Duration = milliseconds
|
||||
var before = DateTime.UtcNow;
|
||||
// SDK TimedShelve invokes OnShelve(ctx, alarm, shelving:true, oneShot:false, shelvingTime).
|
||||
var result = condition!.OnShelve!(ctx, condition, shelving: true, oneShot: false, shelvingTime: shelvingTimeMs);
|
||||
var after = DateTime.UtcNow;
|
||||
|
||||
result.ShouldBe(ServiceResult.Good);
|
||||
captured.Count.ShouldBe(1);
|
||||
captured[0].Operation.ShouldBe("TimedShelve");
|
||||
captured[0].UnshelveAtUtc.ShouldNotBeNull();
|
||||
// UtcNow + 60s, bracketed by the before/after capture instants.
|
||||
captured[0].UnshelveAtUtc!.Value.ShouldBeGreaterThanOrEqualTo(before.AddMilliseconds(shelvingTimeMs));
|
||||
captured[0].UnshelveAtUtc!.Value.ShouldBeLessThanOrEqualTo(after.AddMilliseconds(shelvingTimeMs));
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>OnShelve with shelving=false maps to Unshelve with no UnshelveAtUtc.</summary>
|
||||
[Fact]
|
||||
public async Task OnShelve_unshelve_routes_unshelve_operation()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
var captured = new List<AlarmCommand>();
|
||||
nm.AlarmCommandRouter = captured.Add;
|
||||
|
||||
nm.EnsureFolder("eq-s3", parentNodeId: null, displayName: "Equipment S3");
|
||||
nm.MaterialiseAlarmCondition("alm-s3", "eq-s3", "HighTemp", "OffNormalAlarm", severity: 700);
|
||||
var condition = nm.TryGetAlarmCondition("alm-s3");
|
||||
condition.ShouldNotBeNull();
|
||||
|
||||
var ctx = SessionContext(server, "grace", OpcUaDataPlaneRoles.AlarmAck);
|
||||
// SDK Unshelve invokes OnShelve(ctx, alarm, shelving:false, oneShot:false, 0.0).
|
||||
var result = condition!.OnShelve!(ctx, condition, shelving: false, oneShot: false, shelvingTime: 0.0);
|
||||
|
||||
result.ShouldBe(ServiceResult.Good);
|
||||
captured.Count.ShouldBe(1);
|
||||
captured[0].Operation.ShouldBe("Unshelve");
|
||||
captured[0].UnshelveAtUtc.ShouldBeNull();
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>OnShelve without AlarmAck is vetoed and does not route.</summary>
|
||||
[Fact]
|
||||
public async Task OnShelve_without_AlarmAck_returns_denied_and_does_not_route()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
var captured = new List<AlarmCommand>();
|
||||
nm.AlarmCommandRouter = captured.Add;
|
||||
|
||||
nm.EnsureFolder("eq-s4", parentNodeId: null, displayName: "Equipment S4");
|
||||
nm.MaterialiseAlarmCondition("alm-s4", "eq-s4", "HighTemp", "OffNormalAlarm", severity: 700);
|
||||
var condition = nm.TryGetAlarmCondition("alm-s4");
|
||||
condition.ShouldNotBeNull();
|
||||
|
||||
var ctx = SessionContext(server, "heidi", "ReadOnly"); // no AlarmAck
|
||||
var result = condition!.OnShelve!(ctx, condition, shelving: true, oneShot: true, shelvingTime: 0.0);
|
||||
|
||||
result.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
captured.ShouldBeEmpty();
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>OnTimedUnshelve fires with the SDK's system context (no session, no user identity) —
|
||||
/// the real SDK path when a TimedShelve duration expires. The gate must NOT veto: the result must be
|
||||
/// Good, the router must be invoked exactly once with Operation == "Unshelve" and User == empty,
|
||||
/// and no UnshelveAtUtc is carried.</summary>
|
||||
[Fact]
|
||||
public async Task OnTimedUnshelve_with_system_context_returns_good_and_routes_unshelve()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
var captured = new List<AlarmCommand>();
|
||||
nm.AlarmCommandRouter = captured.Add;
|
||||
|
||||
nm.EnsureFolder("eq-tu", parentNodeId: null, displayName: "Equipment TU");
|
||||
nm.MaterialiseAlarmCondition("alm-tu", "eq-tu", "HighTemp", "OffNormalAlarm", severity: 700);
|
||||
var condition = nm.TryGetAlarmCondition("alm-tu");
|
||||
condition.ShouldNotBeNull();
|
||||
condition!.OnTimedUnshelve.ShouldNotBeNull();
|
||||
|
||||
// Reproduce the real SDK path: system context has no session and no UserIdentity — exactly what
|
||||
// the SDK's internal timer fires the callback with when a TimedShelve duration expires.
|
||||
var ctx = new ServerSystemContext(server.CurrentInstance); // no UserIdentity set
|
||||
var result = condition.OnTimedUnshelve!(ctx, condition);
|
||||
|
||||
result.ShouldBe(ServiceResult.Good);
|
||||
captured.Count.ShouldBe(1);
|
||||
var cmd = captured[0];
|
||||
cmd.AlarmId.ShouldBe("alm-tu"); // == ScriptedAlarmId / condition NodeId identifier
|
||||
cmd.Operation.ShouldBe("Unshelve");
|
||||
cmd.User.ShouldBe(string.Empty); // no client principal — system-initiated
|
||||
cmd.UnshelveAtUtc.ShouldBeNull();
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>A null router is a safe no-op: handler still gates + returns Good, just routes nowhere.</summary>
|
||||
[Fact]
|
||||
public async Task OnAcknowledge_with_null_router_is_safe_noop_and_returns_good()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
// No router set (default null).
|
||||
|
||||
nm.EnsureFolder("eq-nr", parentNodeId: null, displayName: "Equipment NR");
|
||||
nm.MaterialiseAlarmCondition("alm-nr", "eq-nr", "HighTemp", "OffNormalAlarm", severity: 700);
|
||||
var condition = nm.TryGetAlarmCondition("alm-nr");
|
||||
condition.ShouldNotBeNull();
|
||||
|
||||
var ctx = SessionContext(server, "judy", OpcUaDataPlaneRoles.AlarmAck);
|
||||
var result = condition!.OnAcknowledge!(ctx, condition, EventIdBytes(), new LocalizedText("ok"));
|
||||
|
||||
result.ShouldBe(ServiceResult.Good);
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>Builds a <see cref="ServerSystemContext"/> (an <see cref="ISessionOperationContext"/>)
|
||||
/// carrying a <see cref="RoleCarryingUserIdentity"/> with the given name + roles — the exact seam the
|
||||
/// gate reads via <c>(context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity</c>.</summary>
|
||||
private static ServerSystemContext SessionContext(OtOpcUaSdkServer server, string user, params string[] roles)
|
||||
{
|
||||
var identity = new RoleCarryingUserIdentity(
|
||||
new UserNameIdentityToken { UserName = user },
|
||||
roles);
|
||||
return new ServerSystemContext(server.CurrentInstance) { UserIdentity = identity };
|
||||
}
|
||||
|
||||
private static byte[] EventIdBytes() => Guid.NewGuid().ToByteArray();
|
||||
|
||||
private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync()
|
||||
{
|
||||
var host = new OpcUaApplicationHost(
|
||||
new OpcUaApplicationHostOptions
|
||||
{
|
||||
ApplicationName = "OtOpcUa.AlarmCmdTest",
|
||||
ApplicationUri = $"urn:OtOpcUa.AlarmCmdTest:{Guid.NewGuid():N}",
|
||||
OpcUaPort = AllocateFreePort(),
|
||||
PublicHostname = "localhost",
|
||||
PkiStoreRoot = _pkiRoot,
|
||||
},
|
||||
NullLogger<OpcUaApplicationHost>.Instance);
|
||||
|
||||
var server = new OtOpcUaSdkServer();
|
||||
await host.StartAsync(server, Ct);
|
||||
return (host, server);
|
||||
}
|
||||
|
||||
private static int AllocateFreePort()
|
||||
{
|
||||
using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
|
||||
listener.Stop();
|
||||
return port;
|
||||
}
|
||||
|
||||
/// <summary>Cleans up the PKI root directory.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_pkiRoot))
|
||||
{
|
||||
try { Directory.Delete(_pkiRoot, recursive: true); }
|
||||
catch { /* best-effort cleanup */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ public sealed class DeferredAddressSpaceSinkTests
|
||||
|
||||
// No throw, no observable side effect.
|
||||
deferred.WriteValue("x", 1, OpcUaQuality.Good, DateTime.UtcNow);
|
||||
deferred.WriteAlarmState("a", true, false, DateTime.UtcNow);
|
||||
deferred.WriteAlarmCondition("a", Snapshot(active: true), DateTime.UtcNow);
|
||||
deferred.RebuildAddressSpace();
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ public sealed class DeferredAddressSpaceSinkTests
|
||||
deferred.SetSink(inner);
|
||||
|
||||
deferred.WriteValue("x", 42, OpcUaQuality.Good, DateTime.UtcNow);
|
||||
deferred.WriteAlarmState("a-1", true, false, DateTime.UtcNow);
|
||||
deferred.WriteAlarmCondition("a-1", Snapshot(active: true), DateTime.UtcNow);
|
||||
deferred.RebuildAddressSpace();
|
||||
|
||||
inner.Calls.ShouldBe(new[] { "WV:x", "WA:a-1", "RB" });
|
||||
@@ -67,6 +67,12 @@ public sealed class DeferredAddressSpaceSinkTests
|
||||
second.Calls.Single().ShouldBe("WV:b");
|
||||
}
|
||||
|
||||
/// <summary>Builds a minimal <see cref="AlarmConditionSnapshot"/> for the forwarding tests (the
|
||||
/// inner sink only records the node id, so the exact state values don't matter here).</summary>
|
||||
private static AlarmConditionSnapshot Snapshot(bool active = false) =>
|
||||
new(active, Acknowledged: true, Confirmed: true, Enabled: true,
|
||||
Shelving: AlarmShelvingKind.Unshelved, Severity: 500, Message: "test");
|
||||
|
||||
private sealed class RecordingSink : IOpcUaAddressSpaceSink
|
||||
{
|
||||
/// <summary>Gets the queue of recorded calls.</summary>
|
||||
@@ -78,9 +84,12 @@ public sealed class DeferredAddressSpaceSinkTests
|
||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc)
|
||||
=> CallQueue.Enqueue($"WV:{nodeId}");
|
||||
/// <inheritdoc />
|
||||
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc)
|
||||
=> CallQueue.Enqueue($"WA:{alarmNodeId}");
|
||||
/// <inheritdoc />
|
||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity)
|
||||
=> CallQueue.Enqueue($"MA:{alarmNodeId}");
|
||||
/// <inheritdoc />
|
||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
|
||||
=> CallQueue.Enqueue($"EF:{folderNodeId}");
|
||||
/// <inheritdoc />
|
||||
|
||||
+18
@@ -35,6 +35,24 @@ public sealed class OpcUaApplicationHostImpersonationTests
|
||||
authenticator.LastPassword.ShouldBe("secret");
|
||||
}
|
||||
|
||||
/// <summary>T17 — verifies the granted identity carries the authenticator's resolved roles
|
||||
/// (so a downstream method handler can read them off <c>context.UserIdentity</c> for the
|
||||
/// AlarmAck gate). The identity must be a <see cref="RoleCarryingUserIdentity"/> whose
|
||||
/// <see cref="RoleCarryingUserIdentity.Roles"/> equals <c>result.Roles</c>.</summary>
|
||||
[Fact]
|
||||
public void HandleImpersonation_username_success_carries_roles_on_identity()
|
||||
{
|
||||
var roles = new[] { "ReadOnly", "WriteOperate", "AlarmAck" };
|
||||
var token = new UserNameIdentityToken { UserName = "alice", DecryptedPassword = Encoding.UTF8.GetBytes("secret") };
|
||||
var args = new ImpersonateEventArgs(token, UserNamePolicy, new EndpointDescription());
|
||||
var authenticator = new RecordingAuthenticator(OpcUaUserAuthResult.Allow("Alice", roles));
|
||||
|
||||
OpcUaApplicationHost.HandleImpersonation(authenticator, args, NullLogger<object>.Instance);
|
||||
|
||||
var identity = args.Identity.ShouldBeOfType<RoleCarryingUserIdentity>();
|
||||
identity.Roles.ShouldBe(roles);
|
||||
}
|
||||
|
||||
/// <summary>Verifies failed UserName token impersonation sets validation error and clears identity.</summary>
|
||||
[Fact]
|
||||
public void HandleImpersonation_username_denial_sets_validation_error_and_no_identity()
|
||||
|
||||
@@ -246,12 +246,18 @@ public sealed class Phase7ApplierHierarchyTests : IDisposable
|
||||
/// <param name="quality">The OPC UA quality value.</param>
|
||||
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
|
||||
/// <summary>Records an alarm state write (stub implementation for testing).</summary>
|
||||
/// <summary>Records an alarm condition write (stub implementation for testing).</summary>
|
||||
/// <param name="alarmNodeId">The node ID of the alarm condition.</param>
|
||||
/// <param name="active">Whether the alarm is active.</param>
|
||||
/// <param name="acknowledged">Whether the alarm has been acknowledged.</param>
|
||||
/// <param name="state">The full condition state snapshot.</param>
|
||||
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
||||
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc) { }
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) { }
|
||||
/// <summary>Materialises an alarm condition (stub implementation for testing).</summary>
|
||||
/// <param name="alarmNodeId">The alarm node ID (== ScriptedAlarmId).</param>
|
||||
/// <param name="equipmentNodeId">The equipment folder node ID.</param>
|
||||
/// <param name="displayName">The condition display name.</param>
|
||||
/// <param name="alarmType">The domain alarm type.</param>
|
||||
/// <param name="severity">The domain severity.</param>
|
||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity) { }
|
||||
/// <summary>Records a folder creation request.</summary>
|
||||
/// <param name="folderNodeId">The node ID of the folder.</param>
|
||||
/// <param name="parentNodeId">The node ID of the parent folder, or null for root.</param>
|
||||
|
||||
@@ -38,7 +38,8 @@ public sealed class Phase7ApplierTests
|
||||
outcome.RemovedNodes.ShouldBe(2);
|
||||
outcome.RebuildCalled.ShouldBeTrue();
|
||||
sink.AlarmWrites.Select(a => a.NodeId).OrderBy(x => x).ShouldBe(new[] { "eq-1", "eq-2" });
|
||||
sink.AlarmWrites.All(a => a.Active == false && a.Acknowledged == false).ShouldBeTrue();
|
||||
// Removed nodes are reset to the "no-event" state: inactive + acked + confirmed + enabled.
|
||||
sink.AlarmWrites.All(a => !a.State.Active && a.State.Acknowledged && a.State.Confirmed).ShouldBeTrue();
|
||||
sink.RebuildCalls.ShouldBe(1);
|
||||
}
|
||||
|
||||
@@ -103,9 +104,9 @@ public sealed class Phase7ApplierTests
|
||||
sink.RebuildCalls.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that sink exceptions in WriteAlarmState do not propagate and rebuild still fires.</summary>
|
||||
/// <summary>Verifies that sink exceptions in WriteAlarmCondition do not propagate and rebuild still fires.</summary>
|
||||
[Fact]
|
||||
public void Sink_exception_in_WriteAlarmState_does_not_propagate_and_rebuild_still_fires()
|
||||
public void Sink_exception_in_WriteAlarmCondition_does_not_propagate_and_rebuild_still_fires()
|
||||
{
|
||||
var sink = new ThrowingSink(throwOnAlarmWrite: true);
|
||||
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
|
||||
@@ -311,6 +312,38 @@ public sealed class Phase7ApplierTests
|
||||
sink.VariableCalls.ShouldContain(("eq-1/load-pct", "eq-1", "load-pct", "Float64"));
|
||||
}
|
||||
|
||||
/// <summary>T14 — MaterialiseScriptedAlarms materialises one condition per ENABLED alarm (keyed by
|
||||
/// ScriptedAlarmId, parented to its EquipmentId, carrying Name/AlarmType/Severity) and SKIPS
|
||||
/// disabled alarms.</summary>
|
||||
[Fact]
|
||||
public void MaterialiseScriptedAlarms_materialises_enabled_and_skips_disabled()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
|
||||
|
||||
var composition = new Phase7CompositionResult(
|
||||
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||
{
|
||||
EquipmentScriptedAlarms = new[]
|
||||
{
|
||||
new EquipmentScriptedAlarmPlan(
|
||||
ScriptedAlarmId: "alm-1", EquipmentId: "eq-1", Name: "HighTemp", AlarmType: "OffNormalAlarm",
|
||||
Severity: 700, MessageTemplate: "Temp high", PredicateScriptId: "scr-1", PredicateSource: "return true;",
|
||||
DependencyRefs: Array.Empty<string>(), HistorizeToAveva: false, Retain: true, Enabled: true),
|
||||
new EquipmentScriptedAlarmPlan(
|
||||
ScriptedAlarmId: "alm-2", EquipmentId: "eq-2", Name: "LowFlow", AlarmType: "AlarmCondition",
|
||||
Severity: 300, MessageTemplate: "Flow low", PredicateScriptId: "scr-2", PredicateSource: "return false;",
|
||||
DependencyRefs: Array.Empty<string>(), HistorizeToAveva: false, Retain: true, Enabled: false),
|
||||
},
|
||||
};
|
||||
|
||||
applier.MaterialiseScriptedAlarms(composition);
|
||||
|
||||
// Only the enabled alarm is materialised; the disabled one is skipped entirely.
|
||||
sink.AlarmConditionCalls.ShouldHaveSingleItem()
|
||||
.ShouldBe(("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that added equipment tags in an otherwise-empty plan trigger an
|
||||
/// address-space rebuild (parity with the Galaxy-tag path — the planner now diffs equipment
|
||||
/// tags, so a tags-only deploy is no longer a silent no-op).</summary>
|
||||
@@ -413,21 +446,25 @@ public sealed class Phase7ApplierTests
|
||||
|
||||
private sealed class RecordingSink : IOpcUaAddressSpaceSink
|
||||
{
|
||||
/// <summary>Gets the queue of alarm state write calls.</summary>
|
||||
public ConcurrentQueue<(string NodeId, bool Active, bool Acknowledged)> AlarmQueue { get; } = new();
|
||||
/// <summary>Gets the queue of alarm condition write calls.</summary>
|
||||
public ConcurrentQueue<(string NodeId, AlarmConditionSnapshot State)> AlarmQueue { get; } = new();
|
||||
/// <summary>Gets the queue of folder creation calls.</summary>
|
||||
public ConcurrentQueue<(string NodeId, string? Parent, string DisplayName)> FolderQueue { get; } = new();
|
||||
/// <summary>Gets the queue of variable creation calls.</summary>
|
||||
public ConcurrentQueue<(string NodeId, string? Parent, string DisplayName, string DataType)> VariableQueue { get; } = new();
|
||||
/// <summary>Gets the queue of alarm-condition materialise calls.</summary>
|
||||
public ConcurrentQueue<(string AlarmNodeId, string EquipmentNodeId, string DisplayName, string AlarmType, int Severity)> AlarmConditionQueue { get; } = new();
|
||||
/// <summary>Gets the number of rebuild calls made on this sink.</summary>
|
||||
public int RebuildCalls;
|
||||
|
||||
/// <summary>Gets the list of recorded alarm writes.</summary>
|
||||
public List<(string NodeId, bool Active, bool Acknowledged)> AlarmWrites => AlarmQueue.ToList();
|
||||
public List<(string NodeId, AlarmConditionSnapshot State)> AlarmWrites => AlarmQueue.ToList();
|
||||
/// <summary>Gets the list of recorded folder creation calls.</summary>
|
||||
public List<(string NodeId, string? Parent, string DisplayName)> FolderCalls => FolderQueue.ToList();
|
||||
/// <summary>Gets the list of recorded variable creation calls.</summary>
|
||||
public List<(string NodeId, string? Parent, string DisplayName, string DataType)> VariableCalls => VariableQueue.ToList();
|
||||
/// <summary>Gets the list of recorded alarm-condition materialise calls.</summary>
|
||||
public List<(string AlarmNodeId, string EquipmentNodeId, string DisplayName, string AlarmType, int Severity)> AlarmConditionCalls => AlarmConditionQueue.ToList();
|
||||
|
||||
/// <summary>Records a value write (no-op in this recording sink).</summary>
|
||||
/// <param name="nodeId">The node ID.</param>
|
||||
@@ -435,13 +472,20 @@ public sealed class Phase7ApplierTests
|
||||
/// <param name="quality">The OPC UA quality.</param>
|
||||
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
|
||||
/// <summary>Records an alarm state write call.</summary>
|
||||
/// <summary>Records an alarm condition write call.</summary>
|
||||
/// <param name="alarmNodeId">The alarm node ID.</param>
|
||||
/// <param name="active">Whether the alarm is active.</param>
|
||||
/// <param name="acknowledged">Whether the alarm is acknowledged.</param>
|
||||
/// <param name="state">The full condition state snapshot.</param>
|
||||
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
||||
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
|
||||
=> AlarmQueue.Enqueue((alarmNodeId, active, acknowledged));
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc)
|
||||
=> AlarmQueue.Enqueue((alarmNodeId, state));
|
||||
/// <summary>Records an alarm-condition materialise call.</summary>
|
||||
/// <param name="alarmNodeId">The alarm node ID (== ScriptedAlarmId).</param>
|
||||
/// <param name="equipmentNodeId">The equipment folder node ID.</param>
|
||||
/// <param name="displayName">The condition display name.</param>
|
||||
/// <param name="alarmType">The domain alarm type.</param>
|
||||
/// <param name="severity">The domain severity.</param>
|
||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity)
|
||||
=> AlarmConditionQueue.Enqueue((alarmNodeId, equipmentNodeId, displayName, alarmType, severity));
|
||||
/// <summary>Records a folder creation call.</summary>
|
||||
/// <param name="folderNodeId">The folder node ID.</param>
|
||||
/// <param name="parentNodeId">The parent folder node ID, if any.</param>
|
||||
@@ -474,14 +518,20 @@ public sealed class Phase7ApplierTests
|
||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
|
||||
/// <summary>Throws an exception if configured to do so.</summary>
|
||||
/// <param name="alarmNodeId">The alarm node ID.</param>
|
||||
/// <param name="active">Whether the alarm is active.</param>
|
||||
/// <param name="acknowledged">Whether the alarm is acknowledged.</param>
|
||||
/// <param name="state">The full condition state snapshot.</param>
|
||||
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
||||
/// <exception cref="InvalidOperationException">Thrown when configured to throw on alarm write.</exception>
|
||||
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc)
|
||||
{
|
||||
if (_throwOnAlarmWrite) throw new InvalidOperationException("simulated sink fault");
|
||||
}
|
||||
/// <summary>No-op alarm-condition materialise call.</summary>
|
||||
/// <param name="alarmNodeId">The alarm node ID.</param>
|
||||
/// <param name="equipmentNodeId">The equipment folder node ID.</param>
|
||||
/// <param name="displayName">The condition display name.</param>
|
||||
/// <param name="alarmType">The domain alarm type.</param>
|
||||
/// <param name="severity">The domain severity.</param>
|
||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity) { }
|
||||
/// <summary>No-op folder creation call.</summary>
|
||||
/// <param name="folderNodeId">The folder node ID.</param>
|
||||
/// <param name="parentNodeId">The parent folder node ID, if any.</param>
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the live-edit compose seam (<see cref="Phase7Composer.Compose"/>) builds the richer
|
||||
/// per-equipment scripted-alarm projection (<see cref="EquipmentScriptedAlarmPlan"/>): each alarm
|
||||
/// is joined to its predicate <see cref="Script"/> for the source, carries the parsed
|
||||
/// <c>ctx.GetTag("…")</c> read refs UNION the <c>{TagPath}</c> tokens referenced in its
|
||||
/// <c>MessageTemplate</c> as DependencyRefs, and preserves every flag the host decides on
|
||||
/// (including disabled alarms). The projection is deterministic so the upcoming
|
||||
/// artifact-byte-parity test (sibling task) is reliable.
|
||||
/// </summary>
|
||||
public sealed class Phase7ComposerScriptedAlarmTests
|
||||
{
|
||||
/// <summary>Two equipments, each with a scripted alarm whose predicate script reads one tag via
|
||||
/// <c>ctx.GetTag("X.Y")</c>. Each plan must carry the resolved PredicateSource, DependencyRefs
|
||||
/// containing the read path, and every scalar field copied through faithfully.</summary>
|
||||
[Fact]
|
||||
public void Compose_emits_richer_scripted_alarm_plan_per_equipment_joined_to_script()
|
||||
{
|
||||
var script1 = new Script
|
||||
{
|
||||
ScriptId = "s-1",
|
||||
Name = "hot-1",
|
||||
SourceCode = "return System.Convert.ToDouble(ctx.GetTag(\"Mach1.Temp\").Value) > 80;",
|
||||
SourceHash = "h1",
|
||||
};
|
||||
var script2 = new Script
|
||||
{
|
||||
ScriptId = "s-2",
|
||||
Name = "hot-2",
|
||||
SourceCode = "return System.Convert.ToDouble(ctx.GetTag(\"Mach2.Temp\").Value) > 80;",
|
||||
SourceHash = "h2",
|
||||
};
|
||||
var alarm1 = new ScriptedAlarm
|
||||
{
|
||||
ScriptedAlarmId = "al-1",
|
||||
EquipmentId = "eq-1",
|
||||
Name = "Overheat-1",
|
||||
AlarmType = "LimitAlarm",
|
||||
Severity = 700,
|
||||
MessageTemplate = "Machine 1 overheating",
|
||||
PredicateScriptId = "s-1",
|
||||
HistorizeToAveva = true,
|
||||
Retain = true,
|
||||
Enabled = true,
|
||||
};
|
||||
var alarm2 = new ScriptedAlarm
|
||||
{
|
||||
ScriptedAlarmId = "al-2",
|
||||
EquipmentId = "eq-2",
|
||||
Name = "Overheat-2",
|
||||
AlarmType = "OffNormalAlarm",
|
||||
Severity = 250,
|
||||
MessageTemplate = "Machine 2 overheating",
|
||||
PredicateScriptId = "s-2",
|
||||
HistorizeToAveva = false,
|
||||
Retain = false,
|
||||
Enabled = true,
|
||||
};
|
||||
|
||||
var result = Phase7Composer.Compose(
|
||||
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
|
||||
Array.Empty<DriverInstance>(), new[] { alarm1, alarm2 },
|
||||
Array.Empty<Tag>(), Array.Empty<Namespace>(),
|
||||
virtualTags: Array.Empty<VirtualTag>(),
|
||||
scripts: new[] { script1, script2 });
|
||||
|
||||
result.EquipmentScriptedAlarms.Count.ShouldBe(2);
|
||||
|
||||
var plan1 = result.EquipmentScriptedAlarms.Single(p => p.ScriptedAlarmId == "al-1");
|
||||
plan1.EquipmentId.ShouldBe("eq-1");
|
||||
plan1.Name.ShouldBe("Overheat-1");
|
||||
plan1.AlarmType.ShouldBe("LimitAlarm");
|
||||
plan1.Severity.ShouldBe(700);
|
||||
plan1.MessageTemplate.ShouldBe("Machine 1 overheating");
|
||||
plan1.PredicateScriptId.ShouldBe("s-1");
|
||||
plan1.PredicateSource.ShouldBe(script1.SourceCode);
|
||||
plan1.DependencyRefs.ShouldBe(new[] { "Mach1.Temp" });
|
||||
plan1.HistorizeToAveva.ShouldBeTrue();
|
||||
plan1.Retain.ShouldBeTrue();
|
||||
plan1.Enabled.ShouldBeTrue();
|
||||
|
||||
var plan2 = result.EquipmentScriptedAlarms.Single(p => p.ScriptedAlarmId == "al-2");
|
||||
plan2.EquipmentId.ShouldBe("eq-2");
|
||||
plan2.AlarmType.ShouldBe("OffNormalAlarm");
|
||||
plan2.Severity.ShouldBe(250);
|
||||
plan2.PredicateSource.ShouldBe(script2.SourceCode);
|
||||
plan2.DependencyRefs.ShouldBe(new[] { "Mach2.Temp" });
|
||||
plan2.HistorizeToAveva.ShouldBeFalse();
|
||||
plan2.Retain.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>The <c>{TagPath}</c> tokens in the MessageTemplate are merged into DependencyRefs
|
||||
/// alongside the predicate's <c>ctx.GetTag</c> reads, so the host can subscribe to every signal
|
||||
/// the alarm references for either the predicate or the message.</summary>
|
||||
[Fact]
|
||||
public void Compose_merges_message_template_token_paths_into_dependency_refs()
|
||||
{
|
||||
var script = new Script
|
||||
{
|
||||
ScriptId = "s-1",
|
||||
Name = "hot",
|
||||
SourceCode = "return System.Convert.ToDouble(ctx.GetTag(\"Line.Pressure\").Value) > 5;",
|
||||
SourceHash = "h1",
|
||||
};
|
||||
var alarm = new ScriptedAlarm
|
||||
{
|
||||
ScriptedAlarmId = "al-1",
|
||||
EquipmentId = "eq-1",
|
||||
Name = "TempHigh",
|
||||
AlarmType = "LimitAlarm",
|
||||
Severity = 500,
|
||||
MessageTemplate = "Temp {Line.Temp} high",
|
||||
PredicateScriptId = "s-1",
|
||||
};
|
||||
|
||||
var result = Phase7Composer.Compose(
|
||||
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
|
||||
Array.Empty<DriverInstance>(), new[] { alarm },
|
||||
Array.Empty<Tag>(), Array.Empty<Namespace>(),
|
||||
scripts: new[] { script });
|
||||
|
||||
var plan = result.EquipmentScriptedAlarms.ShouldHaveSingleItem();
|
||||
// Predicate read + message token are both present (predicate reads first, then template tokens).
|
||||
plan.DependencyRefs.ShouldBe(new[] { "Line.Pressure", "Line.Temp" });
|
||||
}
|
||||
|
||||
/// <summary>A template token that duplicates a predicate read collapses to one ref, and a
|
||||
/// repeated template token collapses too — DependencyRefs are distinct (predicate reads first,
|
||||
/// then first-seen template tokens), and the reserved <c>{{equip}}</c> double-brace form is not
|
||||
/// harvested as a token.</summary>
|
||||
[Fact]
|
||||
public void Compose_dependency_refs_are_distinct_and_exclude_the_reserved_equip_token()
|
||||
{
|
||||
var script = new Script
|
||||
{
|
||||
ScriptId = "s-1",
|
||||
Name = "dup",
|
||||
SourceCode = "return ctx.GetTag(\"A.X\").Value;",
|
||||
SourceHash = "h1",
|
||||
};
|
||||
var alarm = new ScriptedAlarm
|
||||
{
|
||||
ScriptedAlarmId = "al-1",
|
||||
EquipmentId = "eq-1",
|
||||
Name = "Dup",
|
||||
AlarmType = "AlarmCondition",
|
||||
Severity = 500,
|
||||
// {A.X} duplicates the predicate read; {B.Y} appears twice; {{equip}}.Z must NOT be a token.
|
||||
MessageTemplate = "v={A.X} w={B.Y} x={B.Y} base={{equip}}.Z",
|
||||
PredicateScriptId = "s-1",
|
||||
};
|
||||
|
||||
var result = Phase7Composer.Compose(
|
||||
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
|
||||
Array.Empty<DriverInstance>(), new[] { alarm },
|
||||
Array.Empty<Tag>(), Array.Empty<Namespace>(),
|
||||
scripts: new[] { script });
|
||||
|
||||
var plan = result.EquipmentScriptedAlarms.ShouldHaveSingleItem();
|
||||
plan.DependencyRefs.ShouldBe(new[] { "A.X", "B.Y" });
|
||||
plan.DependencyRefs.ShouldNotContain("equip");
|
||||
}
|
||||
|
||||
/// <summary>A scripted alarm whose PredicateScriptId matches no Script row is SKIPPED (not in the
|
||||
/// result) and compose does NOT throw — the bad row is dropped, the rest of the projection stands.</summary>
|
||||
[Fact]
|
||||
public void Compose_skips_scripted_alarm_with_missing_predicate_script_without_throwing()
|
||||
{
|
||||
var goodScript = new Script
|
||||
{
|
||||
ScriptId = "s-ok",
|
||||
Name = "ok",
|
||||
SourceCode = "return ctx.GetTag(\"A.X\").Value;",
|
||||
SourceHash = "h1",
|
||||
};
|
||||
var goodAlarm = new ScriptedAlarm
|
||||
{
|
||||
ScriptedAlarmId = "al-ok",
|
||||
EquipmentId = "eq-1",
|
||||
Name = "Ok",
|
||||
AlarmType = "AlarmCondition",
|
||||
Severity = 500,
|
||||
MessageTemplate = "ok",
|
||||
PredicateScriptId = "s-ok",
|
||||
};
|
||||
var orphanAlarm = new ScriptedAlarm
|
||||
{
|
||||
ScriptedAlarmId = "al-orphan",
|
||||
EquipmentId = "eq-2",
|
||||
Name = "Orphan",
|
||||
AlarmType = "AlarmCondition",
|
||||
Severity = 500,
|
||||
MessageTemplate = "orphan",
|
||||
PredicateScriptId = "s-does-not-exist",
|
||||
};
|
||||
|
||||
var result = Phase7Composer.Compose(
|
||||
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
|
||||
Array.Empty<DriverInstance>(), new[] { goodAlarm, orphanAlarm },
|
||||
Array.Empty<Tag>(), Array.Empty<Namespace>(),
|
||||
scripts: new[] { goodScript });
|
||||
|
||||
var plan = result.EquipmentScriptedAlarms.ShouldHaveSingleItem();
|
||||
plan.ScriptedAlarmId.ShouldBe("al-ok");
|
||||
result.EquipmentScriptedAlarms.ShouldNotContain(p => p.ScriptedAlarmId == "al-orphan");
|
||||
}
|
||||
|
||||
/// <summary>A disabled alarm (<c>Enabled=false</c>) is STILL present in the projection — the host
|
||||
/// decides whether to host it, the composer never drops it. The flag is carried through.</summary>
|
||||
[Fact]
|
||||
public void Compose_keeps_disabled_scripted_alarm_with_enabled_flag_carried()
|
||||
{
|
||||
var script = new Script
|
||||
{
|
||||
ScriptId = "s-1",
|
||||
Name = "off",
|
||||
SourceCode = "return ctx.GetTag(\"A.X\").Value;",
|
||||
SourceHash = "h1",
|
||||
};
|
||||
var alarm = new ScriptedAlarm
|
||||
{
|
||||
ScriptedAlarmId = "al-off",
|
||||
EquipmentId = "eq-1",
|
||||
Name = "Disabled",
|
||||
AlarmType = "AlarmCondition",
|
||||
Severity = 500,
|
||||
MessageTemplate = "off",
|
||||
PredicateScriptId = "s-1",
|
||||
Enabled = false,
|
||||
};
|
||||
|
||||
var result = Phase7Composer.Compose(
|
||||
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
|
||||
Array.Empty<DriverInstance>(), new[] { alarm },
|
||||
Array.Empty<Tag>(), Array.Empty<Namespace>(),
|
||||
scripts: new[] { script });
|
||||
|
||||
var plan = result.EquipmentScriptedAlarms.ShouldHaveSingleItem();
|
||||
plan.ScriptedAlarmId.ShouldBe("al-off");
|
||||
plan.Enabled.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Determinism: the same inputs in different order produce identical ordered output
|
||||
/// (stable sort by EquipmentId then ScriptedAlarmId) — this is the contract the upcoming
|
||||
/// artifact byte-parity test relies on.</summary>
|
||||
[Fact]
|
||||
public void Compose_scripted_alarm_projection_is_deterministic_regardless_of_input_order()
|
||||
{
|
||||
var script1 = new Script { ScriptId = "s-1", Name = "a", SourceCode = "return ctx.GetTag(\"A.X\").Value;", SourceHash = "h1" };
|
||||
var script2 = new Script { ScriptId = "s-2", Name = "b", SourceCode = "return ctx.GetTag(\"B.Y\").Value;", SourceHash = "h2" };
|
||||
var script3 = new Script { ScriptId = "s-3", Name = "c", SourceCode = "return ctx.GetTag(\"C.Z\").Value;", SourceHash = "h3" };
|
||||
|
||||
// Two alarms on the same equipment to exercise the secondary (ScriptedAlarmId) sort key.
|
||||
var a1 = NewAlarm("al-1", "eq-2", "s-1");
|
||||
var a2 = NewAlarm("al-2", "eq-1", "s-2");
|
||||
var a3 = NewAlarm("al-3", "eq-1", "s-3");
|
||||
var scripts = new[] { script1, script2, script3 };
|
||||
|
||||
var r1 = Phase7Composer.Compose(
|
||||
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
|
||||
Array.Empty<DriverInstance>(), new[] { a1, a2, a3 },
|
||||
Array.Empty<Tag>(), Array.Empty<Namespace>(), scripts: scripts);
|
||||
|
||||
var r2 = Phase7Composer.Compose(
|
||||
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
|
||||
Array.Empty<DriverInstance>(), new[] { a3, a1, a2 },
|
||||
Array.Empty<Tag>(), Array.Empty<Namespace>(), scripts: scripts);
|
||||
|
||||
// Sorted by EquipmentId then ScriptedAlarmId: (eq-1,al-2), (eq-1,al-3), (eq-2,al-1).
|
||||
r1.EquipmentScriptedAlarms.Select(p => p.ScriptedAlarmId)
|
||||
.ShouldBe(new[] { "al-2", "al-3", "al-1" });
|
||||
r1.EquipmentScriptedAlarms.ShouldBe(r2.EquipmentScriptedAlarms);
|
||||
}
|
||||
|
||||
/// <summary>The new projection defaults to empty when no scripts/alarms are supplied (init-only
|
||||
/// member, no positional-parameter break for existing callers).</summary>
|
||||
[Fact]
|
||||
public void Composition_carries_empty_scripted_alarms_by_default()
|
||||
{
|
||||
var r = new Phase7CompositionResult(
|
||||
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>());
|
||||
r.EquipmentScriptedAlarms.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
private static ScriptedAlarm NewAlarm(string id, string equipmentId, string scriptId) => new()
|
||||
{
|
||||
ScriptedAlarmId = id,
|
||||
EquipmentId = equipmentId,
|
||||
Name = id,
|
||||
AlarmType = "AlarmCondition",
|
||||
Severity = 500,
|
||||
MessageTemplate = $"{id} alarm",
|
||||
PredicateScriptId = scriptId,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
@@ -8,7 +9,7 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||
/// <summary>
|
||||
/// Integration tests for the F10b production binding: boot a real <see cref="OtOpcUaSdkServer"/>
|
||||
/// through <see cref="OpcUaApplicationHost"/>, attach a <see cref="SdkAddressSpaceSink"/>,
|
||||
/// drive <c>WriteValue</c>/<c>WriteAlarmState</c>/<c>RebuildAddressSpace</c>, and verify the
|
||||
/// drive <c>WriteValue</c>/<c>WriteAlarmCondition</c>/<c>RebuildAddressSpace</c>, and verify the
|
||||
/// <see cref="OtOpcUaNodeManager"/> reflects the writes.
|
||||
/// </summary>
|
||||
public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
@@ -35,14 +36,15 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that WriteAlarmState creates a dedicated node distinct from value writes.</summary>
|
||||
/// <summary>Verifies that WriteAlarmCondition (un-materialised fallback) creates a dedicated node
|
||||
/// distinct from value writes.</summary>
|
||||
[Fact]
|
||||
public async Task WriteAlarmState_creates_dedicated_node_distinct_from_value_writes()
|
||||
public async Task WriteAlarmCondition_creates_dedicated_node_distinct_from_value_writes()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var sink = new SdkAddressSpaceSink(server.NodeManager!);
|
||||
|
||||
sink.WriteAlarmState("alarm-7", active: true, acknowledged: false, DateTime.UtcNow);
|
||||
sink.WriteAlarmCondition("alarm-7", Snapshot(active: true, acknowledged: false), DateTime.UtcNow);
|
||||
sink.WriteValue("eq-1/temp", 22.5, OpcUaQuality.Good, DateTime.UtcNow);
|
||||
|
||||
server.NodeManager!.VariableCount.ShouldBe(2);
|
||||
@@ -59,7 +61,7 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
|
||||
sink.WriteValue("a", 1, OpcUaQuality.Good, DateTime.UtcNow);
|
||||
sink.WriteValue("b", 2, OpcUaQuality.Good, DateTime.UtcNow);
|
||||
sink.WriteAlarmState("alarm-c", true, false, DateTime.UtcNow);
|
||||
sink.WriteAlarmCondition("alarm-c", Snapshot(active: true), DateTime.UtcNow);
|
||||
server.NodeManager!.VariableCount.ShouldBe(3);
|
||||
|
||||
sink.RebuildAddressSpace();
|
||||
@@ -80,11 +82,436 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
// NullOpcUaAddressSpaceSink when no SDK NodeManager is wired.
|
||||
var sink = NullOpcUaAddressSpaceSink.Instance;
|
||||
sink.WriteValue("x", 1, OpcUaQuality.Good, DateTime.UtcNow);
|
||||
sink.WriteAlarmState("a", true, false, DateTime.UtcNow);
|
||||
sink.WriteAlarmCondition("a", Snapshot(active: true), DateTime.UtcNow);
|
||||
sink.RebuildAddressSpace();
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>T14 — materialises an equipment folder + a real Part 9 AlarmConditionState under it,
|
||||
/// then projects active state through WriteAlarmCondition. Asserts the node is a real
|
||||
/// <see cref="AlarmConditionState"/>, reachable under the equipment folder, and that
|
||||
/// ActiveState/Retain reflect the write. Also inspects which optional Part 9 children
|
||||
/// <c>Create</c> auto-builds (the T13 uncertainty) and records the finding inline.</summary>
|
||||
[Fact]
|
||||
public async Task MaterialiseAlarmCondition_creates_real_condition_node_and_WriteAlarmCondition_updates_it()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
var sink = new SdkAddressSpaceSink(nm);
|
||||
|
||||
// Equipment folder must exist first (MaterialiseHierarchy owns this in production).
|
||||
sink.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1");
|
||||
|
||||
// Materialise the condition. NodeId == alarm node id (the ScriptedAlarmId) so WriteAlarmCondition targets it.
|
||||
sink.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", severity: 700);
|
||||
|
||||
nm.AlarmConditionCount.ShouldBe(1);
|
||||
|
||||
var condition = nm.TryGetAlarmCondition("alm-1");
|
||||
condition.ShouldNotBeNull();
|
||||
// It is a REAL Part 9 alarm condition (subtype mapped from "OffNormalAlarm").
|
||||
condition.ShouldBeOfType<OffNormalAlarmState>();
|
||||
condition.NodeId.ShouldBe(new NodeId("alm-1", nm.NamespaceIndex));
|
||||
|
||||
// Reachable under the equipment folder: the parent is the eq-1 folder (HasComponent child).
|
||||
condition.Parent.ShouldNotBeNull();
|
||||
condition.Parent!.NodeId.ShouldBe(new NodeId("eq-1", nm.NamespaceIndex));
|
||||
|
||||
// Initial state set by MaterialiseAlarmCondition: enabled, inactive, acked, retain=false.
|
||||
condition.EnabledState.Id.Value.ShouldBeTrue();
|
||||
condition.ActiveState.Id.Value.ShouldBeFalse();
|
||||
condition.Retain.Value.ShouldBeFalse();
|
||||
|
||||
// --- T13 optional-children finding (RESOLVED by THIS real-server test) ---
|
||||
// AckedState is mandatory on AcknowledgeableConditionState so it is always present.
|
||||
condition.AckedState.ShouldNotBeNull();
|
||||
// FINDING (1.5.378.106): Create auto-builds the FULL optional Part 9 child set from the
|
||||
// embedded type definition WITHOUT us pre-setting any property — both ConfirmedState (Confirm
|
||||
// sub-state machine) AND ShelvingState (Shelve state machine) come back non-null. This is
|
||||
// RICHER than the SDK-notes' [SAMPLE-ONLY] caveat predicted (it suggested we'd have to
|
||||
// instantiate optional children ourselves). Net: T15/T16 can drive SetConfirmedState /
|
||||
// SetShelvingState directly — no manual child materialisation needed. Asserting both non-null
|
||||
// so a future SDK bump that changes auto-build behaviour fails loudly.
|
||||
condition.ConfirmedState.ShouldNotBeNull();
|
||||
condition.ShelvingState.ShouldNotBeNull();
|
||||
|
||||
// WriteAlarmCondition now targets the real condition (not the bool[2] placeholder): no extra
|
||||
// BaseDataVariable is minted for the alarm id.
|
||||
sink.WriteAlarmCondition("alm-1", Snapshot(active: true, acknowledged: false), DateTime.UtcNow);
|
||||
nm.VariableCount.ShouldBe(0); // fallback bool[2] path NOT taken
|
||||
|
||||
condition.ActiveState.Id.Value.ShouldBeTrue();
|
||||
condition.AckedState.Id.Value.ShouldBeFalse();
|
||||
condition.Retain.Value.ShouldBeTrue(); // active || !acked ⇒ retain
|
||||
|
||||
// Idempotent re-materialise (e.g. redeploy): still exactly one condition node for the id.
|
||||
sink.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", severity: 700);
|
||||
nm.AlarmConditionCount.ShouldBe(1);
|
||||
|
||||
// RebuildAddressSpace clears the alarm dict too.
|
||||
sink.RebuildAddressSpace();
|
||||
nm.AlarmConditionCount.ShouldBe(0);
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>An unknown / limit-style AlarmType (with no script-supplied OPC limits) falls back to
|
||||
/// the base <see cref="AlarmConditionState"/> per the T13 notes.</summary>
|
||||
[Fact]
|
||||
public async Task MaterialiseAlarmCondition_unknown_type_falls_back_to_base_condition()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
var sink = new SdkAddressSpaceSink(nm);
|
||||
|
||||
sink.EnsureFolder("eq-9", parentNodeId: null, displayName: "Equipment 9");
|
||||
sink.MaterialiseAlarmCondition("alm-x", "eq-9", "GenericAlarm", "LimitAlarm", severity: 500);
|
||||
|
||||
var condition = nm.TryGetAlarmCondition("alm-x");
|
||||
condition.ShouldNotBeNull();
|
||||
// Base type exactly — NOT a LimitAlarmState (no limits to populate for a script alarm).
|
||||
condition.GetType().ShouldBe(typeof(AlarmConditionState));
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>T15 — the full condition state bridges through WriteAlarmCondition. Materialise a
|
||||
/// condition, then push a rich snapshot (active + unacked + disabled + timed-shelved + high severity
|
||||
/// + message) and assert every projected child reflects it:
|
||||
/// ActiveState/AckedState/EnabledState/ConfirmedState/ShelvingState/Severity/Message/Retain.</summary>
|
||||
[Fact]
|
||||
public async Task WriteAlarmCondition_projects_full_state_onto_materialised_condition()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
var sink = new SdkAddressSpaceSink(nm);
|
||||
|
||||
sink.EnsureFolder("eq-2", parentNodeId: null, displayName: "Equipment 2");
|
||||
sink.MaterialiseAlarmCondition("alm-rich", "eq-2", "HighPressure", "OffNormalAlarm", severity: 300);
|
||||
|
||||
var condition = nm.TryGetAlarmCondition("alm-rich");
|
||||
condition.ShouldNotBeNull();
|
||||
|
||||
// Rich snapshot: active, unacknowledged, unconfirmed, DISABLED, TIMED-shelved, severity 850, message.
|
||||
sink.WriteAlarmCondition(
|
||||
"alm-rich",
|
||||
new AlarmConditionSnapshot(
|
||||
Active: true,
|
||||
Acknowledged: false,
|
||||
Confirmed: false,
|
||||
Enabled: false,
|
||||
Shelving: AlarmShelvingKind.Timed,
|
||||
Severity: 850,
|
||||
Message: "Pressure above limit"),
|
||||
DateTime.UtcNow);
|
||||
|
||||
condition.ActiveState.Id.Value.ShouldBeTrue();
|
||||
condition.AckedState.Id.Value.ShouldBeFalse();
|
||||
condition.EnabledState.Id.Value.ShouldBeFalse(); // disabled
|
||||
condition.ConfirmedState!.Id.Value.ShouldBeFalse(); // unconfirmed
|
||||
// SetShelvingState(shelved:true, oneShot:false) drives the shelving sub-state machine into a
|
||||
// shelved state; CurrentState is populated (TimedShelved).
|
||||
condition.ShelvingState!.CurrentState.Id.Value.ShouldNotBeNull();
|
||||
// Severity 850 → High bucket (>= 800) → EventSeverity.High (the SDK stamps the ushort value).
|
||||
condition.Severity.Value.ShouldBe((ushort)EventSeverity.High);
|
||||
condition.Message.Value.Text.ShouldBe("Pressure above limit");
|
||||
condition.Retain.Value.ShouldBeTrue(); // active || !acked ⇒ retain
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>T15 null-guard — a base <see cref="AlarmConditionState"/> (AlarmType "AlarmCondition")
|
||||
/// whose optional ConfirmedState child the SDK might not materialise must not throw when a snapshot
|
||||
/// sets Confirmed/Shelving. We force the ConfirmedState child to null after materialise to simulate a
|
||||
/// leaner child set, then write a snapshot that sets Confirmed=true — the write must be a safe no-op
|
||||
/// on that child rather than an NRE, while still projecting the mandatory state.</summary>
|
||||
[Fact]
|
||||
public async Task WriteAlarmCondition_null_optional_child_does_not_throw()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
var sink = new SdkAddressSpaceSink(nm);
|
||||
|
||||
sink.EnsureFolder("eq-3", parentNodeId: null, displayName: "Equipment 3");
|
||||
sink.MaterialiseAlarmCondition("alm-base", "eq-3", "Generic", "AlarmCondition", severity: 200);
|
||||
|
||||
var condition = nm.TryGetAlarmCondition("alm-base");
|
||||
condition.ShouldNotBeNull();
|
||||
condition.GetType().ShouldBe(typeof(AlarmConditionState)); // base type
|
||||
|
||||
// Simulate a build where the optional Confirm sub-state machine was NOT created.
|
||||
condition.ConfirmedState = null;
|
||||
|
||||
// Setting Confirmed on a condition with no ConfirmedState child must not throw.
|
||||
Should.NotThrow(() =>
|
||||
sink.WriteAlarmCondition(
|
||||
"alm-base",
|
||||
new AlarmConditionSnapshot(
|
||||
Active: true,
|
||||
Acknowledged: false,
|
||||
Confirmed: true, // targets the (now-null) optional child
|
||||
Enabled: true,
|
||||
Shelving: AlarmShelvingKind.OneShot,
|
||||
Severity: 500,
|
||||
Message: "still works"),
|
||||
DateTime.UtcNow));
|
||||
|
||||
// Mandatory state still projected despite the missing optional child.
|
||||
condition.ActiveState.Id.Value.ShouldBeTrue();
|
||||
condition.AckedState.Id.Value.ShouldBeFalse();
|
||||
condition.Message.Value.Text.ShouldBe("still works");
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>T16 — each engine-driven <see cref="OtOpcUaNodeManager.WriteAlarmCondition"/> on a
|
||||
/// materialised condition fires a real Part 9 condition event, stamping a FRESH per-event
|
||||
/// <c>EventId</c> (GUID bytes). Part 9 requires a unique EventId per event so inbound
|
||||
/// Acknowledge/Confirm (T17) can correlate back to the exact event being acked.
|
||||
/// <para>
|
||||
/// We assert EventId-freshness (non-null + changed from its materialise-time value, and a second
|
||||
/// write yields a DIFFERENT EventId) plus no-throw. The node manager exposes no in-process hook to
|
||||
/// observe <c>ReportEvent</c> delivery without standing up a full client subscription + monitored
|
||||
/// item, so actual event DELIVERY to a subscribing client is proven live in T19 (Client.CLI) rather
|
||||
/// than faked here — see the comment below.
|
||||
/// </para></summary>
|
||||
[Fact]
|
||||
public async Task WriteAlarmCondition_fires_event_with_fresh_EventId_per_transition()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
var sink = new SdkAddressSpaceSink(nm);
|
||||
|
||||
sink.EnsureFolder("eq-evt", parentNodeId: null, displayName: "Equipment Evt");
|
||||
sink.MaterialiseAlarmCondition("alm-evt", "eq-evt", "HighTemp", "OffNormalAlarm", severity: 700);
|
||||
|
||||
var condition = nm.TryGetAlarmCondition("alm-evt");
|
||||
condition.ShouldNotBeNull();
|
||||
|
||||
// EventId at materialise time (Create stamps an initial one). Capture a COPY — the live byte[]
|
||||
// reference is reused, so we compare snapshots, not the same array instance.
|
||||
var materialiseEventId = (byte[]?)condition!.EventId.Value?.Clone();
|
||||
|
||||
// First engine-driven transition → fires an event with a fresh EventId.
|
||||
sink.WriteAlarmCondition("alm-evt", Snapshot(active: true, acknowledged: false), DateTime.UtcNow);
|
||||
|
||||
var firstEventId = (byte[]?)condition.EventId.Value?.Clone();
|
||||
firstEventId.ShouldNotBeNull();
|
||||
firstEventId!.Length.ShouldBe(16); // GUID bytes
|
||||
// Changed from the materialise-time value (a real event fired, not the create-time stamp).
|
||||
if (materialiseEventId is not null)
|
||||
{
|
||||
firstEventId.ShouldNotBe(materialiseEventId);
|
||||
}
|
||||
|
||||
// Second transition → DIFFERENT EventId (fresh per event, so T17 ack-correlation is unambiguous).
|
||||
sink.WriteAlarmCondition("alm-evt", Snapshot(active: true, acknowledged: true), DateTime.UtcNow);
|
||||
|
||||
var secondEventId = (byte[]?)condition.EventId.Value?.Clone();
|
||||
secondEventId.ShouldNotBeNull();
|
||||
secondEventId!.ShouldNotBe(firstEventId);
|
||||
|
||||
// NOTE: event DELIVERY (a subscribing client actually receiving these ActiveState/AckedState
|
||||
// transitions on the equipment folder) is an integration concern proven LIVE in T19 via the
|
||||
// Client.CLI alarm subscription — not asserted here, and deliberately NOT faked. This unit-level
|
||||
// test proves the firing path is exercised (no throw) and the Part 9 EventId-freshness invariant
|
||||
// that T17's ack routing depends on.
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>T16 — firing an event must not break the state projection even when ReportEvent could
|
||||
/// fail. We can't easily force ReportEvent to throw in-process, but we CAN prove the projection +
|
||||
/// firing path is exception-safe end-to-end across repeated transitions and that the condition's
|
||||
/// mandatory state stays correct after firing.</summary>
|
||||
[Fact]
|
||||
public async Task WriteAlarmCondition_firing_does_not_disturb_projected_state()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
var sink = new SdkAddressSpaceSink(nm);
|
||||
|
||||
sink.EnsureFolder("eq-evt2", parentNodeId: null, displayName: "Equipment Evt2");
|
||||
sink.MaterialiseAlarmCondition("alm-evt2", "eq-evt2", "HighTemp", "OffNormalAlarm", severity: 700);
|
||||
|
||||
var condition = nm.TryGetAlarmCondition("alm-evt2");
|
||||
condition.ShouldNotBeNull();
|
||||
|
||||
// Drive several transitions; each fires an event AND projects state. State must survive firing.
|
||||
Should.NotThrow(() =>
|
||||
{
|
||||
sink.WriteAlarmCondition("alm-evt2", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow);
|
||||
sink.WriteAlarmCondition("alm-evt2", Snapshot(active: true, acknowledged: true, message: "acked"), DateTime.UtcNow);
|
||||
sink.WriteAlarmCondition("alm-evt2", Snapshot(active: false, acknowledged: true, message: "cleared"), DateTime.UtcNow);
|
||||
});
|
||||
|
||||
// Final projected state is intact after the last firing.
|
||||
condition!.ActiveState.Id.Value.ShouldBeFalse();
|
||||
condition.AckedState.Id.Value.ShouldBeTrue();
|
||||
condition.Message.Value.Text.ShouldBe("cleared");
|
||||
condition.Retain.Value.ShouldBeFalse(); // inactive && acked ⇒ no retain
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>T20 — the inbound double-emit is suppressed by the delta-gate. We simulate the REAL
|
||||
/// inbound sequence: a client Acknowledge whose T18 gate returned Good causes the SDK to apply the
|
||||
/// acked state to the node and auto-fire its OWN event (E2) WITHOUT going through WriteAlarmCondition.
|
||||
/// We reproduce that by applying the acked state directly onto the live AlarmConditionState the way
|
||||
/// the SDK would. THEN the engine re-projects the same logical transition through WriteAlarmCondition
|
||||
/// with the matching snapshot — and because that snapshot equals the node's current state, the
|
||||
/// delta-gate must fire NO event (no E3). We prove "no event fired" by asserting the condition's
|
||||
/// EventId is UNCHANGED across the WriteAlarmCondition call (ReportConditionEvent always restamps a
|
||||
/// fresh GUID EventId when it fires — same probe the T16 event test uses).</summary>
|
||||
[Fact]
|
||||
public async Task WriteAlarmCondition_suppresses_event_when_snapshot_equals_node_current_state()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
var sink = new SdkAddressSpaceSink(nm);
|
||||
|
||||
sink.EnsureFolder("eq-ack", parentNodeId: null, displayName: "Equipment Ack");
|
||||
sink.MaterialiseAlarmCondition("alm-ack", "eq-ack", "HighTemp", "OffNormalAlarm", severity: 700);
|
||||
|
||||
var condition = nm.TryGetAlarmCondition("alm-ack");
|
||||
condition.ShouldNotBeNull();
|
||||
|
||||
// Drive the alarm active+unacked through the engine path (a genuine transition → fires).
|
||||
sink.WriteAlarmCondition("alm-ack", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow);
|
||||
condition!.ActiveState.Id.Value.ShouldBeTrue();
|
||||
condition.AckedState.Id.Value.ShouldBeFalse();
|
||||
|
||||
// === Simulate the SDK-applied inbound Acknowledge (E2) ===
|
||||
// After T18's gate returns Good, the SDK applies the acked state to the node and auto-fires its
|
||||
// own event — directly on the node, BYPASSING WriteAlarmCondition. Reproduce that node mutation.
|
||||
lock (nm.Lock)
|
||||
{
|
||||
condition.SetAcknowledgedState(nm.SystemContext, true);
|
||||
condition.Message.Value = new LocalizedText("active");
|
||||
condition.ClearChangeMasks(nm.SystemContext, includeChildren: true);
|
||||
}
|
||||
condition.AckedState.Id.Value.ShouldBeTrue();
|
||||
|
||||
// Capture the EventId AFTER the SDK-applied ack but BEFORE the engine re-projection.
|
||||
var beforeReProject = (byte[]?)condition.EventId.Value?.Clone();
|
||||
|
||||
// === Engine re-projects the SAME acked transition through WriteAlarmCondition (would-be E3) ===
|
||||
// Snapshot equals the node's current state (active, acked, message "active") ⇒ delta-gate sees
|
||||
// no change ⇒ NO event fires.
|
||||
sink.WriteAlarmCondition("alm-ack", Snapshot(active: true, acknowledged: true, message: "active"), DateTime.UtcNow);
|
||||
|
||||
var afterReProject = (byte[]?)condition.EventId.Value?.Clone();
|
||||
// EventId is UNCHANGED ⇒ ReportConditionEvent did NOT run ⇒ E3 suppressed.
|
||||
afterReProject.ShouldBe(beforeReProject);
|
||||
|
||||
// The projection still applied (state is intact) — only the redundant event was suppressed.
|
||||
condition.ActiveState.Id.Value.ShouldBeTrue();
|
||||
condition.AckedState.Id.Value.ShouldBeTrue();
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>T20 — genuine engine-driven transitions still fire exactly one event, and a second
|
||||
/// IDENTICAL WriteAlarmCondition (no delta vs the node's now-current state) fires zero more. We use
|
||||
/// the EventId-changed-on-fire probe: a fire restamps a fresh GUID EventId, a suppress leaves it
|
||||
/// untouched.</summary>
|
||||
[Fact]
|
||||
public async Task WriteAlarmCondition_fires_on_delta_and_suppresses_identical_reprojection()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
var sink = new SdkAddressSpaceSink(nm);
|
||||
|
||||
sink.EnsureFolder("eq-delta", parentNodeId: null, displayName: "Equipment Delta");
|
||||
sink.MaterialiseAlarmCondition("alm-delta", "eq-delta", "HighTemp", "OffNormalAlarm", severity: 700);
|
||||
|
||||
var condition = nm.TryGetAlarmCondition("alm-delta");
|
||||
condition.ShouldNotBeNull();
|
||||
|
||||
var beforeFirst = (byte[]?)condition!.EventId.Value?.Clone();
|
||||
|
||||
// Genuine transition: snapshot (active, unacked) differs from the materialise state
|
||||
// (inactive, acked) ⇒ delta ⇒ fires exactly one event (EventId changes).
|
||||
sink.WriteAlarmCondition("alm-delta", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow);
|
||||
var afterFirst = (byte[]?)condition.EventId.Value?.Clone();
|
||||
afterFirst.ShouldNotBeNull();
|
||||
afterFirst!.ShouldNotBe(beforeFirst); // fired
|
||||
|
||||
// Identical re-projection: snapshot now EQUALS the node's current state ⇒ no delta ⇒ 0 more
|
||||
// events (EventId unchanged from the first fire).
|
||||
sink.WriteAlarmCondition("alm-delta", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow);
|
||||
var afterSecond = (byte[]?)condition.EventId.Value?.Clone();
|
||||
afterSecond.ShouldBe(afterFirst); // suppressed
|
||||
|
||||
// A FURTHER genuine transition (clear) differs again ⇒ fires once more.
|
||||
sink.WriteAlarmCondition("alm-delta", Snapshot(active: false, acknowledged: true, message: "cleared"), DateTime.UtcNow);
|
||||
var afterThird = (byte[]?)condition.EventId.Value?.Clone();
|
||||
afterThird.ShouldNotBe(afterSecond); // fired
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>T20 — direct unit test of the pure fire-vs-suppress decision seam
|
||||
/// (<see cref="OtOpcUaNodeManager.ShouldFireConditionEvent"/>). Equal states ⇒ suppress; any single
|
||||
/// field differing ⇒ fire. This pins the gate logic independent of the booted server.</summary>
|
||||
[Fact]
|
||||
public void ShouldFireConditionEvent_fires_only_on_a_field_delta()
|
||||
{
|
||||
var baseState = new OtOpcUaNodeManager.AlarmConditionDelta(
|
||||
Active: true, Acknowledged: false, Confirmed: true, Enabled: true,
|
||||
Shelving: AlarmShelvingKind.Unshelved, MappedSeverity: 100, Message: "m");
|
||||
|
||||
// Equal ⇒ suppress (this is the inbound double-emit case in pure form).
|
||||
OtOpcUaNodeManager.ShouldFireConditionEvent(baseState, baseState).ShouldBeFalse();
|
||||
OtOpcUaNodeManager.ShouldFireConditionEvent(baseState, baseState with { }).ShouldBeFalse();
|
||||
|
||||
// Each single-field difference ⇒ fire.
|
||||
OtOpcUaNodeManager.ShouldFireConditionEvent(baseState, baseState with { Active = false }).ShouldBeTrue();
|
||||
OtOpcUaNodeManager.ShouldFireConditionEvent(baseState, baseState with { Acknowledged = true }).ShouldBeTrue();
|
||||
OtOpcUaNodeManager.ShouldFireConditionEvent(baseState, baseState with { Confirmed = false }).ShouldBeTrue();
|
||||
OtOpcUaNodeManager.ShouldFireConditionEvent(baseState, baseState with { Enabled = false }).ShouldBeTrue();
|
||||
OtOpcUaNodeManager.ShouldFireConditionEvent(baseState, baseState with { Shelving = AlarmShelvingKind.Timed }).ShouldBeTrue();
|
||||
OtOpcUaNodeManager.ShouldFireConditionEvent(baseState, baseState with { MappedSeverity = 900 }).ShouldBeTrue();
|
||||
OtOpcUaNodeManager.ShouldFireConditionEvent(baseState, baseState with { Message = "other" }).ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>T20 — null-vs-empty Message normalization. Both snapshot.Message = null and a live node
|
||||
/// whose Message.Value.Text = null normalize to <see cref="string.Empty"/> in
|
||||
/// <see cref="OtOpcUaNodeManager.AlarmConditionDelta"/>, so a snapshot whose Message is null does NOT
|
||||
/// produce a phantom delta against a freshly-materialised node (whose Message is the displayName but
|
||||
/// whose Text reads back as the displayName string, and a snapshot that explicitly passes an empty
|
||||
/// string equally produces no delta vs a null-Message node). This pins the normalization so a
|
||||
/// future change to the null-coalescing in ReadConditionDelta / ToConditionDelta is caught here.</summary>
|
||||
[Fact]
|
||||
public void ShouldFireConditionEvent_null_and_empty_message_normalize_identically()
|
||||
{
|
||||
// Both null and "" collapse to string.Empty in AlarmConditionDelta — they are the same delta value.
|
||||
var withNull = new OtOpcUaNodeManager.AlarmConditionDelta(
|
||||
Active: false, Acknowledged: true, Confirmed: true, Enabled: true,
|
||||
Shelving: AlarmShelvingKind.Unshelved, MappedSeverity: 100, Message: string.Empty);
|
||||
|
||||
var withEmpty = withNull with { Message = string.Empty };
|
||||
|
||||
// null-message snapshot (normalised to "") vs empty-message node (normalised to "") ⇒ no delta.
|
||||
OtOpcUaNodeManager.ShouldFireConditionEvent(withNull, withEmpty).ShouldBeFalse();
|
||||
OtOpcUaNodeManager.ShouldFireConditionEvent(withEmpty, withNull).ShouldBeFalse();
|
||||
|
||||
// A non-empty message IS a delta vs the empty baseline.
|
||||
OtOpcUaNodeManager.ShouldFireConditionEvent(withNull, withNull with { Message = "pressure high" }).ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>Builds a test <see cref="AlarmConditionSnapshot"/> with sensible defaults so each call
|
||||
/// site only specifies the fields it cares about.</summary>
|
||||
private static AlarmConditionSnapshot Snapshot(
|
||||
bool active = false,
|
||||
bool acknowledged = true,
|
||||
bool confirmed = true,
|
||||
bool enabled = true,
|
||||
AlarmShelvingKind shelving = AlarmShelvingKind.Unshelved,
|
||||
ushort severity = 500,
|
||||
string message = "test") =>
|
||||
new(active, acknowledged, confirmed, enabled, shelving, severity, message);
|
||||
|
||||
private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync()
|
||||
{
|
||||
var host = new OpcUaApplicationHost(
|
||||
|
||||
+242
@@ -0,0 +1,242 @@
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
||||
|
||||
/// <summary>
|
||||
/// Byte-parity tests for the scripted-alarm deployment-artifact decode path
|
||||
/// (<c>DeploymentArtifact.BuildEquipmentScriptedAlarmPlans</c>) against the live compose seam
|
||||
/// (<c>Phase7Composer.Compose</c>). Both sides derive <c>EquipmentScriptedAlarmPlan</c> from the
|
||||
/// same ScriptedAlarm + Script data via the shared <c>EquipmentScriptPaths.ExtractAlarmDependencyRefs</c>
|
||||
/// helper, so the decoded plans must equal the composer's element-wise (the record has value
|
||||
/// equality including DependencyRefs order). Mirrors the existing EquipmentVirtualTags parity
|
||||
/// style in <c>DeploymentArtifactEquipTokenTests</c>.
|
||||
/// </summary>
|
||||
public sealed class DeploymentArtifactScriptedAlarmParityTests
|
||||
{
|
||||
private static byte[] BlobOf(object snapshot) => JsonSerializer.SerializeToUtf8Bytes(snapshot);
|
||||
|
||||
/// <summary>The live composer's plan for the same scripted alarms + scripts the artifact carries
|
||||
/// must equal the artifact-decode plan, field-for-field including DependencyRefs order.</summary>
|
||||
[Fact]
|
||||
public void ParseComposition_scripted_alarms_are_byte_parity_with_composer()
|
||||
{
|
||||
// Two equipments, each with a scripted alarm: predicate reads a tag via ctx.GetTag("X.Y"),
|
||||
// and the message template carries a distinct {A.B} token — so DependencyRefs exercise the
|
||||
// predicate-reads-then-template-tokens merge order on both sides.
|
||||
var script1 = new Script
|
||||
{
|
||||
ScriptId = "s-1",
|
||||
Name = "hot-1",
|
||||
SourceCode = "return System.Convert.ToDouble(ctx.GetTag(\"Mach1.Temp\").Value) > 80;",
|
||||
SourceHash = "h1",
|
||||
};
|
||||
var script2 = new Script
|
||||
{
|
||||
ScriptId = "s-2",
|
||||
Name = "hot-2",
|
||||
SourceCode = "return System.Convert.ToDouble(ctx.GetTag(\"Mach2.Temp\").Value) > 80;",
|
||||
SourceHash = "h2",
|
||||
};
|
||||
var alarm1 = new ScriptedAlarm
|
||||
{
|
||||
ScriptedAlarmId = "al-1",
|
||||
EquipmentId = "eq-1",
|
||||
Name = "Overheat-1",
|
||||
AlarmType = "LimitAlarm",
|
||||
Severity = 700,
|
||||
MessageTemplate = "Machine 1 at {Mach1.Pressure}",
|
||||
PredicateScriptId = "s-1",
|
||||
HistorizeToAveva = true,
|
||||
Retain = true,
|
||||
Enabled = true,
|
||||
};
|
||||
var alarm2 = new ScriptedAlarm
|
||||
{
|
||||
ScriptedAlarmId = "al-2",
|
||||
EquipmentId = "eq-2",
|
||||
Name = "Overheat-2",
|
||||
AlarmType = "OffNormalAlarm",
|
||||
Severity = 250,
|
||||
MessageTemplate = "Machine 2 at {Mach2.Pressure}",
|
||||
PredicateScriptId = "s-2",
|
||||
HistorizeToAveva = false,
|
||||
Retain = false,
|
||||
Enabled = false,
|
||||
};
|
||||
|
||||
var composed = Phase7Composer.Compose(
|
||||
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
|
||||
Array.Empty<DriverInstance>(), new[] { alarm1, alarm2 },
|
||||
Array.Empty<Tag>(), Array.Empty<Namespace>(),
|
||||
virtualTags: Array.Empty<VirtualTag>(),
|
||||
scripts: new[] { script1, script2 });
|
||||
|
||||
// The artifact blob the write side (ConfigComposer) emits: the FULL ScriptedAlarm + Script
|
||||
// entities serialised Pascal-case off the EF entity.
|
||||
var blob = BlobOf(new
|
||||
{
|
||||
ScriptedAlarms = new[]
|
||||
{
|
||||
ToSnapshot(alarm1),
|
||||
ToSnapshot(alarm2),
|
||||
},
|
||||
Scripts = new[]
|
||||
{
|
||||
new { ScriptId = script1.ScriptId, SourceCode = script1.SourceCode },
|
||||
new { ScriptId = script2.ScriptId, SourceCode = script2.SourceCode },
|
||||
},
|
||||
});
|
||||
|
||||
var decoded = DeploymentArtifact.ParseComposition(blob);
|
||||
|
||||
// Byte-parity: element-wise value equality (record equality compares DependencyRefs order).
|
||||
decoded.EquipmentScriptedAlarms.Count.ShouldBe(2);
|
||||
decoded.EquipmentScriptedAlarms.SequenceEqual(composed.EquipmentScriptedAlarms).ShouldBeTrue();
|
||||
// And spot-check the merged DependencyRefs (predicate read first, then template token).
|
||||
var plan1 = decoded.EquipmentScriptedAlarms.Single(p => p.ScriptedAlarmId == "al-1");
|
||||
plan1.DependencyRefs.ShouldBe(new[] { "Mach1.Temp", "Mach1.Pressure" });
|
||||
plan1.Enabled.ShouldBeTrue();
|
||||
var plan2 = decoded.EquipmentScriptedAlarms.Single(p => p.ScriptedAlarmId == "al-2");
|
||||
plan2.DependencyRefs.ShouldBe(new[] { "Mach2.Temp", "Mach2.Pressure" });
|
||||
plan2.Enabled.ShouldBeFalse();
|
||||
plan2.HistorizeToAveva.ShouldBeFalse();
|
||||
plan2.Retain.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>The cluster-scoped overload keeps only the scripted alarms whose EquipmentId belongs to
|
||||
/// an in-cluster driver (mirroring how EquipmentVirtualTags + EquipmentTags are filtered).</summary>
|
||||
[Fact]
|
||||
public void ParseComposition_scoped_keeps_only_my_clusters_scripted_alarms()
|
||||
{
|
||||
var blob = BlobOf(new
|
||||
{
|
||||
Clusters = new[] { new { ClusterId = "MAIN" }, new { ClusterId = "SITE-A" } },
|
||||
Nodes = new[]
|
||||
{
|
||||
new { NodeId = "central-1:4053", ClusterId = "MAIN" },
|
||||
new { NodeId = "site-a-1:4053", ClusterId = "SITE-A" },
|
||||
},
|
||||
DriverInstances = new[]
|
||||
{
|
||||
new { DriverInstanceId = "main-modbus", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "MAIN", NamespaceId = "main-ns" },
|
||||
new { DriverInstanceId = "sa-modbus", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "SITE-A", NamespaceId = "sa-ns" },
|
||||
},
|
||||
Equipment = new[]
|
||||
{
|
||||
new { EquipmentId = "eq-main", Name = "eqm", UnsLineId = "l1", DriverInstanceId = "main-modbus" },
|
||||
new { EquipmentId = "eq-sa", Name = "eqs", UnsLineId = "l2", DriverInstanceId = "sa-modbus" },
|
||||
},
|
||||
Scripts = new[]
|
||||
{
|
||||
new { ScriptId = "scr", SourceCode = "return ctx.GetTag(\"A.X\").Value;" },
|
||||
},
|
||||
ScriptedAlarms = new[]
|
||||
{
|
||||
NewAlarmSnapshot("al-main", "eq-main", "scr"),
|
||||
NewAlarmSnapshot("al-sa", "eq-sa", "scr"),
|
||||
},
|
||||
});
|
||||
|
||||
var main = DeploymentArtifact.ParseComposition(blob, "central-1:4053");
|
||||
main.EquipmentScriptedAlarms.Select(a => a.ScriptedAlarmId).ShouldBe(new[] { "al-main" });
|
||||
|
||||
var siteA = DeploymentArtifact.ParseComposition(blob, "site-a-1:4053");
|
||||
siteA.EquipmentScriptedAlarms.Select(a => a.ScriptedAlarmId).ShouldBe(new[] { "al-sa" });
|
||||
}
|
||||
|
||||
/// <summary>An alarm whose PredicateScriptId matches no Script row is SKIPPED on BOTH the composer
|
||||
/// and the artifact-decode side, so parity holds for the surviving alarm.</summary>
|
||||
[Fact]
|
||||
public void ParseComposition_skips_alarm_with_missing_predicate_script_matching_composer()
|
||||
{
|
||||
var goodScript = new Script
|
||||
{
|
||||
ScriptId = "s-ok",
|
||||
Name = "ok",
|
||||
SourceCode = "return ctx.GetTag(\"A.X\").Value;",
|
||||
SourceHash = "h1",
|
||||
};
|
||||
var goodAlarm = new ScriptedAlarm
|
||||
{
|
||||
ScriptedAlarmId = "al-ok",
|
||||
EquipmentId = "eq-1",
|
||||
Name = "Ok",
|
||||
AlarmType = "AlarmCondition",
|
||||
Severity = 500,
|
||||
MessageTemplate = "ok",
|
||||
PredicateScriptId = "s-ok",
|
||||
HistorizeToAveva = true,
|
||||
Retain = true,
|
||||
Enabled = true,
|
||||
};
|
||||
var orphanAlarm = new ScriptedAlarm
|
||||
{
|
||||
ScriptedAlarmId = "al-orphan",
|
||||
EquipmentId = "eq-2",
|
||||
Name = "Orphan",
|
||||
AlarmType = "AlarmCondition",
|
||||
Severity = 500,
|
||||
MessageTemplate = "orphan",
|
||||
PredicateScriptId = "s-does-not-exist",
|
||||
HistorizeToAveva = true,
|
||||
Retain = true,
|
||||
Enabled = true,
|
||||
};
|
||||
|
||||
var composed = Phase7Composer.Compose(
|
||||
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
|
||||
Array.Empty<DriverInstance>(), new[] { goodAlarm, orphanAlarm },
|
||||
Array.Empty<Tag>(), Array.Empty<Namespace>(),
|
||||
scripts: new[] { goodScript });
|
||||
|
||||
var blob = BlobOf(new
|
||||
{
|
||||
ScriptedAlarms = new[] { ToSnapshot(goodAlarm), ToSnapshot(orphanAlarm) },
|
||||
Scripts = new[] { new { ScriptId = goodScript.ScriptId, SourceCode = goodScript.SourceCode } },
|
||||
});
|
||||
|
||||
var decoded = DeploymentArtifact.ParseComposition(blob);
|
||||
|
||||
// The orphan is dropped on both sides; the survivor's plan is byte-identical.
|
||||
var composedPlan = composed.EquipmentScriptedAlarms.ShouldHaveSingleItem();
|
||||
composedPlan.ScriptedAlarmId.ShouldBe("al-ok");
|
||||
var decodedPlan = decoded.EquipmentScriptedAlarms.ShouldHaveSingleItem();
|
||||
decodedPlan.ScriptedAlarmId.ShouldBe("al-ok");
|
||||
decodedPlan.ShouldBe(composedPlan);
|
||||
}
|
||||
|
||||
// The full Pascal-case snapshot a ScriptedAlarm EF entity serialises to (matches ConfigComposer).
|
||||
private static object ToSnapshot(ScriptedAlarm a) => new
|
||||
{
|
||||
a.ScriptedAlarmId,
|
||||
a.EquipmentId,
|
||||
a.Name,
|
||||
a.AlarmType,
|
||||
a.Severity,
|
||||
a.MessageTemplate,
|
||||
a.PredicateScriptId,
|
||||
a.HistorizeToAveva,
|
||||
a.Retain,
|
||||
a.Enabled,
|
||||
};
|
||||
|
||||
private static object NewAlarmSnapshot(string id, string equipmentId, string scriptId) => new
|
||||
{
|
||||
ScriptedAlarmId = id,
|
||||
EquipmentId = equipmentId,
|
||||
Name = id,
|
||||
AlarmType = "AlarmCondition",
|
||||
Severity = 500,
|
||||
MessageTemplate = $"{id} alarm",
|
||||
PredicateScriptId = scriptId,
|
||||
HistorizeToAveva = true,
|
||||
Retain = true,
|
||||
Enabled = true,
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Text.Json;
|
||||
using Akka.Actor;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Shouldly;
|
||||
@@ -7,6 +8,7 @@ using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
||||
@@ -126,6 +128,126 @@ public sealed class DriverHostActorTests : RuntimeActorTestBase
|
||||
.Status.ShouldBe(NodeDeploymentStatus.Applied);
|
||||
}
|
||||
|
||||
/// <summary>Fresh apply: dispatching a deployment whose artifact carries one Equipment
|
||||
/// ScriptedAlarm forwards a <see cref="ScriptedAlarmHostActor.ApplyScriptedAlarms"/> carrying that
|
||||
/// plan to the injected ScriptedAlarm host (via the <c>scriptedAlarmHostOverride</c> seam, mirroring
|
||||
/// the VirtualTag-host wiring).</summary>
|
||||
[Fact]
|
||||
public void Apply_forwards_EquipmentScriptedAlarms_to_scripted_alarm_host()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var deploymentId = SeedDeploymentWithScriptedAlarm(db, RevA);
|
||||
|
||||
var coordinator = CreateTestProbe();
|
||||
var alarmHost = CreateTestProbe();
|
||||
var actor = Sys.ActorOf(DriverHostActor.Props(
|
||||
db, TestNode, coordinator.Ref,
|
||||
localRoles: new HashSet<string> { "driver" },
|
||||
scriptedAlarmHostOverride: alarmHost.Ref));
|
||||
|
||||
actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
|
||||
|
||||
coordinator.ExpectMsg<ApplyAck>(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied);
|
||||
|
||||
var apply = alarmHost.ExpectMsg<ScriptedAlarmHostActor.ApplyScriptedAlarms>(TimeSpan.FromSeconds(5));
|
||||
var plan = apply.Plans.ShouldHaveSingleItem();
|
||||
plan.ScriptedAlarmId.ShouldBe("al-1");
|
||||
plan.EquipmentId.ShouldBe("eq-1");
|
||||
plan.Name.ShouldBe("Overheat");
|
||||
plan.PredicateSource.ShouldContain("ctx.GetTag");
|
||||
}
|
||||
|
||||
/// <summary>Bootstrap-restore: a node that already has an <c>Applied</c> NodeDeploymentState
|
||||
/// row for a ScriptedAlarm-carrying deployment re-forwards the
|
||||
/// <see cref="ScriptedAlarmHostActor.ApplyScriptedAlarms"/> on PreStart (no dispatch needed), so a
|
||||
/// restarted node restores its live ScriptedAlarm children.</summary>
|
||||
[Fact]
|
||||
public void Restore_on_bootstrap_forwards_EquipmentScriptedAlarms_to_scripted_alarm_host()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var deploymentId = SeedDeploymentWithScriptedAlarm(db, RevA);
|
||||
|
||||
// Seed an Applied NodeDeploymentState row so Bootstrap() detects the Applied branch and
|
||||
// calls RestoreApplied — no DispatchDeployment needed.
|
||||
using (var ctx = db.CreateDbContext())
|
||||
{
|
||||
ctx.NodeDeploymentStates.Add(new Configuration.Entities.NodeDeploymentState
|
||||
{
|
||||
NodeId = TestNode.Value,
|
||||
DeploymentId = deploymentId.Value,
|
||||
Status = NodeDeploymentStatus.Applied,
|
||||
StartedAtUtc = DateTime.UtcNow,
|
||||
AppliedAtUtc = DateTime.UtcNow,
|
||||
});
|
||||
ctx.SaveChanges();
|
||||
}
|
||||
|
||||
var coordinator = CreateTestProbe();
|
||||
var alarmHost = CreateTestProbe();
|
||||
// No DispatchDeployment — Bootstrap() should detect the Applied row and run RestoreApplied,
|
||||
// which routes through PushDesiredSubscriptions and forwards ApplyScriptedAlarms.
|
||||
Sys.ActorOf(DriverHostActor.Props(
|
||||
db, TestNode, coordinator.Ref,
|
||||
localRoles: new HashSet<string> { "driver" },
|
||||
scriptedAlarmHostOverride: alarmHost.Ref));
|
||||
|
||||
var apply = alarmHost.ExpectMsg<ScriptedAlarmHostActor.ApplyScriptedAlarms>(TimeSpan.FromSeconds(5));
|
||||
var plan = apply.Plans.ShouldHaveSingleItem();
|
||||
plan.ScriptedAlarmId.ShouldBe("al-1");
|
||||
plan.EquipmentId.ShouldBe("eq-1");
|
||||
plan.Name.ShouldBe("Overheat");
|
||||
plan.PredicateSource.ShouldContain("ctx.GetTag");
|
||||
}
|
||||
|
||||
private static DeploymentId SeedDeploymentWithScriptedAlarm(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev)
|
||||
{
|
||||
// Artifact carries a ScriptedAlarm joined (by PredicateScriptId) to its predicate Script —
|
||||
// the same shape ConfigComposer emits and DeploymentArtifact.ParseComposition decodes into
|
||||
// composition.EquipmentScriptedAlarms.
|
||||
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
|
||||
{
|
||||
Scripts = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
ScriptId = "scr-1",
|
||||
SourceCode = "return System.Convert.ToDouble(ctx.GetTag(\"Mach1.Temp\").Value) > 80;",
|
||||
},
|
||||
},
|
||||
ScriptedAlarms = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
ScriptedAlarmId = "al-1",
|
||||
EquipmentId = "eq-1",
|
||||
Name = "Overheat",
|
||||
AlarmType = "LimitAlarm",
|
||||
Severity = 700,
|
||||
MessageTemplate = "Machine 1 hot",
|
||||
PredicateScriptId = "scr-1",
|
||||
HistorizeToAveva = true,
|
||||
Retain = true,
|
||||
Enabled = true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
var id = DeploymentId.NewId();
|
||||
using var ctx = db.CreateDbContext();
|
||||
ctx.Deployments.Add(new Configuration.Entities.Deployment
|
||||
{
|
||||
DeploymentId = id.Value,
|
||||
RevisionHash = rev.Value,
|
||||
Status = DeploymentStatus.Sealed,
|
||||
CreatedBy = "test",
|
||||
SealedAtUtc = DateTime.UtcNow,
|
||||
ArtifactBlob = artifact,
|
||||
});
|
||||
ctx.SaveChanges();
|
||||
return id;
|
||||
}
|
||||
|
||||
private static DeploymentId SeedDeployment(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> db,
|
||||
RevisionHash rev,
|
||||
|
||||
+10
-4
@@ -195,12 +195,18 @@ public sealed class OtOpcUaTelemetryHookTests : RuntimeActorTestBase
|
||||
/// <param name="quality">The OPC UA quality status.</param>
|
||||
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) => Writes++;
|
||||
/// <summary>Records an alarm state write.</summary>
|
||||
/// <summary>Records an alarm condition write.</summary>
|
||||
/// <param name="alarmNodeId">The alarm node identifier.</param>
|
||||
/// <param name="active">Whether the alarm is active.</param>
|
||||
/// <param name="acknowledged">Whether the alarm is acknowledged.</param>
|
||||
/// <param name="state">The full condition state snapshot.</param>
|
||||
/// <param name="occurredUtc">The time the alarm occurred in UTC.</param>
|
||||
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime occurredUtc) => Writes++;
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc) => Writes++;
|
||||
/// <summary>Materialises an alarm condition (stub implementation).</summary>
|
||||
/// <param name="alarmNodeId">The alarm node identifier.</param>
|
||||
/// <param name="equipmentNodeId">The equipment folder node identifier.</param>
|
||||
/// <param name="displayName">The condition display name.</param>
|
||||
/// <param name="alarmType">The domain alarm type.</param>
|
||||
/// <param name="severity">The domain severity.</param>
|
||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity) { }
|
||||
/// <summary>Ensures folder exists (stub implementation).</summary>
|
||||
/// <param name="folderNodeId">The folder node identifier.</param>
|
||||
/// <param name="parentNodeId">The parent folder node identifier.</param>
|
||||
|
||||
+11
-4
@@ -246,13 +246,20 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
/// <param name="ts">The timestamp of the write.</param>
|
||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime ts)
|
||||
=> Calls.Enqueue($"WV:{nodeId}");
|
||||
/// <summary>Records an alarm state write call.</summary>
|
||||
/// <summary>Records an alarm condition write call.</summary>
|
||||
/// <param name="alarmNodeId">The alarm node ID.</param>
|
||||
/// <param name="active">Whether the alarm is active.</param>
|
||||
/// <param name="acknowledged">Whether the alarm is acknowledged.</param>
|
||||
/// <param name="state">The full condition state snapshot.</param>
|
||||
/// <param name="ts">The timestamp of the state change.</param>
|
||||
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime ts)
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime ts)
|
||||
=> Calls.Enqueue($"WA:{alarmNodeId}");
|
||||
/// <summary>Records a materialise-alarm-condition call.</summary>
|
||||
/// <param name="alarmNodeId">The alarm node ID (== ScriptedAlarmId).</param>
|
||||
/// <param name="equipmentNodeId">The equipment folder node ID.</param>
|
||||
/// <param name="displayName">The condition display name.</param>
|
||||
/// <param name="alarmType">The domain alarm type.</param>
|
||||
/// <param name="severity">The domain severity.</param>
|
||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity)
|
||||
=> Calls.Enqueue($"MA:{alarmNodeId}");
|
||||
/// <summary>Records a folder ensure call.</summary>
|
||||
/// <param name="folderNodeId">The folder node ID.</param>
|
||||
/// <param name="parentNodeId">The parent node ID, or null if this is a root folder.</param>
|
||||
|
||||
@@ -18,7 +18,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
|
||||
{
|
||||
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests());
|
||||
actor.Tell(new OpcUaPublishActor.AttributeValueUpdate("ns=2;s=Tag1", 42.0, OpcUaQuality.Good, DateTime.UtcNow));
|
||||
actor.Tell(new OpcUaPublishActor.AlarmStateUpdate("ns=2;s=Alarm1", true, false, DateTime.UtcNow));
|
||||
actor.Tell(new OpcUaPublishActor.AlarmStateUpdate("ns=2;s=Alarm1", Snapshot(active: true), DateTime.UtcNow));
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
|
||||
actor.Tell(new OpcUaPublishActor.ServiceLevelChanged(240));
|
||||
|
||||
@@ -53,24 +53,38 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
|
||||
}, duration: TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that AlarmStateUpdate routes to sink WriteAlarmState.</summary>
|
||||
/// <summary>Verifies that AlarmStateUpdate routes to sink WriteAlarmCondition with the full snapshot.</summary>
|
||||
[Fact]
|
||||
public void AlarmStateUpdate_routes_to_sink_WriteAlarmState()
|
||||
public void AlarmStateUpdate_routes_to_sink_WriteAlarmCondition()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink));
|
||||
|
||||
actor.Tell(new OpcUaPublishActor.AlarmStateUpdate("ns=2;s=A1", Active: true, Acknowledged: false, DateTime.UtcNow));
|
||||
actor.Tell(new OpcUaPublishActor.AlarmStateUpdate(
|
||||
"ns=2;s=A1", Snapshot(active: true, acknowledged: false, severity: 700), DateTime.UtcNow));
|
||||
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
sink.Alarms.Count.ShouldBe(1);
|
||||
sink.Alarms[0].AlarmNodeId.ShouldBe("ns=2;s=A1");
|
||||
sink.Alarms[0].Active.ShouldBeTrue();
|
||||
sink.Alarms[0].Acknowledged.ShouldBeFalse();
|
||||
sink.Alarms[0].State.Active.ShouldBeTrue();
|
||||
sink.Alarms[0].State.Acknowledged.ShouldBeFalse();
|
||||
sink.Alarms[0].State.Severity.ShouldBe((ushort)700);
|
||||
}, duration: TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
/// <summary>Builds a test <see cref="AlarmConditionSnapshot"/> with sensible defaults so each test
|
||||
/// only specifies the fields it cares about.</summary>
|
||||
private static AlarmConditionSnapshot Snapshot(
|
||||
bool active = false,
|
||||
bool acknowledged = true,
|
||||
bool confirmed = true,
|
||||
bool enabled = true,
|
||||
AlarmShelvingKind shelving = AlarmShelvingKind.Unshelved,
|
||||
ushort severity = 500,
|
||||
string message = "test") =>
|
||||
new(active, acknowledged, confirmed, enabled, shelving, severity, message);
|
||||
|
||||
/// <summary>Verifies that RebuildAddressSpace calls sink Rebuild.</summary>
|
||||
[Fact]
|
||||
public void RebuildAddressSpace_calls_sink_Rebuild()
|
||||
@@ -148,16 +162,16 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
|
||||
{
|
||||
/// <summary>Gets the queue of recorded value updates.</summary>
|
||||
public ConcurrentQueue<(string NodeId, object? Value, OpcUaQuality Quality, DateTime Ts)> ValueQueue { get; } = new();
|
||||
/// <summary>Gets the queue of recorded alarm state updates.</summary>
|
||||
public ConcurrentQueue<(string AlarmNodeId, bool Active, bool Acknowledged, DateTime Ts)> AlarmQueue { get; } = new();
|
||||
/// <summary>Gets the queue of recorded alarm condition updates.</summary>
|
||||
public ConcurrentQueue<(string AlarmNodeId, AlarmConditionSnapshot State, DateTime Ts)> AlarmQueue { get; } = new();
|
||||
/// <summary>Count of rebuild calls.</summary>
|
||||
public int RebuildCalls;
|
||||
|
||||
/// <summary>Gets the list of recorded value updates.</summary>
|
||||
public List<(string NodeId, object? Value, OpcUaQuality Quality, DateTime Ts)> Values =>
|
||||
ValueQueue.ToList();
|
||||
/// <summary>Gets the list of recorded alarm state updates.</summary>
|
||||
public List<(string AlarmNodeId, bool Active, bool Acknowledged, DateTime Ts)> Alarms =>
|
||||
/// <summary>Gets the list of recorded alarm condition updates.</summary>
|
||||
public List<(string AlarmNodeId, AlarmConditionSnapshot State, DateTime Ts)> Alarms =>
|
||||
AlarmQueue.ToList();
|
||||
|
||||
/// <summary>Records a value update.</summary>
|
||||
@@ -168,13 +182,20 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
|
||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime ts) =>
|
||||
ValueQueue.Enqueue((nodeId, value, quality, ts));
|
||||
|
||||
/// <summary>Records an alarm state update.</summary>
|
||||
/// <summary>Records an alarm condition update.</summary>
|
||||
/// <param name="alarmNodeId">The OPC UA alarm node identifier.</param>
|
||||
/// <param name="active">Whether the alarm is active.</param>
|
||||
/// <param name="acknowledged">Whether the alarm is acknowledged.</param>
|
||||
/// <param name="state">The full condition state snapshot.</param>
|
||||
/// <param name="ts">The timestamp of the update.</param>
|
||||
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime ts) =>
|
||||
AlarmQueue.Enqueue((alarmNodeId, active, acknowledged, ts));
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime ts) =>
|
||||
AlarmQueue.Enqueue((alarmNodeId, state, ts));
|
||||
|
||||
/// <summary>Materialises an alarm condition (no-op in test).</summary>
|
||||
/// <param name="alarmNodeId">The alarm node ID.</param>
|
||||
/// <param name="equipmentNodeId">The equipment folder node ID.</param>
|
||||
/// <param name="displayName">The condition display name.</param>
|
||||
/// <param name="alarmType">The domain alarm type.</param>
|
||||
/// <param name="severity">The domain severity.</param>
|
||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity) { }
|
||||
|
||||
/// <summary>Ensures a folder exists (no-op in test).</summary>
|
||||
/// <param name="folderNodeId">The OPC UA folder node identifier.</param>
|
||||
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.ScriptedAlarms;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="DependencyMuxTagUpstreamSource"/> — the host-actor-fed
|
||||
/// <c>ITagUpstreamSource</c> the scripted-alarm engine reads + subscribes through.
|
||||
/// </summary>
|
||||
public sealed class DependencyMuxTagUpstreamSourceTests
|
||||
{
|
||||
private static DataValueSnapshot Good(object? value, DateTime ts)
|
||||
=> new(value, 0u, ts, ts);
|
||||
|
||||
[Fact]
|
||||
public void Push_then_ReadTag_returns_the_pushed_snapshot()
|
||||
{
|
||||
var src = new DependencyMuxTagUpstreamSource();
|
||||
var now = new DateTime(2026, 06, 10, 12, 0, 0, DateTimeKind.Utc);
|
||||
var snap = Good(42, now);
|
||||
|
||||
src.Push("a/b/c", snap);
|
||||
|
||||
src.ReadTag("a/b/c").ShouldBeSameAs(snap);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadTag_of_unknown_path_returns_a_Bad_quality_snapshot()
|
||||
{
|
||||
var fixedNow = new DateTime(2026, 06, 10, 9, 30, 0, DateTimeKind.Utc);
|
||||
var src = new DependencyMuxTagUpstreamSource(clock: () => fixedNow);
|
||||
|
||||
var snap = src.ReadTag("never/pushed");
|
||||
|
||||
snap.Value.ShouldBeNull();
|
||||
// Bad: OPC UA Part 4 StatusCode bit 31 set (severity 10).
|
||||
(snap.StatusCode & 0x80000000u).ShouldNotBe(0u);
|
||||
snap.StatusCode.ShouldNotBe(0u);
|
||||
// Timestamp comes from the injected clock.
|
||||
snap.ServerTimestampUtc.ShouldBe(fixedNow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubscribeTag_observer_fires_on_next_push_to_same_path_only()
|
||||
{
|
||||
var src = new DependencyMuxTagUpstreamSource();
|
||||
var now = new DateTime(2026, 06, 10, 12, 0, 0, DateTimeKind.Utc);
|
||||
var received = new List<(string Path, DataValueSnapshot Snap)>();
|
||||
|
||||
using var sub = src.SubscribeTag("tag/x", (p, s) => received.Add((p, s)));
|
||||
|
||||
// A push to a DIFFERENT path must NOT fire this observer.
|
||||
src.Push("tag/other", Good(1, now));
|
||||
received.ShouldBeEmpty();
|
||||
|
||||
var snap = Good(7, now);
|
||||
src.Push("tag/x", snap);
|
||||
|
||||
received.Count.ShouldBe(1);
|
||||
received[0].Path.ShouldBe("tag/x");
|
||||
received[0].Snap.ShouldBeSameAs(snap);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Multiple_observers_on_same_path_all_fire()
|
||||
{
|
||||
var src = new DependencyMuxTagUpstreamSource();
|
||||
var now = new DateTime(2026, 06, 10, 12, 0, 0, DateTimeKind.Utc);
|
||||
var a = 0;
|
||||
var b = 0;
|
||||
|
||||
using var subA = src.SubscribeTag("tag/y", (_, _) => a++);
|
||||
using var subB = src.SubscribeTag("tag/y", (_, _) => b++);
|
||||
|
||||
src.Push("tag/y", Good(1, now));
|
||||
|
||||
a.ShouldBe(1);
|
||||
b.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Disposing_a_subscription_stops_delivery_to_that_observer_only()
|
||||
{
|
||||
var src = new DependencyMuxTagUpstreamSource();
|
||||
var now = new DateTime(2026, 06, 10, 12, 0, 0, DateTimeKind.Utc);
|
||||
var a = 0;
|
||||
var b = 0;
|
||||
|
||||
var subA = src.SubscribeTag("tag/z", (_, _) => a++);
|
||||
using var subB = src.SubscribeTag("tag/z", (_, _) => b++);
|
||||
|
||||
src.Push("tag/z", Good(1, now));
|
||||
a.ShouldBe(1);
|
||||
b.ShouldBe(1);
|
||||
|
||||
subA.Dispose();
|
||||
|
||||
src.Push("tag/z", Good(2, now));
|
||||
a.ShouldBe(1); // disposed — no further delivery
|
||||
b.ShouldBe(2); // still subscribed
|
||||
|
||||
// Dispose-after-already-removed must be a no-op, not throw.
|
||||
Should.NotThrow(() => subA.Dispose());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Concurrent_push_and_subscribe_does_not_throw()
|
||||
{
|
||||
var src = new DependencyMuxTagUpstreamSource();
|
||||
var now = new DateTime(2026, 06, 10, 12, 0, 0, DateTimeKind.Utc);
|
||||
var subs = new List<IDisposable>();
|
||||
var subsLock = new object();
|
||||
Exception? failure = null;
|
||||
|
||||
var pusher = Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < 2000; i++)
|
||||
src.Push("hot/path", Good(i, now));
|
||||
}
|
||||
catch (Exception ex) { failure = ex; }
|
||||
});
|
||||
|
||||
var subscriber = Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < 2000; i++)
|
||||
{
|
||||
var d = src.SubscribeTag("hot/path", (_, _) => { });
|
||||
lock (subsLock) subs.Add(d);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { failure = ex; }
|
||||
});
|
||||
|
||||
await Task.WhenAll(pusher, subscriber);
|
||||
|
||||
failure.ShouldBeNull();
|
||||
foreach (var d in subs) d.Dispose();
|
||||
}
|
||||
}
|
||||
+244
@@ -0,0 +1,244 @@
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.ScriptedAlarms;
|
||||
|
||||
/// <summary>
|
||||
/// Round-trip + upsert + lifecycle coverage for <see cref="EfAlarmConditionStateStore"/>,
|
||||
/// the EF-backed <see cref="IAlarmStateStore"/> over the <c>ScriptedAlarmState</c> table.
|
||||
/// </summary>
|
||||
public sealed class EfAlarmConditionStateStoreTests : RuntimeActorTestBase
|
||||
{
|
||||
private static EfAlarmConditionStateStore NewStore(out Microsoft.EntityFrameworkCore.IDbContextFactory<ZB.MOM.WW.OtOpcUa.Configuration.OtOpcUaConfigDbContext> db)
|
||||
{
|
||||
db = NewInMemoryDbFactory();
|
||||
return new EfAlarmConditionStateStore(db, NullLogger<EfAlarmConditionStateStore>.Instance);
|
||||
}
|
||||
|
||||
private static AlarmConditionState Sample(
|
||||
string alarmId,
|
||||
AlarmActiveState active = AlarmActiveState.Inactive,
|
||||
ShelvingState? shelving = null,
|
||||
ImmutableList<AlarmComment>? comments = null)
|
||||
{
|
||||
var t = new DateTime(2026, 06, 10, 12, 00, 00, DateTimeKind.Utc);
|
||||
return new AlarmConditionState(
|
||||
AlarmId: alarmId,
|
||||
Enabled: AlarmEnabledState.Disabled,
|
||||
Active: active,
|
||||
Acked: AlarmAckedState.Unacknowledged,
|
||||
Confirmed: AlarmConfirmedState.Unconfirmed,
|
||||
Shelving: shelving ?? new ShelvingState(ShelvingKind.Timed, t.AddMinutes(30)),
|
||||
LastTransitionUtc: t,
|
||||
LastActiveUtc: t.AddMinutes(-5),
|
||||
LastClearedUtc: t.AddMinutes(-1),
|
||||
LastAckUtc: t.AddMinutes(-2),
|
||||
LastAckUser: "jane",
|
||||
LastAckComment: "ack-comment",
|
||||
LastConfirmUtc: t.AddMinutes(-3),
|
||||
LastConfirmUser: "bob",
|
||||
LastConfirmComment: "confirm-comment",
|
||||
Comments: comments ?? ImmutableList<AlarmComment>.Empty);
|
||||
}
|
||||
|
||||
/// <summary>Save then load round-trips every persisted operator-state + audit field.</summary>
|
||||
[Fact]
|
||||
public async Task SaveAsync_then_LoadAsync_round_trips_persisted_fields()
|
||||
{
|
||||
var store = NewStore(out _);
|
||||
var state = Sample("alarm-1");
|
||||
|
||||
await store.SaveAsync(state, CancellationToken.None);
|
||||
var loaded = await store.LoadAsync("alarm-1", CancellationToken.None);
|
||||
|
||||
loaded.ShouldNotBeNull();
|
||||
loaded.AlarmId.ShouldBe("alarm-1");
|
||||
loaded.Enabled.ShouldBe(AlarmEnabledState.Disabled);
|
||||
loaded.Acked.ShouldBe(AlarmAckedState.Unacknowledged);
|
||||
loaded.Confirmed.ShouldBe(AlarmConfirmedState.Unconfirmed);
|
||||
loaded.Shelving.Kind.ShouldBe(ShelvingKind.Timed);
|
||||
loaded.Shelving.UnshelveAtUtc.ShouldBe(state.Shelving.UnshelveAtUtc);
|
||||
loaded.LastTransitionUtc.ShouldBe(state.LastTransitionUtc);
|
||||
loaded.LastAckUtc.ShouldBe(state.LastAckUtc);
|
||||
loaded.LastAckUser.ShouldBe("jane");
|
||||
loaded.LastAckComment.ShouldBe("ack-comment");
|
||||
loaded.LastConfirmUtc.ShouldBe(state.LastConfirmUtc);
|
||||
loaded.LastConfirmUser.ShouldBe("bob");
|
||||
loaded.LastConfirmComment.ShouldBe("confirm-comment");
|
||||
}
|
||||
|
||||
/// <summary>Loading an id that was never saved returns null.</summary>
|
||||
[Fact]
|
||||
public async Task LoadAsync_for_unknown_id_returns_null()
|
||||
{
|
||||
var store = NewStore(out _);
|
||||
|
||||
var loaded = await store.LoadAsync("never-saved", CancellationToken.None);
|
||||
|
||||
loaded.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Active is not persisted — a saved Active state loads back as Inactive.</summary>
|
||||
[Fact]
|
||||
public async Task Active_is_ignored_on_load_and_defaults_to_Inactive()
|
||||
{
|
||||
var store = NewStore(out _);
|
||||
await store.SaveAsync(Sample("alarm-active", active: AlarmActiveState.Active), CancellationToken.None);
|
||||
|
||||
var loaded = await store.LoadAsync("alarm-active", CancellationToken.None);
|
||||
|
||||
loaded.ShouldNotBeNull();
|
||||
loaded.Active.ShouldBe(AlarmActiveState.Inactive);
|
||||
}
|
||||
|
||||
/// <summary>LastActiveUtc / LastClearedUtc have no columns and default to null on load.</summary>
|
||||
[Fact]
|
||||
public async Task Derived_timestamps_default_to_null_on_load()
|
||||
{
|
||||
var store = NewStore(out _);
|
||||
await store.SaveAsync(Sample("alarm-derived"), CancellationToken.None);
|
||||
|
||||
var loaded = await store.LoadAsync("alarm-derived", CancellationToken.None);
|
||||
|
||||
loaded.ShouldNotBeNull();
|
||||
loaded.LastActiveUtc.ShouldBeNull();
|
||||
loaded.LastClearedUtc.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Unshelved round-trips with a null UnshelveAtUtc.</summary>
|
||||
[Fact]
|
||||
public async Task Unshelved_round_trips()
|
||||
{
|
||||
var store = NewStore(out _);
|
||||
await store.SaveAsync(
|
||||
Sample("alarm-unshelved", shelving: ShelvingState.Unshelved),
|
||||
CancellationToken.None);
|
||||
|
||||
var loaded = await store.LoadAsync("alarm-unshelved", CancellationToken.None);
|
||||
|
||||
loaded.ShouldNotBeNull();
|
||||
loaded.Shelving.Kind.ShouldBe(ShelvingKind.Unshelved);
|
||||
loaded.Shelving.UnshelveAtUtc.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>OneShot shelving round-trips.</summary>
|
||||
[Fact]
|
||||
public async Task OneShot_shelving_round_trips()
|
||||
{
|
||||
var store = NewStore(out _);
|
||||
await store.SaveAsync(
|
||||
Sample("alarm-oneshot", shelving: new ShelvingState(ShelvingKind.OneShot, null)),
|
||||
CancellationToken.None);
|
||||
|
||||
var loaded = await store.LoadAsync("alarm-oneshot", CancellationToken.None);
|
||||
|
||||
loaded.ShouldNotBeNull();
|
||||
loaded.Shelving.Kind.ShouldBe(ShelvingKind.OneShot);
|
||||
loaded.Shelving.UnshelveAtUtc.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>The append-only comment trail round-trips all four fields per comment.</summary>
|
||||
[Fact]
|
||||
public async Task Comments_round_trip_all_fields()
|
||||
{
|
||||
var store = NewStore(out _);
|
||||
var c1 = new AlarmComment(
|
||||
new DateTime(2026, 06, 10, 10, 00, 00, DateTimeKind.Utc), "jane", "Acknowledge", "checking pump");
|
||||
var c2 = new AlarmComment(
|
||||
new DateTime(2026, 06, 10, 11, 30, 00, DateTimeKind.Utc), "bob", "Confirm", "all clear");
|
||||
var comments = ImmutableList.Create(c1, c2);
|
||||
|
||||
await store.SaveAsync(Sample("alarm-comments", comments: comments), CancellationToken.None);
|
||||
var loaded = await store.LoadAsync("alarm-comments", CancellationToken.None);
|
||||
|
||||
loaded.ShouldNotBeNull();
|
||||
loaded.Comments.Count.ShouldBe(2);
|
||||
loaded.Comments[0].TimestampUtc.ShouldBe(c1.TimestampUtc);
|
||||
loaded.Comments[0].User.ShouldBe("jane");
|
||||
loaded.Comments[0].Kind.ShouldBe("Acknowledge");
|
||||
loaded.Comments[0].Text.ShouldBe("checking pump");
|
||||
loaded.Comments[1].TimestampUtc.ShouldBe(c2.TimestampUtc);
|
||||
loaded.Comments[1].User.ShouldBe("bob");
|
||||
loaded.Comments[1].Kind.ShouldBe("Confirm");
|
||||
loaded.Comments[1].Text.ShouldBe("all clear");
|
||||
}
|
||||
|
||||
/// <summary>An empty comment list round-trips as an empty list.</summary>
|
||||
[Fact]
|
||||
public async Task Empty_comments_round_trip()
|
||||
{
|
||||
var store = NewStore(out _);
|
||||
await store.SaveAsync(
|
||||
Sample("alarm-empty-comments", comments: ImmutableList<AlarmComment>.Empty),
|
||||
CancellationToken.None);
|
||||
|
||||
var loaded = await store.LoadAsync("alarm-empty-comments", CancellationToken.None);
|
||||
|
||||
loaded.ShouldNotBeNull();
|
||||
loaded.Comments.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>Saving the same id twice is an upsert — one row, latest values win.</summary>
|
||||
[Fact]
|
||||
public async Task SaveAsync_twice_upserts_latest()
|
||||
{
|
||||
var store = NewStore(out var db);
|
||||
var first = Sample("alarm-upsert") with { LastAckUser = "first-user", Enabled = AlarmEnabledState.Enabled };
|
||||
var second = Sample("alarm-upsert") with { LastAckUser = "second-user", Enabled = AlarmEnabledState.Disabled };
|
||||
|
||||
await store.SaveAsync(first, CancellationToken.None);
|
||||
await store.SaveAsync(second, CancellationToken.None);
|
||||
|
||||
using (var ctx = db.CreateDbContext())
|
||||
{
|
||||
ctx.ScriptedAlarmStates.Count(r => r.ScriptedAlarmId == "alarm-upsert").ShouldBe(1);
|
||||
}
|
||||
|
||||
var loaded = await store.LoadAsync("alarm-upsert", CancellationToken.None);
|
||||
loaded.ShouldNotBeNull();
|
||||
loaded.LastAckUser.ShouldBe("second-user");
|
||||
loaded.Enabled.ShouldBe(AlarmEnabledState.Disabled);
|
||||
}
|
||||
|
||||
/// <summary>LoadAllAsync returns every saved state.</summary>
|
||||
[Fact]
|
||||
public async Task LoadAllAsync_returns_all_saved_states()
|
||||
{
|
||||
var store = NewStore(out _);
|
||||
await store.SaveAsync(Sample("a-1"), CancellationToken.None);
|
||||
await store.SaveAsync(Sample("a-2"), CancellationToken.None);
|
||||
await store.SaveAsync(Sample("a-3"), CancellationToken.None);
|
||||
|
||||
var all = await store.LoadAllAsync(CancellationToken.None);
|
||||
|
||||
all.Select(s => s.AlarmId).OrderBy(x => x).ShouldBe(new[] { "a-1", "a-2", "a-3" });
|
||||
}
|
||||
|
||||
/// <summary>RemoveAsync deletes a row so a subsequent load returns null; others survive.</summary>
|
||||
[Fact]
|
||||
public async Task RemoveAsync_deletes_one_and_LoadAsync_returns_null()
|
||||
{
|
||||
var store = NewStore(out _);
|
||||
await store.SaveAsync(Sample("keep"), CancellationToken.None);
|
||||
await store.SaveAsync(Sample("drop"), CancellationToken.None);
|
||||
|
||||
await store.RemoveAsync("drop", CancellationToken.None);
|
||||
|
||||
(await store.LoadAsync("drop", CancellationToken.None)).ShouldBeNull();
|
||||
(await store.LoadAsync("keep", CancellationToken.None)).ShouldNotBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Removing an unknown id is a no-op (does not throw).</summary>
|
||||
[Fact]
|
||||
public async Task RemoveAsync_for_unknown_id_is_noop()
|
||||
{
|
||||
var store = NewStore(out _);
|
||||
|
||||
await Should.NotThrowAsync(() => store.RemoveAsync("ghost", CancellationToken.None));
|
||||
}
|
||||
}
|
||||
-176
@@ -1,176 +0,0 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.ScriptedAlarms;
|
||||
|
||||
public sealed class ScriptedAlarmActorTests : RuntimeActorTestBase
|
||||
{
|
||||
/// <summary>Verifies that full state cycle publishes StateChanged messages to parent at each transition.</summary>
|
||||
[Fact]
|
||||
public void Full_state_cycle_publishes_StateChanged_to_parent_at_each_transition()
|
||||
{
|
||||
var parent = CreateTestProbe();
|
||||
var actor = parent.ChildActorOf(ScriptedAlarmActor.Props("alarm-1"));
|
||||
|
||||
actor.Tell(new ScriptedAlarmActor.ConditionMet("threshold"));
|
||||
var t1 = parent.ExpectMsg<ScriptedAlarmActor.StateChanged>();
|
||||
t1.State.ShouldBe(ScriptedAlarmActorState.Active);
|
||||
|
||||
actor.Tell(new ScriptedAlarmActor.AcknowledgeAlarm("joe"));
|
||||
var t2 = parent.ExpectMsg<ScriptedAlarmActor.StateChanged>();
|
||||
t2.State.ShouldBe(ScriptedAlarmActorState.Acknowledged);
|
||||
|
||||
actor.Tell(new ScriptedAlarmActor.ConditionCleared());
|
||||
var t3 = parent.ExpectMsg<ScriptedAlarmActor.StateChanged>();
|
||||
t3.State.ShouldBe(ScriptedAlarmActorState.Inactive);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that duplicate ConditionMet messages in Active state are ignored.</summary>
|
||||
[Fact]
|
||||
public void Duplicate_ConditionMet_in_Active_is_ignored()
|
||||
{
|
||||
var parent = CreateTestProbe();
|
||||
var actor = parent.ChildActorOf(ScriptedAlarmActor.Props("alarm-1"));
|
||||
|
||||
actor.Tell(new ScriptedAlarmActor.ConditionMet("first"));
|
||||
parent.ExpectMsg<ScriptedAlarmActor.StateChanged>();
|
||||
|
||||
actor.Tell(new ScriptedAlarmActor.ConditionMet("second"));
|
||||
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that active transition publishes AlarmTransitionEvent to the alerts topic.</summary>
|
||||
[Fact]
|
||||
public void Engine_active_transition_publishes_AlarmTransitionEvent_to_alerts_topic()
|
||||
{
|
||||
var capture = new CapturingPublisher();
|
||||
var parent = CreateTestProbe();
|
||||
var config = new ScriptedAlarmActor.AlarmConfig(
|
||||
AlarmId: "alarm-7",
|
||||
AlarmName: "High Temp",
|
||||
EquipmentPath: "/site-1/line-A/oven",
|
||||
Severity: 800,
|
||||
Predicate: "temp > 80");
|
||||
var actor = parent.ChildActorOf(ScriptedAlarmActor.Props(
|
||||
config,
|
||||
evaluator: new ThresholdEvaluator(80),
|
||||
publisherFactory: () => new DPSPublisher(capture.Publish)));
|
||||
|
||||
actor.Tell(new ScriptedAlarmActor.DependencyValueChanged("temp", 92, DateTime.UtcNow));
|
||||
parent.ExpectMsg<ScriptedAlarmActor.StateChanged>().State.ShouldBe(ScriptedAlarmActorState.Active);
|
||||
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
var transitionEvt = capture.Payloads.OfType<AlarmTransitionEvent>().SingleOrDefault();
|
||||
transitionEvt.ShouldNotBeNull();
|
||||
transitionEvt.AlarmId.ShouldBe("alarm-7");
|
||||
transitionEvt.AlarmName.ShouldBe("High Temp");
|
||||
transitionEvt.EquipmentPath.ShouldBe("/site-1/line-A/oven");
|
||||
transitionEvt.Severity.ShouldBe(800);
|
||||
transitionEvt.TransitionKind.ShouldBe("Activated");
|
||||
transitionEvt.User.ShouldBe("system");
|
||||
|
||||
var log = capture.Payloads.OfType<ScriptLogEntry>().SingleOrDefault();
|
||||
log.ShouldNotBeNull();
|
||||
log.AlarmId.ShouldBe("alarm-7");
|
||||
}, duration: TimeSpan.FromSeconds(1));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that clear transition publishes Cleared event.</summary>
|
||||
[Fact]
|
||||
public void Engine_clear_transition_publishes_Cleared_event()
|
||||
{
|
||||
var capture = new CapturingPublisher();
|
||||
var parent = CreateTestProbe();
|
||||
var config = new ScriptedAlarmActor.AlarmConfig("alarm-7", "High Temp", "/p", 500, "temp > 80");
|
||||
var evaluator = new ThresholdEvaluator(80);
|
||||
var actor = parent.ChildActorOf(ScriptedAlarmActor.Props(
|
||||
config, evaluator,
|
||||
publisherFactory: () => new DPSPublisher(capture.Publish)));
|
||||
|
||||
actor.Tell(new ScriptedAlarmActor.DependencyValueChanged("temp", 92, DateTime.UtcNow));
|
||||
parent.ExpectMsg<ScriptedAlarmActor.StateChanged>();
|
||||
|
||||
actor.Tell(new ScriptedAlarmActor.DependencyValueChanged("temp", 70, DateTime.UtcNow));
|
||||
parent.ExpectMsg<ScriptedAlarmActor.StateChanged>().State.ShouldBe(ScriptedAlarmActorState.Inactive);
|
||||
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
var kinds = capture.Payloads.OfType<AlarmTransitionEvent>().Select(e => e.TransitionKind).ToList();
|
||||
kinds.ShouldContain("Activated");
|
||||
kinds.ShouldContain("Cleared");
|
||||
}, duration: TimeSpan.FromSeconds(1));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that manual acknowledge emits Acknowledged transition with the user.</summary>
|
||||
[Fact]
|
||||
public void Manual_acknowledge_emits_Acknowledged_transition_with_user()
|
||||
{
|
||||
var capture = new CapturingPublisher();
|
||||
var parent = CreateTestProbe();
|
||||
var config = new ScriptedAlarmActor.AlarmConfig("a-1", "Pump Fail", "/eq", 700, Predicate: null);
|
||||
var actor = parent.ChildActorOf(ScriptedAlarmActor.Props(
|
||||
config, evaluator: null,
|
||||
publisherFactory: () => new DPSPublisher(capture.Publish)));
|
||||
|
||||
actor.Tell(new ScriptedAlarmActor.ConditionMet("driver-fault"));
|
||||
parent.ExpectMsg<ScriptedAlarmActor.StateChanged>();
|
||||
|
||||
actor.Tell(new ScriptedAlarmActor.AcknowledgeAlarm("operator-jane"));
|
||||
parent.ExpectMsg<ScriptedAlarmActor.StateChanged>().State.ShouldBe(ScriptedAlarmActorState.Acknowledged);
|
||||
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
var ackEvt = capture.Payloads.OfType<AlarmTransitionEvent>()
|
||||
.SingleOrDefault(e => e.TransitionKind == "Acknowledged");
|
||||
ackEvt.ShouldNotBeNull();
|
||||
ackEvt.User.ShouldBe("operator-jane");
|
||||
}, duration: TimeSpan.FromSeconds(1));
|
||||
}
|
||||
|
||||
/// <summary>A threshold-based alarm evaluator for testing.</summary>
|
||||
private sealed class ThresholdEvaluator : IScriptedAlarmEvaluator
|
||||
{
|
||||
private readonly double _threshold;
|
||||
|
||||
/// <summary>Initializes a new instance of the ThresholdEvaluator class.</summary>
|
||||
/// <param name="threshold">The threshold value to compare against.</param>
|
||||
public ThresholdEvaluator(double threshold) { _threshold = threshold; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public ScriptedAlarmEvalResult Evaluate(string id, string predicate, IReadOnlyDictionary<string, object?> deps)
|
||||
{
|
||||
if (!deps.TryGetValue("temp", out var raw) || raw is null)
|
||||
return ScriptedAlarmEvalResult.Failure("missing temp");
|
||||
return ScriptedAlarmEvalResult.Ok(Convert.ToDouble(raw) > _threshold);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A test publisher that captures published messages.</summary>
|
||||
private sealed class CapturingPublisher
|
||||
{
|
||||
/// <summary>Gets the topics that messages were published to.</summary>
|
||||
public ConcurrentBag<string> Topics { get; } = new();
|
||||
|
||||
/// <summary>Gets the payloads that were published.</summary>
|
||||
public ConcurrentBag<object> Payloads { get; } = new();
|
||||
|
||||
/// <summary>Publishes a message to the specified topic.</summary>
|
||||
/// <param name="topic">The topic name.</param>
|
||||
/// <param name="payload">The message payload.</param>
|
||||
public void Publish(string topic, object payload)
|
||||
{
|
||||
Topics.Add(topic);
|
||||
Payloads.Add(payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
+384
@@ -0,0 +1,384 @@
|
||||
using Akka.Actor;
|
||||
using Akka.Cluster.Tools.PublishSubscribe;
|
||||
using Akka.TestKit;
|
||||
using Serilog;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.ScriptedAlarms;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies <see cref="ScriptedAlarmHostActor"/> loads the enabled subset of
|
||||
/// <see cref="EquipmentScriptedAlarmPlan"/>s into its <see cref="ScriptedAlarmEngine"/>, registers mux
|
||||
/// interest for their dependency refs after the load completes, feeds live
|
||||
/// <see cref="VirtualTagActor.DependencyValueChanged"/> values into the engine, and bridges the
|
||||
/// engine's emissions to both an <see cref="OpcUaPublishActor.AlarmStateUpdate"/> and an
|
||||
/// <see cref="AlarmTransitionEvent"/> on the cluster <c>alerts</c> topic.
|
||||
/// </summary>
|
||||
public sealed class ScriptedAlarmHostActorTests : RuntimeActorTestBase
|
||||
{
|
||||
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(8);
|
||||
|
||||
/// <summary>Plan whose predicate compares the single tag "M.T" against 90 — enabled by default.</summary>
|
||||
private static EquipmentScriptedAlarmPlan Plan(
|
||||
string id = "alm-1",
|
||||
string equipmentId = "Plant/Line1/M",
|
||||
string name = "HighTemp",
|
||||
string depRef = "M.T",
|
||||
int threshold = 90,
|
||||
bool enabled = true,
|
||||
int severity = 800) =>
|
||||
new(
|
||||
ScriptedAlarmId: id,
|
||||
EquipmentId: equipmentId,
|
||||
Name: name,
|
||||
AlarmType: "AlarmCondition",
|
||||
Severity: severity,
|
||||
MessageTemplate: "condition",
|
||||
PredicateScriptId: $"{id}-script",
|
||||
PredicateSource: $"return (int)ctx.GetTag(\"{depRef}\").Value > {threshold};",
|
||||
DependencyRefs: new[] { depRef },
|
||||
HistorizeToAveva: true,
|
||||
Retain: true,
|
||||
Enabled: enabled);
|
||||
|
||||
/// <summary>Plan whose predicate references an unknown identifier so it fails to compile — applying it
|
||||
/// faults the engine's LoadAsync. Used to prove a faulted load doesn't crash the host.</summary>
|
||||
private static EquipmentScriptedAlarmPlan BadPlan(string id = "bad-1") =>
|
||||
new(
|
||||
ScriptedAlarmId: id,
|
||||
EquipmentId: "Plant/Line1/M",
|
||||
Name: "Broken",
|
||||
AlarmType: "AlarmCondition",
|
||||
Severity: 500,
|
||||
MessageTemplate: "broken",
|
||||
PredicateScriptId: $"{id}-script",
|
||||
PredicateSource: "return unknownIdentifier;", // uncompilable → LoadAsync throws
|
||||
DependencyRefs: Array.Empty<string>(),
|
||||
HistorizeToAveva: false,
|
||||
Retain: false,
|
||||
Enabled: true);
|
||||
|
||||
private static ScriptedAlarmEngine BuildEngine(DependencyMuxTagUpstreamSource upstream)
|
||||
{
|
||||
var logger = new LoggerConfiguration().CreateLogger();
|
||||
return new ScriptedAlarmEngine(upstream, new InMemoryAlarmStateStore(), new ScriptLoggerFactory(logger), logger);
|
||||
}
|
||||
|
||||
private (IActorRef Host, DependencyMuxTagUpstreamSource Upstream) Spawn(
|
||||
TestProbe publish, TestProbe mux)
|
||||
{
|
||||
var upstream = new DependencyMuxTagUpstreamSource();
|
||||
var engine = BuildEngine(upstream);
|
||||
var host = Sys.ActorOf(ScriptedAlarmHostActor.Props(publish.Ref, mux.Ref, upstream, engine));
|
||||
return (host, upstream);
|
||||
}
|
||||
|
||||
/// <summary>Subscribe <paramref name="probe"/> to the <c>alerts</c> DPS topic and wait for the ack.
|
||||
/// The Subscribe is sent FROM the probe so the SubscribeAck returns to it.</summary>
|
||||
private void SubscribeToAlerts(TestProbe probe)
|
||||
{
|
||||
DistributedPubSub.Get(Sys).Mediator.Tell(
|
||||
new Subscribe(ScriptedAlarmHostActor.AlertsTopic, probe.Ref), probe.Ref);
|
||||
probe.ExpectMsg<SubscribeAck>(Timeout);
|
||||
}
|
||||
|
||||
/// <summary>Load + interest: applying one enabled alarm registers mux interest for its dep ref
|
||||
/// AFTER the engine load completes; a disabled alarm in the same apply contributes no dep ref.</summary>
|
||||
[Fact]
|
||||
public void Apply_loads_enabled_alarm_and_registers_interest()
|
||||
{
|
||||
var publish = CreateTestProbe();
|
||||
var mux = CreateTestProbe();
|
||||
var (host, _) = Spawn(publish, mux);
|
||||
|
||||
host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[]
|
||||
{
|
||||
Plan(id: "alm-1", depRef: "M.T"),
|
||||
Plan(id: "alm-2", depRef: "M.X", enabled: false), // disabled — not loaded, dep absent
|
||||
}));
|
||||
|
||||
var reg = mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(Timeout);
|
||||
reg.TagRefs.ShouldContain("M.T");
|
||||
reg.TagRefs.ShouldNotContain("M.X");
|
||||
}
|
||||
|
||||
/// <summary>Activation path: with the alarm loaded, pushing a value above the threshold drives an
|
||||
/// Inactive→Active transition — the host publishes an AlarmStateUpdate(Active=true) and an
|
||||
/// AlarmTransitionEvent("Activated") on the alerts topic.</summary>
|
||||
[Fact]
|
||||
public void Dependency_change_above_threshold_activates_alarm()
|
||||
{
|
||||
var publish = CreateTestProbe();
|
||||
var mux = CreateTestProbe();
|
||||
var alerts = CreateTestProbe();
|
||||
SubscribeToAlerts(alerts);
|
||||
|
||||
var (host, _) = Spawn(publish, mux);
|
||||
host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan(severity: 800) }));
|
||||
mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(Timeout); // load completed
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
host.Tell(new VirtualTagActor.DependencyValueChanged("M.T", 99, now));
|
||||
|
||||
var state = publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(Timeout);
|
||||
state.AlarmNodeId.ShouldBe("alm-1");
|
||||
// The full Part 9 snapshot bridges through (T15) — every Core condition field maps:
|
||||
// on activation the engine sets Active, clears Ack AND Confirm (a new active occurrence needs a
|
||||
// fresh ack→clear→confirm cycle), keeps Enabled, and leaves Shelving unshelved.
|
||||
state.State.Active.ShouldBeTrue(); // Condition.Active == Active
|
||||
state.State.Acknowledged.ShouldBeFalse(); // Condition.Acked == Unacknowledged on activation
|
||||
state.State.Confirmed.ShouldBeFalse(); // Condition.Confirmed == Unconfirmed on activation
|
||||
state.State.Enabled.ShouldBeTrue(); // Condition.Enabled == Enabled
|
||||
state.State.Shelving.ShouldBe(AlarmShelvingKind.Unshelved); // Condition.Shelving.Kind == Unshelved
|
||||
state.State.Severity.ShouldBe((ushort)1000); // 800 → Critical bucket → 1000
|
||||
state.State.Message.ShouldBe("condition"); // e.Message
|
||||
|
||||
var evt = alerts.ExpectMsg<AlarmTransitionEvent>(Timeout);
|
||||
evt.AlarmId.ShouldBe("alm-1");
|
||||
evt.TransitionKind.ShouldBe("Activated");
|
||||
evt.Severity.ShouldBe(1000); // 800 → Critical bucket → 1000
|
||||
evt.User.ShouldBe("system");
|
||||
}
|
||||
|
||||
/// <summary>Clear path: after activating, pushing a value below the threshold drives Active→Inactive
|
||||
/// — AlarmStateUpdate(Active=false) + AlarmTransitionEvent("Cleared").</summary>
|
||||
[Fact]
|
||||
public void Dependency_change_below_threshold_clears_alarm()
|
||||
{
|
||||
var publish = CreateTestProbe();
|
||||
var mux = CreateTestProbe();
|
||||
var alerts = CreateTestProbe();
|
||||
SubscribeToAlerts(alerts);
|
||||
|
||||
var (host, _) = Spawn(publish, mux);
|
||||
host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan() }));
|
||||
mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(Timeout);
|
||||
|
||||
// Activate first.
|
||||
host.Tell(new VirtualTagActor.DependencyValueChanged("M.T", 99, DateTime.UtcNow));
|
||||
publish.FishForMessage<OpcUaPublishActor.AlarmStateUpdate>(m => m.State.Active, Timeout);
|
||||
alerts.FishForMessage<AlarmTransitionEvent>(e => e.TransitionKind == "Activated", Timeout);
|
||||
|
||||
// Now clear.
|
||||
host.Tell(new VirtualTagActor.DependencyValueChanged("M.T", 10, DateTime.UtcNow));
|
||||
|
||||
var cleared = publish.FishForMessage<OpcUaPublishActor.AlarmStateUpdate>(m => !m.State.Active, Timeout);
|
||||
cleared.AlarmNodeId.ShouldBe("alm-1");
|
||||
|
||||
var evt = alerts.FishForMessage<AlarmTransitionEvent>(e => e.TransitionKind == "Cleared", Timeout);
|
||||
evt.AlarmId.ShouldBe("alm-1");
|
||||
}
|
||||
|
||||
/// <summary>Re-apply reloads: a second ApplyScriptedAlarms with a different alarm set loads the new
|
||||
/// alarm — a fresh RegisterInterest reflecting the new dependency refs lands on the mux.</summary>
|
||||
[Fact]
|
||||
public void Reapply_reloads_with_new_dependency_refs()
|
||||
{
|
||||
var publish = CreateTestProbe();
|
||||
var mux = CreateTestProbe();
|
||||
var (host, _) = Spawn(publish, mux);
|
||||
|
||||
host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan(id: "alm-1", depRef: "M.T") }));
|
||||
var first = mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(Timeout);
|
||||
first.TagRefs.ShouldContain("M.T");
|
||||
|
||||
host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan(id: "alm-9", depRef: "M.Y") }));
|
||||
var second = mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(Timeout);
|
||||
second.TagRefs.ShouldContain("M.Y");
|
||||
second.TagRefs.ShouldNotContain("M.T");
|
||||
}
|
||||
|
||||
/// <summary>Faulted load resilience: applying an alarm whose predicate doesn't compile faults the
|
||||
/// engine's LoadAsync. The host logs a Warning (Status.Failure handler) and stays alive — it must
|
||||
/// still process a subsequent valid apply, registering interest for that apply's dep refs.</summary>
|
||||
[Fact]
|
||||
public void Faulted_load_does_not_crash_host()
|
||||
{
|
||||
var publish = CreateTestProbe();
|
||||
var mux = CreateTestProbe();
|
||||
var (host, _) = Spawn(publish, mux);
|
||||
|
||||
// Apply a plan that fails to compile — LoadAsync faults, the host swallows it as a Warning and
|
||||
// does NOT register interest (no dep refs to register, and the load never completed).
|
||||
host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { BadPlan() }));
|
||||
mux.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
|
||||
// Prove the actor is still responsive: a later valid apply loads + registers interest as normal.
|
||||
host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan(id: "alm-ok", depRef: "M.T") }));
|
||||
var reg = mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(Timeout);
|
||||
reg.TagRefs.ShouldContain("M.T");
|
||||
}
|
||||
|
||||
/// <summary>Stale-load guard (fix A): two back-to-back applies with different dep-ref sets must end
|
||||
/// with the mux holding the SECOND (latest-generation) set — a stale earlier completion must never
|
||||
/// re-introduce the first set's refs.
|
||||
///
|
||||
/// <para>Limitation: forcing the two LoadAsync continuations to complete out of order is not
|
||||
/// deterministic via real async timing (both loads are short + run on the thread pool). This test
|
||||
/// therefore validates the guard's observable contract rather than the race itself: it fishes for
|
||||
/// the RegisterInterest carrying the second apply's refs, then asserts no LATER RegisterInterest
|
||||
/// re-introduces the first apply's refs. With the generation guard in place the latest apply always
|
||||
/// wins; without it, an out-of-order stale completion could land a "M.A"-bearing RegisterInterest
|
||||
/// after the "M.B" one — which this assertion would catch.</para></summary>
|
||||
[Fact]
|
||||
public void Stale_load_does_not_register_superseded_dep_refs()
|
||||
{
|
||||
var publish = CreateTestProbe();
|
||||
var mux = CreateTestProbe();
|
||||
var (host, _) = Spawn(publish, mux);
|
||||
|
||||
// Fire two applies in quick succession with disjoint dep refs; the second supersedes the first.
|
||||
host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan(id: "alm-a", depRef: "M.A") }));
|
||||
host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan(id: "alm-b", depRef: "M.B") }));
|
||||
|
||||
// The latest generation must win: fish for the RegisterInterest reflecting the second apply.
|
||||
mux.FishForMessage<DependencyMuxActor.RegisterInterest>(r => r.TagRefs.Contains("M.B"), Timeout);
|
||||
|
||||
// No LATER RegisterInterest may re-introduce the first (superseded) apply's "M.A" ref.
|
||||
mux.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
/// <summary>Command path: an AlarmCommand("Acknowledge") for an alarm this host owns (and that is
|
||||
/// currently active+unacknowledged) drives the engine's AcknowledgeAsync — observed via the resulting
|
||||
/// AlarmStateUpdate(Acknowledged=true) and an AlarmTransitionEvent("Acknowledged") on the alerts topic
|
||||
/// carrying the command's User (the user threads through AcknowledgeAsync → LastAckUser → evt.User).</summary>
|
||||
[Fact]
|
||||
public void AlarmCommand_acknowledge_drives_engine_with_mapped_args()
|
||||
{
|
||||
var publish = CreateTestProbe();
|
||||
var mux = CreateTestProbe();
|
||||
var alerts = CreateTestProbe();
|
||||
SubscribeToAlerts(alerts);
|
||||
|
||||
var (host, _) = Spawn(publish, mux);
|
||||
host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan(id: "alm-1", depRef: "M.T") }));
|
||||
mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(Timeout); // load completed
|
||||
|
||||
// Activate so there is something to acknowledge (Acknowledge no-ops on an already-acked alarm).
|
||||
host.Tell(new VirtualTagActor.DependencyValueChanged("M.T", 99, DateTime.UtcNow));
|
||||
publish.FishForMessage<OpcUaPublishActor.AlarmStateUpdate>(m => m.State.Active && !m.State.Acknowledged, Timeout);
|
||||
alerts.FishForMessage<AlarmTransitionEvent>(e => e.TransitionKind == "Activated", Timeout);
|
||||
|
||||
// Acknowledge via the command topic — the host owns alm-1, so AcknowledgeAsync runs.
|
||||
host.Tell(new AlarmCommand(
|
||||
AlarmId: "alm-1", Operation: "Acknowledge", User: "alice", Comment: "ack-note", UnshelveAtUtc: null));
|
||||
|
||||
var acked = publish.FishForMessage<OpcUaPublishActor.AlarmStateUpdate>(m => m.State.Acknowledged, Timeout);
|
||||
acked.AlarmNodeId.ShouldBe("alm-1");
|
||||
acked.State.Acknowledged.ShouldBeTrue();
|
||||
|
||||
// The transition carries the acting user mapped from cmd.User (proves AcknowledgeAsync got it).
|
||||
var evt = alerts.FishForMessage<AlarmTransitionEvent>(e => e.TransitionKind == "Acknowledged", Timeout);
|
||||
evt.AlarmId.ShouldBe("alm-1");
|
||||
evt.User.ShouldBe("alice");
|
||||
}
|
||||
|
||||
/// <summary>Ownership filter: an AlarmCommand for an AlarmId this host's engine does NOT own is ignored
|
||||
/// — the engine op never runs, so no AlarmStateUpdate and no alerts transition are produced.</summary>
|
||||
[Fact]
|
||||
public void AlarmCommand_for_unowned_alarm_is_ignored()
|
||||
{
|
||||
var publish = CreateTestProbe();
|
||||
var mux = CreateTestProbe();
|
||||
var alerts = CreateTestProbe();
|
||||
SubscribeToAlerts(alerts);
|
||||
|
||||
var (host, _) = Spawn(publish, mux);
|
||||
host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan(id: "alm-1", depRef: "M.T") }));
|
||||
mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(Timeout); // load completed; host owns only alm-1
|
||||
|
||||
// Command targets an alarm this engine never loaded — it must be a no-op.
|
||||
host.Tell(new AlarmCommand(
|
||||
AlarmId: "not-mine", Operation: "Acknowledge", User: "alice", Comment: null, UnshelveAtUtc: null));
|
||||
|
||||
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); // no engine op → no projection
|
||||
alerts.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); // no engine op → no transition
|
||||
}
|
||||
|
||||
/// <summary>Validation: an AddComment command with empty or whitespace comment text is rejected (logged),
|
||||
/// NOT propagated to the engine — the actor stays alive and still processes a subsequent valid command,
|
||||
/// proving it didn't fault and the engine's AddCommentAsync was never driven.</summary>
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void AlarmCommand_add_comment_empty_text_is_rejected_not_driven(string emptyComment)
|
||||
{
|
||||
var publish = CreateTestProbe();
|
||||
var mux = CreateTestProbe();
|
||||
var alerts = CreateTestProbe();
|
||||
SubscribeToAlerts(alerts);
|
||||
|
||||
var (host, _) = Spawn(publish, mux);
|
||||
host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan(id: "alm-1", depRef: "M.T") }));
|
||||
mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(Timeout); // load completed
|
||||
|
||||
// AddComment with empty/whitespace text is rejected before reaching the engine.
|
||||
host.Tell(new AlarmCommand(
|
||||
AlarmId: "alm-1", Operation: "AddComment", User: "alice", Comment: emptyComment, UnshelveAtUtc: null));
|
||||
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); // rejected → no engine op → no OPC UA projection
|
||||
alerts.ExpectNoMsg(TimeSpan.FromMilliseconds(200)); // rejected → no alerts event
|
||||
|
||||
// Prove the actor survived: activate the alarm and observe the normal projection flow.
|
||||
host.Tell(new VirtualTagActor.DependencyValueChanged("M.T", 99, DateTime.UtcNow));
|
||||
var state = publish.FishForMessage<OpcUaPublishActor.AlarmStateUpdate>(m => m.State.Active, Timeout);
|
||||
state.AlarmNodeId.ShouldBe("alm-1");
|
||||
}
|
||||
|
||||
/// <summary>Positive AddComment path: a non-empty AddComment for a loaded alarm drives the engine's
|
||||
/// AddCommentAsync — observed via an AlarmTransitionEvent("CommentAdded") on the alerts topic carrying
|
||||
/// the acting user (proves the op ran end-to-end through the host dispatch).</summary>
|
||||
[Fact]
|
||||
public void AlarmCommand_add_comment_nonempty_drives_engine()
|
||||
{
|
||||
var publish = CreateTestProbe();
|
||||
var mux = CreateTestProbe();
|
||||
var alerts = CreateTestProbe();
|
||||
SubscribeToAlerts(alerts);
|
||||
|
||||
var (host, _) = Spawn(publish, mux);
|
||||
host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan(id: "alm-1", depRef: "M.T") }));
|
||||
mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(Timeout); // load completed
|
||||
|
||||
// AddComment with a non-empty comment drives the engine — CommentAdded transition emitted.
|
||||
host.Tell(new AlarmCommand(
|
||||
AlarmId: "alm-1", Operation: "AddComment", User: "bob", Comment: "note from operator", UnshelveAtUtc: null));
|
||||
|
||||
var evt = alerts.FishForMessage<AlarmTransitionEvent>(e => e.TransitionKind == "CommentAdded", Timeout);
|
||||
evt.AlarmId.ShouldBe("alm-1");
|
||||
}
|
||||
|
||||
/// <summary>Validation: a TimedShelve command missing UnshelveAtUtc is rejected (logged), NOT thrown —
|
||||
/// the actor stays alive and still processes a subsequent valid command, proving it didn't fault.</summary>
|
||||
[Fact]
|
||||
public void AlarmCommand_timed_shelve_missing_unshelve_time_is_rejected_not_thrown()
|
||||
{
|
||||
var publish = CreateTestProbe();
|
||||
var mux = CreateTestProbe();
|
||||
var alerts = CreateTestProbe();
|
||||
SubscribeToAlerts(alerts);
|
||||
|
||||
var (host, _) = Spawn(publish, mux);
|
||||
host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan(id: "alm-1", depRef: "M.T") }));
|
||||
mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(Timeout); // load completed
|
||||
|
||||
// TimedShelve with a null UnshelveAtUtc is malformed — the host rejects + logs, does not throw.
|
||||
host.Tell(new AlarmCommand(
|
||||
AlarmId: "alm-1", Operation: "TimedShelve", User: "alice", Comment: null, UnshelveAtUtc: null));
|
||||
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); // rejected → no engine op → no projection
|
||||
|
||||
// Prove the actor survived: activate the alarm and observe the normal projection flow.
|
||||
host.Tell(new VirtualTagActor.DependencyValueChanged("M.T", 99, DateTime.UtcNow));
|
||||
var state = publish.FishForMessage<OpcUaPublishActor.AlarmStateUpdate>(m => m.State.Active, Timeout);
|
||||
state.AlarmNodeId.ShouldBe("alm-1");
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user