Compare commits

...

51 Commits

Author SHA1 Message Date
Joseph Doherty bc9843d2bd docs(plan): mark T17-T24 inbound-ack complete + merged-to-master
v2-ci / build (push) Failing after 7m11s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
2026-06-11 08:08:37 -04:00
Joseph Doherty 87e433871e docs: document inbound alarm ack/shelve (AlarmAck gate, alarm-commands, AdminUI/CLI) + remove scratch files
Records T17-T22 as shipped: RoleCarryingUserIdentity, Part 9 method handlers gated on AlarmAck
role, alarm-commands DPS topic, ScriptedAlarmHostActor dispatch, WriteAlarmCondition delta-gate,
AdminUI /alerts Acknowledge/Shelve/Unshelve buttons via AdminOperationsActor singleton, and
Client.CLI ack/confirm/shelve commands. Corrects stale "Not started" / "Partial" entries in
phase-7-status.md (Stream G OPC UA method binding row and C.6 row and Gap 1 body) and adds
the alarm-commands topic to Runtime.md. Removes untracked scratch files resume.md and pending.md.
2026-06-11 07:49:41 -04:00
Joseph Doherty d860b0d8a6 fix(adminui): dispose Alerts CTS + self-render ShowOpResult (T21 review)
Dispose the CancellationTokenSource in AcknowledgeAsync and ShelveAsync
(the TimeSpan overload holds an internal timer — leaked without using).
Add StateHasChanged() to ShowOpResult so the result chip renders even if
a future caller omits the finally-block re-render.
2026-06-11 06:50:06 -04:00
Joseph Doherty 370a2b7b48 feat(alerts): AdminUI alarm ack/shelve via AdminOperationsActor singleton
T21: add an AdminUI path for acknowledging/shelving alarms that routes
through the admin-pinned AdminOperationsActor cluster singleton, which
republishes onto the same 'alarm-commands' DPS topic the OPC UA method
path (T18) and the engine subscriber (T19) use. The broadcast + the
ScriptedAlarmHostActor ownership filter handle cross-node routing, so
the singleton needs no knowledge of which node owns the alarm.

- Commons: AcknowledgeAlarmCommand/ShelveAlarmCommand (+ result records)
  and a shared AlarmCommandsTopic const; ScriptedAlarmHostActor now
  re-exports that const (mirrors the DriverControlTopic pattern).
- AdminOperationsActor: two handlers map the control-plane messages to
  AlarmCommand (Acknowledge / OneShotShelve / TimedShelve / Unshelve,
  threading User/Comment/UnshelveAtUtc) and publish via the DPS mediator.
- IAdminOperationsClient + AdminOperationsClient: typed Acknowledge/Shelve
  ask wrappers mirroring StartDeploymentAsync.
- Alerts.razor: per-row DriverOperator-gated Ack/Shelve/Unshelve controls;
  operator name from AuthenticationState. Timed-shelve datetime UI deferred.
- 5 TestKit tests (mediator-probe subscribed to alarm-commands) verifying
  each kind's mapping + reply; 56/56 ControlPlane tests green.
2026-06-11 06:44:27 -04:00
Joseph Doherty f742050ebd docs(opcua): explain intentional CommentAdded/Retain delta-gate suppression (T20 review)
Three code-review points on commit 004558c2 were correct behavior
that was under-documented, not bugs:

1. AlarmConditionDelta gains explicit paragraphs explaining why
   CommentAdded is absent: it always originates from a client
   AddComment call whose T18 OnAddComment handler returns Good →
   SDK auto-fires the comment event (E2); the engine re-projection
   carries no delta-field change, so the gate correctly suppresses
   the duplicate. Force-firing would double-emit.

2. Same doc explains Retain is intentionally absent: Retain is a
   pure function of Active/Acknowledged (both compared), so it
   cannot flip without a real delta. Notes future risk if that
   ever changes.

3. ReportConditionEvent Time/ReceiveTime comment corrected: the
   projection was already applied by WriteAlarmCondition above
   with identical values; the restamp is a locality repeat, not a
   reorder guard.

Also adds one seam unit-test (103 total, was 102) pinning the
null-vs-empty Message normalization boundary so a change to the
?? string.Empty coalescing is caught at the seam level.
2026-06-11 06:38:31 -04:00
Joseph Doherty 1d7e2a0f8b fix(runtime): reject empty AddComment instead of silently swallowing it
Validate AddComment up-front (IsNullOrWhiteSpace guard + Warning log) so
a blank-comment command is cleanly rejected before reaching the engine
rather than faulting inside ApplyAddComment and being silently swallowed
by the outer catch.  Mirrors the existing TimedShelve missing-UnshelveAtUtc
pattern.

Also fix two stale inline comments: the "async void crash" note on
TimedShelve now correctly says "fault escaping async Task → supervision
restart", and the ownership-filter now documents the benign race with a
concurrent LoadAsync clearing the loaded set.

Tests: AlarmCommand_add_comment_empty_text_is_rejected_not_driven (Theory
— empty string + whitespace) and AlarmCommand_add_comment_nonempty_drives_engine
(positive path, asserts CommentAdded transition on alerts topic).
2026-06-11 06:32:53 -04:00
Joseph Doherty 004558c241 fix(alarms): delta-gate WriteAlarmCondition to suppress inbound ack double-emit (T20)
Inbound client acks now route through the engine (T18/T19). On a successful
Acknowledge the T18 gate returns Good, so the SDK applies the acked state to the
AlarmConditionState node and auto-fires its own condition event (E2) -- directly
on the node, bypassing WriteAlarmCondition. The engine then re-projects that same
transition through WriteAlarmCondition, which fired again (E3): a double-emit.

Gate WriteAlarmCondition's ReportConditionEvent on a genuine delta computed
against the node's CURRENT live state (read before projecting the snapshot), not
a last-written cache (which would be stale, since the SDK-applied ack never went
through this method). For a re-projected ack the snapshot equals the node's
already-applied state -> no delta -> suppress E3. Genuine engine-driven
transitions still differ -> fire.

Compared fields (value-equality via AlarmConditionDelta record): Active, Acked,
Confirmed, Enabled, Shelving (mapped from the shelving state machine), Severity
(mapped through MapSeverity to match the bucket the node stores), Message.
Optional Confirmed/Shelving fold to the node read-back default when the child is
absent so they can't register a phantom delta.

Tests prove both: suppression of the simulated inbound ack re-projection
(EventId unchanged) and that genuine transitions fire while identical
re-projections suppress; plus a direct unit test of the ShouldFireConditionEvent
seam. 102/102 OpcUaServer.Tests green.
2026-06-11 06:26:48 -04:00
Joseph Doherty 4f7999eac2 feat(alarms): consume alarm-commands topic in ScriptedAlarmHostActor (T19)
Subscribe the host to the cluster alarm-commands DPS topic in PreStart and
drive the matching ScriptedAlarmEngine op per inbound AlarmCommand. An
ownership filter (engine.LoadedAlarmIds) ignores commands for alarms this
node does not own; TimedShelve without UnshelveAtUtc and unknown operations
are logged + rejected (never thrown); op failures are caught + logged so a
faulting op can't fault the actor. Re-projection is left to the engine's
existing OnEvent -> OnEngineEmission path.

Handler is a Task-returning ReceiveAsync (the project's AK2003 analyzer
forbids an async-void Receive delegate), giving ordered awaited async on the
actor thread. Adds 3 TestKit tests: ack drives the engine with mapped args,
unowned command ignored, missing-UnshelveAtUtc TimedShelve rejected not
thrown.
2026-06-11 06:23:08 -04:00
Joseph Doherty 1784eedd3f fix(opcua): exempt OnTimedUnshelve from the client AlarmAck gate (system-initiated)
The SDK fires OnTimedUnshelve with the node manager's system context (no
session, no user identity) when a TimedShelve duration expires. Routing
through the shared HandleAlarmCommand hit the AlarmAck gate and returned
BadUserAccessDenied, leaving the alarm permanently shelved.

Replace the delegated HandleAlarmCommand call with an inline lambda that
bypasses the client gate, extracts the AlarmId the same way, and routes an
Unshelve command so the engine clears its shelve state. The manual-client
Unshelve path via OnShelve(shelving:false) remains gated.

Update the AlarmCommandRouterTests OnTimedUnshelve test to use a real
system context (no UserIdentity) — reproducing the actual SDK invocation
path — and assert Good, AlarmId, Operation==Unshelve, User==empty.

Add a doc note to AlarmCommand.Operation that Enable/Disable are in the
vocabulary but not yet wired at the node-manager seam.
2026-06-11 06:16:30 -04:00
Joseph Doherty 63289d377c feat(alarms): route inbound Part 9 alarm methods through AlarmAck gate (T18)
Wire the materialised AlarmConditionState method handlers so a client calling
Acknowledge/Confirm/Shelve/AddComment is gated on the AlarmAck data-plane role
and, when allowed, routed back to the scripted-alarm engine via a new
`alarm-commands` DistributedPubSub topic.

- Commons: new AlarmCommand DTO (AlarmId/Operation/User/Comment/UnshelveAtUtc).
- ScriptedAlarmHostActor: add AlarmCommandsTopic const.
- OtOpcUaNodeManager: settable AlarmCommandRouter + wire OnAcknowledge/OnConfirm/
  OnAddComment/OnShelve/OnTimedUnshelve. Each resolves the principal off
  ISessionOperationContext.UserIdentity as RoleCarryingUserIdentity, fails closed
  (BadUserAccessDenied) when the AlarmAck role is absent or no identity, else maps
  + routes an AlarmCommand and returns Good. OnShelve discriminates OneShotShelve/
  TimedShelve/Unshelve from the SDK flags; TimedShelve expiry = UtcNow + ms.
  No Akka/IActorRef handle — only the Action<AlarmCommand> delegate. T20 de-dup
  note left; WriteAlarmCondition untouched.
- OpcUaServer.Security: OpcUaDataPlaneRoles.AlarmAck shared const (the role was a
  bare string everywhere; introduced one symbol for the gate + tests).
- OtOpcUaSdkServer: SetAlarmCommandRouter pass-through.
- Host: boot wiring publishes each command via mediator.Tell(Publish(...)) using a
  lazy ActorSystem accessor (mirrors DpsScriptLogPublisher).
- Tests: 11 new gate + mapping tests (OpcUaServer.Tests 88->99, all green).
2026-06-11 06:05:39 -04:00
Joseph Doherty ac5db0a9f8 fix(client): TimedShelve milliseconds + shelve node guard + service-layer tests
Important 1: ShelveAlarmAsync Timed branch now multiplies shelvingTimeSeconds × 1000.0
before passing to CallMethodAsync — OPC UA Part 9 TimedShelve ShelvingTime is a Duration
in milliseconds, not seconds. IOpcUaClientService XML doc and ShelveCommand --duration
description updated to document the seconds-in / ms-out contract.

Important 2: ShelveAlarmAsync builds shelvingStateNodeId with the same
EndsWith(".ShelvingState") guard already used by the .Condition suffix in
AcknowledgeAlarmAsync / ConfirmAlarmAsync, preventing double-append.

Important 3: Add 6 service-layer tests to OpcUaClientServiceTests —
  ConfirmAlarmAsync_OnSuccess_ReturnsGood
  ConfirmAlarmAsync_OnServiceResultException_ReturnsBadStatusCode
  ShelveAlarmAsync_OneShot_CallsMethodWithNoArgs
  ShelveAlarmAsync_Timed_PassesDurationInMilliseconds (regression guard for Important 1)
  ShelveAlarmAsync_Unshelve_CallsMethodWithNoArgs
  ShelveAlarmAsync_OnServiceResultException_ReturnsBadStatusCode
FakeSessionAdapter extended with CallMethodInputArgs list to record per-call input
arguments so the Timed test can assert the ms value.

Minor 4: ShelveCommand output changed from "Shelve (OneShot) successful" to
"{shelveKind} successful/failed" so Unshelve reads "Unshelve successful: …".

Minor 6: ShelveCommand --duration description updated to "(must be > 0; in seconds,
converted to milliseconds for the OPC UA call; required for --kind Timed)".
2026-06-11 05:57:40 -04:00
Joseph Doherty 4b38a9d8c8 harden(security): freeze RoleCarryingUserIdentity.Roles to a defensive copy
Code-review follow-up to T17: copy the roles into a fresh array at construction so a
caller mutating the source list cannot retroactively alter a session's granted roles,
and so the T18 ack gate's per-invocation .Contains(...) runs over a known-small frozen
array.
2026-06-11 05:53:11 -04:00
Joseph Doherty 1f172e55f7 feat(client-cli): add ack/confirm/shelve alarm commands with service layer
Adds ConfirmAlarmAsync and ShelveAlarmAsync to IOpcUaClientService and
OpcUaClientService (mirroring the AcknowledgeAlarmAsync pattern: same
CallMethodAsync/ServiceResultException/StatusCode contract). Adds ShelveKind
enum (OneShot/Timed/Unshelve). Adds three new CLI commands — ack, confirm,
shelve — with hex EventId input and per-command argument validation.
Updates both fakes (CLI + UI) to implement the new interface members and
record calls. Adds 16 unit tests covering argument mapping, invalid-input
CommandException paths, bad-status output, and disconnect-in-finally.
2026-06-11 05:46:25 -04:00
Joseph Doherty a6fed85ac9 feat(security): carry LDAP roles onto session identity (T17)
Stop discarding the authenticator's resolved roles during impersonation.
HandleImpersonation now sets args.Identity to a RoleCarryingUserIdentity
(: UserIdentity) that carries result.Roles, so a downstream method handler
can read them off context.UserIdentity for the inbound AlarmAck gate (T18).

Verified via the decompiled SDK (1.5.378.106) that the instance we assign to
ImpersonateEventArgs.Identity is stored by reference onto Session.Identity /
EffectiveIdentity and surfaced unchanged on OperationContext.UserIdentity --
the custom subclass survives the round-trip. No auth-decision logic changes.
2026-06-11 05:42:27 -04:00
Joseph Doherty 2b890fa716 docs(plan): re-scope script-log Layer 2 inbound-ack into T17-T24
The original single T17 (inbound method dispatch + ack plumbing) proved on a
2026-06-11 deep dive to be four hard problems: roles on the session identity
(T17), node-manager command router + AlarmAck veto + alarm-commands DPS topic
(T18), host-actor inbound handler (T19), and delta-gate double-emit (T20). Old
T18->T21 (AdminUI), old T19 split into T22 (Client.CLI feature) + T23 (verify),
old T20->T24. Adds the Layer 2 design-decisions preamble.
2026-06-11 05:37:54 -04:00
Joseph Doherty 74e1abdf32 docs(plan): record disable-login feature merged to master (2ad1dbc8) 2026-06-11 05:37:54 -04:00
Joseph Doherty 2ad1dbc894 fix(security): AutoLoginAuthenticationHandler no-op sign-in/out (avoid 500 on /auth/logout when flag on) 2026-06-11 04:45:29 -04:00
Joseph Doherty 82fec753c8 feat(security): wire Security:Auth:DisableLogin into AddOtOpcUaAuth 2026-06-11 04:39:23 -04:00
Joseph Doherty caeaae21f9 feat(security): AutoLoginAuthenticationHandler for dev login bypass 2026-06-11 04:31:07 -04:00
Joseph Doherty a27e82c8d1 chore(dev): Security:Auth:DisableLogin default off; enable in docker-dev central nodes 2026-06-11 04:29:47 -04:00
Joseph Doherty a92ba6a10b feat(security): add AuthDisableLoginOptions + DevAuthRoles for dev login bypass 2026-06-11 04:26:20 -04:00
Joseph Doherty bc31b6a4de docs(adminui): implementation plan for Security:Auth:DisableLogin dev flag
6-task plan (T0 branch -> T1 options/roles -> T2 handler -> T3 wiring -> T5 verify;
T4 config+docker-dev parallel). AutoLoginAuthenticationHandler registered under the
cookie scheme name so existing policies keep working; enabled in docker-dev.
2026-06-11 04:24:00 -04:00
Joseph Doherty 789176738f docs(adminui): design for Security:Auth:DisableLogin dev flag
Approved brainstorming design: a config flag that disables AdminUI login,
auto-authenticating every request as 'multi-role-test' with all roles via an
always-succeeding AuthenticationHandler registered under the cookie scheme name.
Default off; enabled in docker-dev (central-1/central-2). AdminUI cookie surface
only; OPC UA LDAP + deploy API key untouched.
2026-06-11 04:19:43 -04:00
Joseph Doherty 43e8a37ded chore(plan): mark T0-T16 complete; T17-T20 deferred (Layer 2 partial) 2026-06-11 01:25:17 -04:00
Joseph Doherty 4c417f7fb8 fix(scripted-alarms): log failed event-report via SDK trace + correct sink doc (T16 review) 2026-06-10 19:54:37 -04:00
Joseph Doherty 295bb55dc6 feat(scripted-alarms): fire Part 9 condition events on transition (T16) 2026-06-10 19:50:09 -04:00
Joseph Doherty ab5d0752d8 fix(scripted-alarms): atomic alarm-condition lookup under Lock (T15 review) 2026-06-10 19:45:24 -04:00
Joseph Doherty 4eb1d65e2b feat(scripted-alarms): richer AlarmConditionState bridge to the OPC UA node (T15) 2026-06-10 19:41:16 -04:00
Joseph Doherty b31d7cb03f fix(scripted-alarms): lock CreateVariable + RemoveRootNotifier on rebuild (T14 review) 2026-06-10 19:26:01 -04:00
Joseph Doherty 60d48a2a0a feat(scripted-alarms): materialise real Part 9 AlarmConditionState nodes (T14) 2026-06-10 19:19:10 -04:00
Joseph Doherty 4217b213b0 docs(scripted-alarms): F14b OPC UA Part 9 SDK research notes (T13) 2026-06-10 18:19:52 -04:00
Joseph Doherty a8640a9331 test(scripted-alarms): cover bootstrap-restore path forwarding alarms (T10 review) 2026-06-10 15:24:39 -04:00
Joseph Doherty fc0d43a3dc refactor(scripted-alarms): retire orphaned ScriptedAlarmActor + F9b evaluator (T11) 2026-06-10 15:22:26 -04:00
Joseph Doherty 5256761368 feat(scripted-alarms): spawn + apply ScriptedAlarmHostActor in DriverHostActor (T10) 2026-06-10 15:17:29 -04:00
Joseph Doherty dafaf2faec fix(scripted-alarms): ScriptedAlarmHostActor review fixes — load-gen guard, quiet cancel, parse guard (T9 review) 2026-06-10 15:08:54 -04:00
Joseph Doherty 3b418a54f1 feat(scripted-alarms): ScriptedAlarmHostActor — engine runtime host (T9) 2026-06-10 14:57:42 -04:00
Joseph Doherty c9590c03d0 fix(scripted-alarms): harden artifact boolean decode + direct helper tests (T6 review)
Default HistorizeToAveva/Retain/Enabled to the entity defaults (true) when a
field is absent/null/non-boolean so a partial blob decodes identically to the
composer's view of a default-constructed ScriptedAlarm (byte-parity), and only
call GetBoolean for a genuine true/false token. Add direct ExtractAlarmDependencyRefs
unit tests (overlap dedup + reserved {{equip}} exclusion).
2026-06-10 14:47:24 -04:00
Joseph Doherty 8e8ca9efe8 feat(scripted-alarms): DeploymentArtifact byte-parity for the alarm plan (T6) 2026-06-10 14:41:46 -04:00
Joseph Doherty 55101baaa4 refactor(scripted-alarms): review-fix polish for T5/T7/T8 (observer isolation, warning hoist, doc) 2026-06-10 14:32:49 -04:00
Joseph Doherty b28c6bdb62 feat(scripted-alarms): EquipmentScriptedAlarmPlan + Phase7Composer enrichment (T5) 2026-06-10 14:21:28 -04:00
Joseph Doherty 1c96fe0be0 feat(scripted-alarms): EfAlarmConditionStateStore (T8) 2026-06-10 14:21:19 -04:00
Joseph Doherty 945ccd0b85 feat(scripted-alarms): DependencyMuxTagUpstreamSource (T7)
Concrete ITagUpstreamSource the scripted-alarm host actor pushes
DependencyValueChanged values into and ScriptedAlarmEngine reads/subscribes
from. Thread-safe: ConcurrentDictionary value cache + per-path ImmutableList
observer lists with atomic add/remove and capture-then-invoke fan-out.
ReadTag of an unknown path returns a Bad-quality (0x80000000) snapshot stamped
via the injected clock. Adds the Core.ScriptedAlarms project reference Runtime
needs to see the interface.
2026-06-10 14:20:02 -04:00
Joseph Doherty b5748288df test(scriptlog): prove bridge→broadcaster delivery off the script-logs DPS topic
Composes the one Layer-0 hop existing tests left uncovered together:
ScriptLogSignalRBridge subscribing to the script-logs DPS topic and
fanning a ScriptLogEntry out to the IInProcessBroadcaster<ScriptLogEntry>
singleton resolved from the SAME DI container the /script-log page injects.
Mirrors DriverStatusHubE2eTests. Confirms the server-side topic→page chain
delivers end-to-end (only the live Blazor circuit remains manual).
2026-06-10 13:53:34 -04:00
Joseph Doherty c42a056537 chore(plan): Layer 0 complete (T0-T3) — tracking update 2026-06-10 12:09:56 -04:00
Joseph Doherty 788bb68d1d fix(scripting): companion sink falls back to ScriptId for the main-log mirror (T3 review) 2026-06-10 12:08:29 -04:00
Joseph Doherty bd2dd05a0c feat(scripting): evaluators log through root script logger → script-log page (F8) 2026-06-10 12:03:51 -04:00
Joseph Doherty bf86b3def6 fix(scripting): explicit companion logger + disposable ScriptRootLogger (T2 review) 2026-06-10 11:56:51 -04:00
Joseph Doherty 73014258ef feat(scripting): root script logger + DPS publisher wired in Host 2026-06-10 11:50:50 -04:00
Joseph Doherty 14fe88fc80 feat(scripting): ScriptLogTopicSink — script LogEvent → ScriptLogEntry → publisher 2026-06-10 11:38:54 -04:00
Joseph Doherty 12423899aa docs(plan): script-log emit + scripted-alarm runtime implementation plan + tasks 2026-06-10 11:34:01 -04:00
Joseph Doherty df4c265753 docs(design): script-log engine emit + scripted-alarm runtime (3-layer) 2026-06-10 11:28:13 -04:00
105 changed files with 9546 additions and 1165 deletions
+4
View File
@@ -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`.
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+618
View File
@@ -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.*
+11 -4
View File
@@ -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 &gt; 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 &gt; 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)
{
+26 -6
View File
@@ -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.
@@ -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.
}
}
}
@@ -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()
@@ -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");
}
}
@@ -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 />
@@ -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(
@@ -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,
@@ -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>
@@ -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>
@@ -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();
}
}
@@ -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));
}
}
@@ -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);
}
}
}
@@ -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