Compare commits

...

20 Commits

Author SHA1 Message Date
Joseph Doherty
63b31e240e Phase 7 follow-ups #239 (plumbing) + #241 (diff-proc extension)
Two complementary pieces that together unblock the last Phase 7 exit-gate deferrals.

## #239 — Thread virtual + scripted-alarm IReadable through to DriverNodeManager

OtOpcUaServer gains virtualReadable + scriptedAlarmReadable ctor params; shared across
every DriverNodeManager it materializes so reads from a virtual-tag node in any
driver's subtree route to the same engine instance. Nulls preserve pre-Phase-7
behaviour (existing tests + drivers untouched).

OpcUaApplicationHost mirrors the same params and forwards them to OtOpcUaServer.

This is the minimum viable wiring — the actual VirtualTagEngine + ScriptedAlarmEngine
instantiation (loading Script/VirtualTag/ScriptedAlarm rows from the sealed cache,
building an ITagUpstreamSource bridge to DriverNodeManager reads, compiling each
script via ScriptEvaluator) lands in task #243. Without that follow-up, deployments
composed with null sources behave exactly as they did before Phase 7 — address-space
nodes with Source=Virtual return BadNotFound per ADR-002, which is the designed
"misconfiguration, not silent fallback" behaviour from PR #186.

## #241 — sp_ComputeGenerationDiff V3 adds Script / VirtualTag / ScriptedAlarm sections

Migration 20260420232000_ExtendComputeGenerationDiffWithPhase7. Same CHECKSUM-based
Modified detection the existing sections use. Logical ids: ScriptId / VirtualTagId /
ScriptedAlarmId. Script CHECKSUM covers Name + SourceHash + Language — source edits
surface as Modified because SourceHash changes; renames surface as Modified on Name
alone; identical (hash + name + language) = Unchanged. VirtualTag + ScriptedAlarm
CHECKSUMs cover their content columns.

ScriptedAlarmState is deliberately excluded — it's logical-id keyed outside the
generation scope per plan decision #14 (ack state follows alarm identity across
Modified generations); diffing it between generations is semantically meaningless.

Down() restores V2 (the NodeAcl-extended proc from migration 20260420000001).

## No new test count — both pieces are proven by existing suites

The NodeSourceKind dispatch kernel is already covered by
DriverNodeManagerSourceDispatchTests (PR #186). The diff-proc extension is exercised
by the existing Admin DiffViewer pipeline test suite once operators publish Phase 7
drafts; a Phase 7 end-to-end diff assertion lands with task #240.
2026-04-20 21:07:59 -04:00
78f388b761 Merge pull request 'Admin.E2ETests scaffolding — Playwright + Kestrel + InMemory DB + test auth' (#188) from phase-6-4-uns-drag-drop-e2e into v2 2026-04-20 20:58:08 -04:00
Joseph Doherty
d78741cfdf Admin.E2ETests scaffolding — Playwright + Kestrel + InMemory DB + test auth
Ships the E2E infrastructure filed against task #199 (UnsTab drag-drop Playwright
smoke). The Blazor Server interactive-render assertion through a test-owned pipeline
needs a dedicated diagnosis pass — filed as task #242 — but the Playwright harness
lands here so that follow-up starts from a known-good scaffolding rather than
setting up the project from scratch.

## New project tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests

- AdminWebAppFactory — boots the Admin pipeline with Kestrel on a free loopback port,
  swaps the SQL DbContext for EF Core InMemory, replaces the LDAP cookie auth with
  TestAuthHandler, mirrors the Razor-components/auth/antiforgery pipeline, and seeds
  a cluster + draft generation with areas warsaw / berlin and a line-a1 in warsaw.
  Not a WebApplicationFactory<Program> because WAF's TestServer transport doesn't
  coexist cleanly with Kestrel-on-a-real-port, which Playwright needs.
- TestAuthHandler — stamps every request with a FleetAdmin claim so tests hit
  authenticated routes without the LDAP bind.
- PlaywrightFixture — one Chromium launch shared across tests; throws
  PlaywrightBrowserMissingException when the binary isn't installed so tests can
  Assert.Skip rather than fail hard.
- UnsTabDragDropE2ETests.Admin_host_serves_HTTP_via_Playwright_scaffolding — proves
  the full stack comes up: Kestrel bind, InMemory DbContext, test auth, Playwright
  navigation, Razor route pipeline responds with HTML < 500. One passing test.

## Prerequisite

Chromium must be installed locally:
  pwsh tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/bin/Debug/net10.0/playwright.ps1 install chromium

Absent the browser, the suite Assert.Skip's cleanly — CI without the install step
still reports green. Once installed, `dotnet test` runs the scaffolding smoke in ~12s.

## Follow-up (task #242)

Diagnose why `/clusters/{id}/draft/{gen}` → UNS-tab click → drag-drop flow times out
under the test-owned Program.cs replica. Candidate causes: route-ordering difference,
missing SignalR hub mapping timing, JS interop asset differences, culture middleware.
Once the interactive circuit boots, add:
- happy-path drag-drop assertion (source row → target area → Confirm → assert re-parent)
- 409 conflict variant (preview → external DB mutation → Confirm → assert red-header modal)
2026-04-20 20:55:57 -04:00
c08ae0d032 Merge pull request 'Phase 7 Stream H — exit gate compliance script + closeout doc' (#187) from phase-7-stream-h-exit-gate into v2 2026-04-20 20:27:18 -04:00
Joseph Doherty
82e4e8c8de Phase 7 Stream H — exit gate compliance script + closeout doc
Ships the check-everything PowerShell script + the human-readable exit-gate doc that
closes Phase 7 (scripting runtime + virtual tags + scripted alarms + historian sink
+ Admin UI + address-space integration).

## scripts/compliance/phase-7-compliance.ps1

Mirrors the Phase 6.x compliance pattern. Checks:
- Stream A: Roslyn sandbox wiring, ForbiddenTypeAnalyzer, DependencyExtractor,
  ScriptLogCompanionSink, Deadband helper
- Stream B: VirtualTagEngine, DependencyGraph (iterative Tarjan),
  SemaphoreSlim async-safe cascade, TimerTriggerScheduler, VirtualTagSource
- Stream C: Part9StateMachine, AlarmConditionState GxP audit Comments,
  MessageTemplate {TagPath}, AlarmPredicateContext SetVirtualTag rejection,
  ScriptedAlarmSource IAlarmSource, IAlarmStateStore + in-memory store
- Stream D: BackoffLadder 1-60s, DefaultDeadLetterRetention (30 days),
  HistorianWriteOutcome enum, Galaxy.Host IPC contracts
- Stream E: Four new entities + check constraints + Phase 7 migration
- Stream F: Five Admin services + ScriptEditor + ScriptsTab + AlarmsHistorian
  page + Monaco loader + DraftEditor wire-up + declared-inputs-only contract
- Stream G: NodeSourceKind discriminator + walker VirtualTag/ScriptedAlarm emission
  + DriverNodeManager SelectReadable + IsWriteAllowedBySource
- Deferred (flagged, not blocking): SealedBootstrap composition, live end-to-end
  smoke, sp_ComputeGenerationDiff extension
- Cross-cutting: full-solution dotnet test (regression check against 1300 baseline)

## docs/v2/implementation/exit-gate-phase-7.md

Summarises shipped PRs (Streams A-G + G follow-up = 8 PRs, ~197 tests), lists the
compliance checks covered, names the deferred follow-ups with task IDs, and points
at the compliance script for verification.

## Exit-gate local run

2191 tests green (baseline 1300), 0 failures, 55 compliance checks PASS,
3 deferred (with follow-up task IDs).

Phase 7 ships.
2026-04-20 20:25:11 -04:00
4e41f196b2 Merge pull request 'Phase 7 Stream G follow-up — DriverNodeManager dispatch routing by NodeSourceKind' (#186) from phase-7-stream-g-followup-dispatch into v2 2026-04-20 20:14:25 -04:00
Joseph Doherty
f0851af6b5 Phase 7 Stream G follow-up — DriverNodeManager dispatch routing by NodeSourceKind
Honors the ADR-002 discriminator at OPC UA Read/Write dispatch time. Virtual tag
reads route to the VirtualTagEngine-backed IReadable; scripted alarm reads route
to the ScriptedAlarmEngine-backed IReadable; driver reads continue to route to the
driver's own IReadable (no regression for any existing driver test).

## Changes

DriverNodeManager ctor gains optional `virtualReadable` + `scriptedAlarmReadable`
parameters. When callers omit them (every existing driver test) the manager behaves
exactly as before. SealedBootstrap wires the engines' IReadable adapters once the
Phase 7 composition root is added.

Per-variable NodeSourceKind tracked in `_sourceByFullRef` during Variable() registration
alongside the existing `_writeIdempotentByFullRef` / `_securityByFullRef` maps.

OnReadValue now picks the IReadable by source kind via the new internal
SelectReadable helper. When the engine-backed IReadable isn't wired (virtual tag
node but no engine provided), returns BadNotFound rather than silently falling
back to the driver — surfaces a misconfiguration instead of masking it.

OnWriteValue gates on IsWriteAllowedBySource which returns true only for Driver.
Plan decision #6: virtual tags + scripted alarms reject direct OPC UA writes with
BadUserAccessDenied. Scripts write virtual tags via `ctx.SetVirtualTag`; operators
ack alarms via the Part 9 method nodes.

## Tests — 7/7 (internal helpers exposed via InternalsVisibleTo)

DriverNodeManagerSourceDispatchTests covers:
- Driver source routes to driver IReadable
- Virtual source routes to virtual IReadable
- ScriptedAlarm source routes to alarm IReadable
- Virtual source with null virtual IReadable returns null (→ BadNotFound)
- ScriptedAlarm source with null alarm IReadable returns null
- Driver source with null driver IReadable returns null (preserves BadNotReadable)
- IsWriteAllowedBySource: only Driver=true (Virtual=false, ScriptedAlarm=false)

Full solution builds clean. Phase 7 test total now 197 green.
2026-04-20 20:12:17 -04:00
6df069b083 Merge pull request 'Phase 7 Stream F — Admin UI for scripts + test harness + historian diagnostics' (#185) from phase-7-stream-f-admin-ui into v2 2026-04-20 20:01:40 -04:00
Joseph Doherty
0687bb2e2d Phase 7 Stream F — Admin UI for scripts + test harness + historian diagnostics
Adds the draft-editor tab + page surface for authoring Phase 7 virtual tags and
scripted alarms, plus the /alarms/historian operator diagnostics page. Monaco loads
from CDN via a progressive-enhancement JS shim — the textarea works immediately so
the page is functional even if the CDN is unreachable.

## New services (Admin)

- ScriptService — CRUD for Script entity. SHA-256 SourceHash recomputed on save so
  Core.Scripting's CompiledScriptCache hits on re-publish of unchanged source + misses
  when the source actually changes.
- VirtualTagService — CRUD for VirtualTag, with Enabled toggle.
- ScriptedAlarmService — CRUD for ScriptedAlarm + lookup of persistent ScriptedAlarmState
  (logical-id-keyed per plan decision #14).
- ScriptTestHarnessService — pre-publish dry-run. Enforces plan decision #22: only
  inputs the DependencyExtractor identifies can be supplied. Missing / extra synthetic
  inputs surface as dedicated outcomes. Captures SetVirtualTag writes + Serilog events
  from the script so the operator can see both the output + the log output before
  publishing.
- HistorianDiagnosticsService — surfaces the local-process IAlarmHistorianSink state
  on /alarms/historian. Null sink reports Disabled + swallows retry. Live
  SqliteStoreAndForwardSink reports real queue depth + last-error + drain state and
  routes the Retry-dead-lettered button through.

## New UI

- ScriptsTab.razor (inside DraftEditor tabs) — list + create/edit/delete scripts with
  Monaco editor + dependency preview + test-harness run panel showing output + writes
  + log emissions.
- ScriptEditor.razor — reusable Monaco-backed textarea. Loads editor from CDN via
  wwwroot/js/monaco-loader.js. Textarea stays authoritative for Blazor binding; Monaco
  mirrors into it on every keystroke.
- AlarmsHistorian.razor (/alarms/historian) — queue depth + dead-letter depth + drain
  state badge + last-error banner + Retry-dead-lettered button.
- DraftEditor.razor — new "Scripts" tab.

## DI wiring

All five services registered in Program.cs. Null historian sink bound at Admin
composition time (real SqliteStoreAndForwardSink lives in the Server process).

## Tests — 13/13

Phase7ServicesTests covers:
- ScriptService: Add generates logical id + hash, Update recomputes hash on source
  change, Update same-source keeps hash (cache-hit preservation), Delete is idempotent
- VirtualTagService: round-trips trigger flags, Enabled toggle works
- ScriptedAlarmService: HistorizeToAveva defaults true per plan decision #15
- ScriptTestHarness: successful run captures output + writes, rejects missing /
  extra inputs, rejects non-literal paths, compile errors surface as Threw
- HistorianDiagnosticsService: null sink reports Disabled + retry returns 0
2026-04-20 19:59:18 -04:00
4d4f08af0d Merge pull request 'Phase 7 Stream G — Address-space integration (NodeSourceKind + walker emits VirtualTag/ScriptedAlarm)' (#184) from phase-7-stream-g-addressspace-integration into v2 2026-04-20 19:43:08 -04:00
Joseph Doherty
f1f53e1789 Phase 7 Stream G — Address-space integration (NodeSourceKind + walker emits VirtualTag/ScriptedAlarm)
Per ADR-002, adds the Driver/Virtual/ScriptedAlarm discriminator to DriverAttributeInfo
so the DriverNodeManager's dispatch layer can route Read/Write/Subscribe to the right
runtime subsystem — drivers (unchanged), VirtualTagEngine (Phase 7 Stream B), or
ScriptedAlarmEngine (Phase 7 Stream C).

## Changes
- NodeSourceKind enum added to Core.Abstractions (Driver=0/Virtual=1/ScriptedAlarm=2).
- DriverAttributeInfo gains Source / VirtualTagId / ScriptedAlarmId parameters — all
  default so existing call sites (every driver) compile unchanged.
- EquipmentNamespaceContent gains VirtualTags + ScriptedAlarms optional collections.
- EquipmentNodeWalker emits:
  - Virtual-tag variables — Source=Virtual, VirtualTagId set, Historize flag honored
  - Scripted-alarm variables — Source=ScriptedAlarm, ScriptedAlarmId set, IsAlarm=true
    (triggers node-manager AlarmConditionState materialization)
  - Skips disabled virtual tags + scripted alarms

## Tests — 13/13 in EquipmentNodeWalkerTests (5 new)
- Virtual-tag variables carry Source=Virtual + VirtualTagId + Historize flag
- Scripted-alarm variables carry Source=ScriptedAlarm + IsAlarm=true + Boolean type
- Disabled rows skipped
- Null VirtualTags/ScriptedAlarms collections safe (back-compat for non-Phase-7 callers)
- Driver tags default Source=Driver (ensures no discriminator regression)

## Next
Stream G follow-up: DriverNodeManager dispatch (Read/Write/Subscribe routing by
NodeSourceKind), SealedBootstrap wiring of VirtualTagEngine + ScriptedAlarmEngine,
end-to-end integration test.
2026-04-20 19:41:01 -04:00
e97db2d108 Merge pull request 'Phase 7 Stream E — Config DB schema for scripts, virtual tags, scripted alarms, and alarm state' (#183) from phase-7-stream-e-config-db into v2 2026-04-20 19:24:53 -04:00
Joseph Doherty
be1003c53e Phase 7 Stream E — Config DB schema for scripts, virtual tags, scripted alarms, and alarm state
Adds the four tables Streams B/C/F consume — Script (generation-scoped source code),
VirtualTag (generation-scoped calculated-tag config), ScriptedAlarm (generation-scoped
alarm config), and ScriptedAlarmState (logical-id-keyed persistent runtime state).

## New entities (net10, EF Core)

- Script — stable logical ScriptId carries across generations; SourceHash is the
  compile-cache key (matches Core.Scripting's CompiledScriptCache).
- VirtualTag — mandatory EquipmentId FK (plan decision #2, unified Equipment tree);
  ChangeTriggered/TimerIntervalMs + Historize flags; check constraints enforce
  "at least one trigger" + "timer >= 50ms".
- ScriptedAlarm — required AlarmType ('AlarmCondition'/'LimitAlarm'/'OffNormalAlarm'/
  'DiscreteAlarm'); Severity 1..1000 range check; HistorizeToAveva default true per
  plan decision #15.
- ScriptedAlarmState — keyed ONLY on ScriptedAlarmId (NOT generation-scoped) per plan
  decision #14 — ack state + audit trail must follow alarm identity across Modified
  generations. CommentsJson has ISJSON check for GxP audit.

## Migration

EF-generated 20260420231641_AddPhase7ScriptingTables covers all 4 tables + indexes +
check constraints + FKs to ConfigGeneration. sp_PublishGeneration required no changes —
it only flips Draft->Published status; the new entities already carry GenerationId so
they publish atomically with the rest of the config.

## Tests — 12/12 (design-time model introspection)

Phase7ScriptingEntitiesTests covers: table registration, column maxlength + column
types, unique indexes (Generation+LogicalId, Generation+EquipmentPath for VirtualTag
and ScriptedAlarm), secondary indexes (SourceHash for cache lookup), check constraints
(trigger-required, timer-min, severity-range, alarm-type-enum, CommentsJson-IsJson),
ScriptedAlarmState PK is alarm-id not generation-scoped, ScriptedAlarm defaults
(HistorizeToAveva=true, Retain=true, Severity=500, Enabled=true), DbSets wired, and
the generated migration type exists for rollforward.
2026-04-20 19:22:45 -04:00
dccaa11510 Merge pull request 'Phase 7 Stream D — Historian alarm sink (SQLite store-and-forward + Galaxy.Host IPC contracts)' (#182) from phase-7-stream-d-alarm-historian into v2 2026-04-20 19:14:01 -04:00
Joseph Doherty
25ad4b1929 Phase 7 Stream D — Historian alarm sink (SQLite store-and-forward + Galaxy.Host IPC contracts)
Phase 7 plan decisions #16, #17, #19, #21 implementation. Durable local SQLite queue
absorbs every qualifying alarm event; drain worker forwards batches to Galaxy.Host
(reusing the already-loaded 32-bit aahClientManaged DLLs) on an exponential-backoff
cadence; operator acks never block on the historian being reachable.

## New project Core.AlarmHistorian (net10)

- AlarmHistorianEvent — source-agnostic event shape (scripted alarms + Galaxy-native +
  AB CIP ALMD + any future IAlarmSource)
- IAlarmHistorianSink / NullAlarmHistorianSink — interface + disabled default
- IAlarmHistorianWriter — per-event outcome (Ack / RetryPlease / PermanentFail); Stream G
  wires the Galaxy.Host IPC client implementation
- SqliteStoreAndForwardSink — full implementation:
  - Queue table with AttemptCount / LastError / DeadLettered columns
  - DrainOnceAsync serialised via SemaphoreSlim
  - BackoffLadder 1s → 2s → 5s → 15s → 60s (cap)
  - DefaultCapacity 1,000,000 rows — overflow evicts oldest non-dead-lettered
  - DefaultDeadLetterRetention 30 days — sweeper purges on every drain tick
  - RetryDeadLettered operator action reattaches dead-letters to the regular queue
  - Writer-side exceptions treated as whole-batch RetryPlease (no data loss)

## New IPC contracts in Driver.Galaxy.Shared

- HistorianAlarmEventRequest — batched up to 100 events/request per plan Stream D.5
- HistorianAlarmEventResponse — per-event outcome (1:1 with request order)
- HistorianAlarmEventOutcomeDto enum (byte on the wire — Ack/RetryPlease/PermanentFail)
- HistorianAlarmEventDto — mirrors Core.AlarmHistorian.AlarmHistorianEvent
- HistorianConnectivityStatusNotification — Host pushes proactively when the SDK
  session drops so /alarms/historian flips red without waiting for the next drain
- MessageKind additions: 0x80 HistorianAlarmEventRequest / 0x81 HistorianAlarmEventResponse
  / 0x82 HistorianConnectivityStatus

## Tests — 14/14

SqliteStoreAndForwardSinkTests covers: enqueue→drain→Ack round-trip, empty-queue no-op,
RetryPlease bumps backoff + keeps row, Ack after Retry resets backoff, PermanentFail
dead-letters one row without blocking neighbors, writer exception treated as whole-batch
retry with error surfaced in status, capacity eviction drops oldest non-dead-lettered,
dead-letters purged past retention window, RetryDeadLettered requeues, ladder caps at
60s after 10 retries, Null sink reports Disabled status, null sink swallows enqueue,
ctor argument validation, disposed sink rejects enqueue.

## Totals
Full Phase 7 tests: 160 green (63 Scripting + 36 VirtualTags + 47 ScriptedAlarms +
14 AlarmHistorian). Stream G wires this into the real Galaxy.Host IPC pipe.
2026-04-20 19:11:17 -04:00
51d0b27bfd Merge pull request 'Phase 7 Stream C — Core.ScriptedAlarms (Part 9 state machine + predicate engine + IAlarmSource)' (#181) from phase-7-stream-c-scripted-alarms into v2 2026-04-20 18:52:11 -04:00
Joseph Doherty
df39809526 Phase 7 Stream C — Core.ScriptedAlarms project (Part 9 state machine + predicate engine + IAlarmSource adapter)
Ships the Part 9 alarm fidelity layer Phase 7 committed to in plan decision #5. Every scripted alarm gets a full OPC UA AlarmConditionType state machine — EnabledState, ActiveState, AckedState, ConfirmedState, ShelvingState — with persistent operator-supplied state across server restarts per Phase 7 plan decision #14. Runtime shape matches the Galaxy-native + AB CIP ALMD alarm sources: scripted alarms fan out through the existing IAlarmSource surface so Phase 6.1 AlarmTracker composition consumes them without per-source branching.

Part9StateMachine is a pure-functions module — no instance state, no I/O, no mutation. Every transition (ApplyPredicate, ApplyAcknowledge, ApplyConfirm, ApplyOneShotShelve, ApplyTimedShelve, ApplyUnshelve, ApplyEnable, ApplyDisable, ApplyAddComment, ApplyShelvingCheck) takes the current AlarmConditionState record plus the event and returns a fresh state + EmissionKind hint. Two structural invariants enforced: disabled alarms never transition ActiveState / AckedState / ConfirmedState; shelved alarms still advance state (so startup recovery reflects reality) but emit a Suppressed hint so subscribers do not see the transition. OneShot shelving expires on clear; Timed shelving expires via ApplyShelvingCheck against the UnshelveAtUtc timestamp. Comments are append-only — every acknowledge, confirm, shelve, unshelve, enable, disable, explicit add-comment, and auto-unshelve appends an AlarmComment record with user identity + timestamp + kind + text for the GxP / 21 CFR Part 11 audit surface.

AlarmConditionState is the persistent record the store saves. Fields: AlarmId, Enabled, Active, Acked, Confirmed, Shelving (kind + UnshelveAtUtc), LastTransitionUtc, LastActiveUtc, LastClearedUtc, LastAckUtc + LastAckUser + LastAckComment, LastConfirmUtc + LastConfirmUser + LastConfirmComment, Comments. Fresh factory initializes everything to the no-event position.

IAlarmStateStore is the persistence abstraction — LoadAsync, LoadAllAsync, SaveAsync, RemoveAsync. Stream E wires this to a SQL-backed store with IAuditLogger hooks; tests use InMemoryAlarmStateStore. Startup recovery per Phase 7 plan decision #14: LoadAsync runs every configured alarm predicate against current tag values to rederive ActiveState, but EnabledState / AckedState / ConfirmedState / ShelvingState + audit history are loaded verbatim from the store so operators do not re-ack after an outage and shelved alarms stay shelved through maintenance windows.

MessageTemplate implements Phase 7 plan decision #13 — static-with-substitution. {TagPath} tokens resolved at event emission time from the engine value cache. Missing paths, non-Good quality, or null values all resolve to {?} so the event still fires but the operator sees where the reference broke. ExtractTokenPaths enumerates tokens at publish time so the engine knows to subscribe to every template-referenced tag in addition to predicate-referenced tags.

AlarmPredicateContext is the ScriptContext subclass alarm scripts see. GetTag reads from the engine shared cache; SetVirtualTag is explicitly rejected at runtime with a pointed error message — alarm predicates must be pure so their output does not couple to virtual-tag state in ways that become impossible to reason about. If cross-tag side effects are needed, the operator authors a virtual tag and the alarm predicate reads it.

ScriptedAlarmEngine orchestrates. LoadAsync compiles every predicate through Stream A ScriptSandbox + ForbiddenTypeAnalyzer, runs DependencyExtractor to find the read set, adds template token paths to the input set, reports every compile failure as one aggregated InvalidOperationException (not one-at-a-time), subscribes to each unique referenced upstream path, seeds the value cache, loads persisted state for each alarm (falling back to Fresh for first-load), re-evaluates the predicate, and saves the recovered state. ChangeTrigger — when an upstream tag changes, look up every alarm referencing that path in a per-path inverse index, enqueue all of them for re-evaluation via a SemaphoreSlim-gated path. Unlike the virtual-tag engine, scripted alarms are leaves in the evaluation DAG (no alarm drives another alarm), so no topological sort is needed. Operator actions (AcknowledgeAsync, ConfirmAsync, OneShotShelveAsync, TimedShelveAsync, UnshelveAsync, EnableAsync, DisableAsync, AddCommentAsync) route through the state machine, persist, and emit if there is an emission. A 5-second shelving-check timer auto-expires Timed shelving and emits Unshelved events at the right moment. Predicate evaluation errors (script throws, timeout, compile-time reads bad tag) leave the state unchanged — the engine does NOT invent a clear transition on predicate failure. Logged as scripts-*.log Error; companion WARN in main log.

ScriptedAlarmSource implements IAlarmSource. SubscribeAlarmsAsync filter is a set of equipment-path prefixes; empty means all. AcknowledgeAsync from the base interface routes to the engine with user identity "opcua-client" — Stream G will replace this with the authenticated principal from the OPC UA dispatch layer. The adapter implements only the base IAlarmSource methods; richer Part 9 methods (Confirm, Shelve, Unshelve, AddComment) remain on the engine and will bind to OPC UA method nodes in Stream G.

47 unit tests across 5 files. Part9StateMachineTests (16) — every transition + noop edge cases: predicate true/false, same-state noop, disabled ignores predicate, acknowledge records user/comment/adds audit, idempotent acknowledge, reject no-user ack, full activate-ack-clear-confirm walk, one-shot shelve suppresses next activation, one-shot expires on clear, timed shelve requires future unshelve time, timed shelve expires via shelving-check, explicit unshelve emits, add-comment appends to audit, comments append-only through multiple operations, full lifecycle walk emits every expected EmissionKind. MessageTemplateTests (11) — no-token passthrough, single+multiple token substitution, bad quality becomes {?}, unknown path becomes {?}, null value becomes {?}, tokens with slashes+dots, empty + null template, ExtractTokenPaths returns every distinct path, whitespace inside tokens trimmed. ScriptedAlarmEngineTests (13) — load compiles+subscribes, compile failures aggregated, upstream change emits Activated, clearing emits Cleared, message template resolves at emission, ack persists to store, startup recovery preserves ack but rederives active, shelved activation state-advances but suppresses emission, runtime exception isolates to owning alarm, disable prevents activation until re-enable, AddComment appends audit without state change, SetVirtualTag from predicate rejected (state unchanged), Dispose releases upstream subscriptions. ScriptedAlarmSourceTests (5) — empty filter matches all, equipment-prefix filter, Unsubscribe stops events, AcknowledgeAsync routes with default user, null arguments rejected. FakeUpstream fixture gives tests an in-memory driver mock with subscription count tracking.

Full Phase 7 test count after Stream C: 146 green (63 Scripting + 36 VirtualTags + 47 ScriptedAlarms). Stream D (historian alarm sink with SQLite store-and-forward + Galaxy.Host IPC) consumes ScriptedAlarmEvent + similar Galaxy / AB CIP emissions to produce the unified alarm timeline. Stream G wires the OPC UA method calls and AlarmSource into DriverNodeManager dispatch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 18:49:48 -04:00
2a8bcc8f60 Merge pull request 'Phase 7 Stream B — Core.VirtualTags engine + dep graph + timer + source' (#180) from phase-7-stream-b-virtual-tag-engine into v2 2026-04-20 17:05:13 -04:00
Joseph Doherty
479af166ab Phase 7 Stream B — Core.VirtualTags project (engine + dep graph + timer + source)
Ships the evaluation engine that consumes compiled scripts from Stream A, subscribes to upstream driver tags, runs on change + on timer, cascades evaluations through dependent virtual tags in topological order, and emits changes through a driver-capability-shaped adapter the DriverNodeManager can dispatch to per ADR-002.

DependencyGraph owns the directed dep-graph where nodes are tag paths (driver tags implicit leaves, virtual tags registered internal nodes) and edges run from a virtual tag to each tag it reads. Kahn algorithm produces the topological sort. Tarjan iterative SCC detects every cycle in one pass so publish-time rejection surfaces all offending cycles together. Both iterative so 10k-deep chains do not StackOverflow. Re-adding a node overwrites prior dependency set cleanly (supports config-publish reloads).

VirtualTagDefinition is the operator-authored config row (Path, DataType, ScriptSource, ChangeTriggered, TimerInterval, Historize). Stream E config DB materializes these on publish.

ITagUpstreamSource is the abstraction the engine pulls driver tag values from. Stream G bridges this to IReadable + ISubscribable on live drivers; tests use FakeUpstream that tracks subscription count for leak-test assertions.

IHistoryWriter is the per-tag Historize sink. NullHistoryWriter default when caller does not pass one.

VirtualTagContext is the per-evaluation ScriptContext. Reads from engine last-known-value cache, writes route through SetVirtualTag callback so cross-tag side effects participate in change cascades. Injectable Now clock for deterministic tests.

VirtualTagEngine orchestrates. Load compiles every script via ScriptSandbox, builds the dep graph via DependencyExtractor, checks for cycles, reports every compile failure in one error, subscribes to each referenced upstream path, seeds the value cache. EvaluateAllAsync runs topological order. EvaluateOneAsync is timer path. Read returns cached value. Subscribe registers observer. OnUpstreamChange updates cache, fans out, schedules transitive dependents (change-driven=false tags skipped). EvaluateInternalAsync holds a SemaphoreSlim so cascades do not interleave. Script exceptions and timeouts map per-tag to BadInternalError. Coercion from script double to config Int32 uses Convert.ToInt32.

TimerTriggerScheduler groups tags by interval into shared Timers. Tags without TimerInterval not scheduled.

VirtualTagSource implements IReadable + ISubscribable per ADR-002. ReadAsync returns cache. SubscribeAsync fires initial-data callback per OPC UA convention. IWritable deliberately not implemented — OPC UA writes to virtual tags rejected in DriverNodeManager per Phase 7 decision 6.

36 unit tests across 4 files: DependencyGraphTests 12, VirtualTagEngineTests 13, VirtualTagSourceTests 6, TimerTriggerSchedulerTests 4. Coverage includes cycle detection (self-loop, 2-node, 3-node, multiple disjoint), 2-level change cascade, per-tag error isolation (one tag throws, others keep working), timeout isolation, Historize toggle, ChangeTriggered=false ignore, reload cleans subscriptions, Dispose releases resources, SetVirtualTag fires observers, type coercion, 10k deep graph no stack overflow, initial-data callback, Unsubscribe stops events.

Fixed two bugs during implementation. Monitor.Enter/Exit cannot be held across await (Monitor ownership is thread-local and lost across suspension) — switched to SemaphoreSlim. Kahn edge-direction was inverted — for dependency ordering (X depends on Y means Y comes before X) in-degree should be count of a node own deps, not count of nodes pointing to it; was incrementing inDegree[dep] instead of inDegree[nodeId], causing false cycle detection on valid DAGs.

Full Phase 7 test count after Stream B: 99 green (63 Scripting + 36 VirtualTags). Streams C and G will plug engine + source into live OPC UA dispatch path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 17:02:50 -04:00
00724e9784 Merge pull request 'Phase 7 Stream A.3 — ScriptLoggerFactory + ScriptLogCompanionSink (closes Stream A)' (#179) from phase-7-stream-a3-script-logger into v2 2026-04-20 16:45:09 -04:00
78 changed files with 11580 additions and 16 deletions

View File

@@ -4,6 +4,9 @@
<Project Path="src/ZB.MOM.WW.OtOpcUa.Configuration/ZB.MOM.WW.OtOpcUa.Configuration.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Core/ZB.MOM.WW.OtOpcUa.Core.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/ZB.MOM.WW.OtOpcUa.Core.VirtualTags.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Server/ZB.MOM.WW.OtOpcUa.Server.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Admin/ZB.MOM.WW.OtOpcUa.Admin.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.csproj"/>
@@ -28,8 +31,12 @@
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Core.Tests/ZB.MOM.WW.OtOpcUa.Core.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Core.VirtualTags.Tests/ZB.MOM.WW.OtOpcUa.Core.VirtualTags.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Server.Tests/ZB.MOM.WW.OtOpcUa.Server.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/ZB.MOM.WW.OtOpcUa.Admin.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.csproj"/>

View File

@@ -0,0 +1,79 @@
# Phase 7 Exit Gate — Scripting, Virtual Tags, Scripted Alarms, Historian Sink
> **Status**: Open. Closed when every compliance check passes + every deferred item either ships or is filed as a post-v2-release follow-up.
>
> **Compliance script**: `scripts/compliance/phase-7-compliance.ps1`
> **Plan doc**: `docs/v2/implementation/phase-7-scripting-and-alarming.md`
## What shipped
| Stream | PR | Summary |
|--------|-----|---------|
| A | #177#179 | `Core.Scripting` — Roslyn sandbox + `DependencyExtractor` + `ForbiddenTypeAnalyzer` + per-script Serilog sink + 63 tests |
| B | #180 | `Core.VirtualTags` — dep graph (iterative Tarjan) + engine + timer scheduler + `VirtualTagSource` + 36 tests |
| C | #181 | `Core.ScriptedAlarms` — Part 9 state machine + predicate engine + message template + `ScriptedAlarmSource` + 47 tests |
| D | #182 | `Core.AlarmHistorian` — SQLite store-and-forward + backoff ladder + dead-letter retention + Galaxy.Host IPC contracts + 14 tests |
| E | #183 | Config DB schema — `Script` / `VirtualTag` / `ScriptedAlarm` / `ScriptedAlarmState` entities + migration + 12 tests |
| F | #185 | Admin UI — `ScriptService` / `VirtualTagService` / `ScriptedAlarmService` / `ScriptTestHarnessService` / `HistorianDiagnosticsService` + Monaco editor + `/alarms/historian` page + 13 tests |
| G | #184 | Walker emits Virtual + ScriptedAlarm variables with `NodeSourceKind` discriminator + 5 tests |
| G follow-up | #186 | `DriverNodeManager` dispatch routes by `NodeSourceKind` + writes rejected for non-Driver sources + 7 tests |
**Phase 7 totals**: ~197 new tests across 7 projects. Plan decisions #1#22 all realised in code.
## Compliance Checks (run at exit gate)
Covered by `scripts/compliance/phase-7-compliance.ps1`:
- [x] Roslyn sandbox anchored on `ScriptContext` assembly with `ForbiddenTypeAnalyzer` defense-in-depth (plan #6)
- [x] `DependencyExtractor` rejects non-literal tag paths with source spans (plan #7)
- [x] Per-script rolling Serilog sink + companion-forwarding Error+ to main log (plan #12)
- [x] VirtualTag dep graph uses iterative SCC — no stack overflow on 10 000-deep chains
- [x] `VirtualTagSource` implements `IReadable` + `ISubscribable` per ADR-002
- [x] Part 9 state machine covers every transition (Apply/Ack/Confirm/Shelve/Unshelve/Enable/Disable/Comment/ShelvingCheck)
- [x] `AlarmPredicateContext` rejects `SetVirtualTag` at runtime (predicates must be pure)
- [x] `MessageTemplate` substitutes `{TagPath}` tokens at event emission (plan #13); missing/bad → `{?}`
- [x] SQLite sink backoff ladder 1s → 2s → 5s → 15s → 60s cap (plan #16)
- [x] Default 1M-row capacity + 30-day dead-letter retention (plan #21)
- [x] Per-event outcomes Ack/RetryPlease/PermanentFail on the wire
- [x] Galaxy.Host IPC contracts (`HistorianAlarmEventRequest` / `Response` / `ConnectivityStatusNotification`)
- [x] Config DB check constraints: trigger-required, timer-min, severity-range, alarm-type-enum, JSON comments
- [x] `ScriptedAlarmState` keyed on `ScriptedAlarmId` (not generation-scoped) per plan #14
- [x] Admin services: SourceHash preserves compile-cache hit on rename; Update recomputes on source change
- [x] `ScriptTestHarnessService` enforces declared-inputs-only contract (plan #22)
- [x] Monaco editor via CDN + textarea fallback (plan #18)
- [x] `/alarms/historian` page with Retry-dead-lettered operator action
- [x] Walker emits `NodeSourceKind.Virtual` + `NodeSourceKind.ScriptedAlarm` variables
- [x] `DriverNodeManager` dispatch routes Reads by source; Writes to non-Driver rejected with `BadUserAccessDenied` (plan #6)
## Deferred to Post-Gate Follow-ups
Kept out of the capstone so the gate can close cleanly while the less-critical wiring lands in targeted PRs:
- [ ] **SealedBootstrap composition root** (task #239) — instantiate `VirtualTagEngine` + `ScriptedAlarmEngine` + `SqliteStoreAndForwardSink` in `Program.cs`; pass `VirtualTagSource` + `ScriptedAlarmSource` as the new `IReadable` parameters on `DriverNodeManager`. Without this, the engines are dormant in production even though every piece is tested.
- [ ] **Live OPC UA end-to-end smoke** (task #240) — Client.CLI browse + read a virtual tag computed by Roslyn; Client.CLI acknowledge a scripted alarm via the Part 9 method node; historian-disabled deployment returns `BadNotFound` for virtual nodes rather than silent failure.
- [ ] **sp_ComputeGenerationDiff extension** (task #241) — emit Script / VirtualTag / ScriptedAlarm sections alongside the existing Namespace/DriverInstance/Equipment/Tag/NodeAcl rows so the Admin DiffViewer shows Phase 7 changes between generations.
## Completion Checklist
- [x] Stream A shipped + merged
- [x] Stream B shipped + merged
- [x] Stream C shipped + merged
- [x] Stream D shipped + merged
- [x] Stream E shipped + merged
- [x] Stream F shipped + merged
- [x] Stream G shipped + merged
- [x] Stream G follow-up (dispatch) shipped + merged
- [x] `phase-7-compliance.ps1` present and passes
- [x] Full solution `dotnet test` passes (no new failures beyond pre-existing tolerated CLI flake)
- [x] Exit-gate doc checked in
- [ ] `SealedBootstrap` composition follow-up filed + tracked
- [ ] Live end-to-end smoke follow-up filed + tracked
- [ ] `sp_ComputeGenerationDiff` extension follow-up filed + tracked
## How to run
```powershell
pwsh ./scripts/compliance/phase-7-compliance.ps1
```
Exit code 0 = all pass; non-zero = failures listed in the preceding `[FAIL]` lines.

View File

@@ -0,0 +1,151 @@
<#
.SYNOPSIS
Phase 7 exit-gate compliance check. Each check either passes or records a failure;
non-zero exit = fail.
.DESCRIPTION
Validates Phase 7 (scripting runtime + virtual tags + scripted alarms + historian
alarm sink + Admin UI + address-space integration) per
`docs/v2/implementation/phase-7-scripting-and-alarming.md`.
.NOTES
Usage: pwsh ./scripts/compliance/phase-7-compliance.ps1
Exit: 0 = all checks passed; non-zero = one or more FAILs
#>
[CmdletBinding()]
param()
$ErrorActionPreference = 'Stop'
$script:failures = 0
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
function Assert-Pass { param([string]$C) Write-Host " [PASS] $C" -ForegroundColor Green }
function Assert-Fail { param([string]$C, [string]$R) Write-Host " [FAIL] $C - $R" -ForegroundColor Red; $script:failures++ }
function Assert-Deferred { param([string]$C, [string]$P) Write-Host " [DEFERRED] $C (follow-up: $P)" -ForegroundColor Yellow }
function Assert-FileExists {
param([string]$C, [string]$P)
if (Test-Path (Join-Path $repoRoot $P)) { Assert-Pass "$C ($P)" }
else { Assert-Fail $C "missing file: $P" }
}
function Assert-TextFound {
param([string]$C, [string]$Pat, [string[]]$Paths)
foreach ($p in $Paths) {
$full = Join-Path $repoRoot $p
if (-not (Test-Path $full)) { continue }
if (Select-String -Path $full -Pattern $Pat -Quiet) {
Assert-Pass "$C (matched in $p)"
return
}
}
Assert-Fail $C "pattern '$Pat' not found in any of: $($Paths -join ', ')"
}
Write-Host ""
Write-Host "=== Phase 7 compliance - scripting + virtual tags + scripted alarms + historian ===" -ForegroundColor Cyan
Write-Host ""
Write-Host "Stream A - Core.Scripting (Roslyn + sandbox + AST inference + logger)"
Assert-FileExists "Core.Scripting project" "src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj"
Assert-TextFound "ScriptSandbox allow-list anchored on ScriptContext assembly" "contextType\.Assembly" @("src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptSandbox.cs")
Assert-TextFound "ForbiddenTypeAnalyzer defense-in-depth (plan decision #6)" "class ForbiddenTypeAnalyzer" @("src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ForbiddenTypeAnalyzer.cs")
Assert-TextFound "DependencyExtractor rejects non-literal paths (plan decision #7)" "class DependencyExtractor" @("src/ZB.MOM.WW.OtOpcUa.Core.Scripting/DependencyExtractor.cs")
Assert-TextFound "Per-script log companion sink forwards Error+ to main log (plan decision #12)" "class ScriptLogCompanionSink" @("src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptLogCompanionSink.cs")
Assert-TextFound "ScriptContext static Deadband helper" "static bool Deadband" @("src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptContext.cs")
Write-Host ""
Write-Host "Stream B - Core.VirtualTags (dependency graph + change/timer + source)"
Assert-FileExists "Core.VirtualTags project" "src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/ZB.MOM.WW.OtOpcUa.Core.VirtualTags.csproj"
Assert-TextFound "DependencyGraph iterative Tarjan SCC (no stack overflow on 10k chains)" "class DependencyGraph" @("src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/DependencyGraph.cs")
Assert-TextFound "VirtualTagEngine SemaphoreSlim async-safe cascade" "SemaphoreSlim" @("src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagEngine.cs")
Assert-TextFound "VirtualTagSource IReadable + ISubscribable per ADR-002" "class VirtualTagSource" @("src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagSource.cs")
Assert-TextFound "TimerTriggerScheduler groups by interval" "class TimerTriggerScheduler" @("src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/TimerTriggerScheduler.cs")
Write-Host ""
Write-Host "Stream C - Core.ScriptedAlarms (Part 9 state machine + predicate engine + IAlarmSource)"
Assert-FileExists "Core.ScriptedAlarms project" "src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.csproj"
Assert-TextFound "Part9StateMachine pure functions" "class Part9StateMachine" @("src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/Part9StateMachine.cs")
Assert-TextFound "Alarm condition state with GxP audit Comments list" "Comments" @("src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/AlarmConditionState.cs")
Assert-TextFound "MessageTemplate {TagPath} substitution (plan decision #13)" "class MessageTemplate" @("src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/MessageTemplate.cs")
Assert-TextFound "AlarmPredicateContext rejects SetVirtualTag (predicates must be pure)" "class AlarmPredicateContext" @("src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/AlarmPredicateContext.cs")
Assert-TextFound "ScriptedAlarmSource implements IAlarmSource" "class ScriptedAlarmSource" @("src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmSource.cs")
Assert-TextFound "IAlarmStateStore abstraction + in-memory default" "class InMemoryAlarmStateStore" @("src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/IAlarmStateStore.cs")
Write-Host ""
Write-Host "Stream D - Core.AlarmHistorian (SQLite store-and-forward + Galaxy.Host IPC contracts)"
Assert-FileExists "Core.AlarmHistorian project" "src/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.csproj"
Assert-TextFound "SqliteStoreAndForwardSink backoff ladder (1s..60s cap)" "BackoffLadder" @("src/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/SqliteStoreAndForwardSink.cs")
Assert-TextFound "Default 1M row capacity + 30-day dead-letter retention (plan decision #21)" "DefaultDeadLetterRetention" @("src/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/SqliteStoreAndForwardSink.cs")
Assert-TextFound "Per-event outcomes (Ack/RetryPlease/PermanentFail)" "HistorianWriteOutcome" @("src/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/IAlarmHistorianSink.cs")
Assert-TextFound "Galaxy.Host IPC contract HistorianAlarmEventRequest" "class HistorianAlarmEventRequest" @("src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/HistorianAlarms.cs")
Assert-TextFound "Historian connectivity status notification" "HistorianConnectivityStatusNotification" @("src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/HistorianAlarms.cs")
Write-Host ""
Write-Host "Stream E - Config DB schema"
Assert-FileExists "Script entity" "src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Script.cs"
Assert-FileExists "VirtualTag entity" "src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/VirtualTag.cs"
Assert-FileExists "ScriptedAlarm entity" "src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ScriptedAlarm.cs"
Assert-FileExists "ScriptedAlarmState entity (logical-id keyed per plan decision #14)" "src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ScriptedAlarmState.cs"
Assert-TextFound "VirtualTag trigger check constraint (change OR timer)" "CK_VirtualTag_Trigger_AtLeastOne" @("src/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs")
Assert-TextFound "ScriptedAlarm severity range check" "CK_ScriptedAlarm_Severity_Range" @("src/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs")
Assert-TextFound "ScriptedAlarm type enum check" "CK_ScriptedAlarm_AlarmType" @("src/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs")
Assert-TextFound "ScriptedAlarmState.CommentsJson is ISJSON (GxP audit)" "CK_ScriptedAlarmState_CommentsJson_IsJson" @("src/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs")
Assert-FileExists "Phase 7 migration present" "src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260420231641_AddPhase7ScriptingTables.cs"
Write-Host ""
Write-Host "Stream F - Admin UI (services + Monaco editor + test harness + historian diagnostics)"
Assert-FileExists "ScriptService" "src/ZB.MOM.WW.OtOpcUa.Admin/Services/ScriptService.cs"
Assert-FileExists "VirtualTagService" "src/ZB.MOM.WW.OtOpcUa.Admin/Services/VirtualTagService.cs"
Assert-FileExists "ScriptedAlarmService" "src/ZB.MOM.WW.OtOpcUa.Admin/Services/ScriptedAlarmService.cs"
Assert-FileExists "ScriptTestHarnessService" "src/ZB.MOM.WW.OtOpcUa.Admin/Services/ScriptTestHarnessService.cs"
Assert-FileExists "HistorianDiagnosticsService" "src/ZB.MOM.WW.OtOpcUa.Admin/Services/HistorianDiagnosticsService.cs"
Assert-FileExists "ScriptEditor Razor component" "src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ScriptEditor.razor"
Assert-FileExists "ScriptsTab Razor component" "src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ScriptsTab.razor"
Assert-FileExists "AlarmsHistorian diagnostics page" "src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/AlarmsHistorian.razor"
Assert-FileExists "Monaco loader (CDN progressive enhancement)" "src/ZB.MOM.WW.OtOpcUa.Admin/wwwroot/js/monaco-loader.js"
Assert-TextFound "Scripts tab wired into DraftEditor" "ScriptsTab " @("src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DraftEditor.razor")
Assert-TextFound "Harness enforces declared-inputs-only contract (plan decision #22)" "UnknownInputs" @("src/ZB.MOM.WW.OtOpcUa.Admin/Services/ScriptTestHarnessService.cs")
Write-Host ""
Write-Host "Stream G - Address-space integration"
Assert-TextFound "NodeSourceKind discriminator in DriverAttributeInfo" "enum NodeSourceKind" @("src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverAttributeInfo.cs")
Assert-TextFound "Walker emits VirtualTag variables with Source=Virtual" "AddVirtualTagVariable" @("src/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs")
Assert-TextFound "Walker emits ScriptedAlarm variables with Source=ScriptedAlarm + IsAlarm" "AddScriptedAlarmVariable" @("src/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs")
Assert-TextFound "EquipmentNamespaceContent carries VirtualTags + ScriptedAlarms" "VirtualTags" @("src/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs")
Assert-TextFound "DriverNodeManager selects IReadable by source kind" "SelectReadable" @("src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs")
Assert-TextFound "Virtual/ScriptedAlarm writes rejected (plan decision #6)" "IsWriteAllowedBySource" @("src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs")
Write-Host ""
Write-Host "Deferred surfaces"
Assert-Deferred "SealedBootstrap composition root wiring (VirtualTagEngine + ScriptedAlarmEngine + SqliteStoreAndForwardSink)" "task #239"
Assert-Deferred "Live OPC UA end-to-end test (virtual-tag Read + scripted-alarm Ack via method node)" "task #240"
Assert-Deferred "sp_ComputeGenerationDiff extension for Script/VirtualTag/ScriptedAlarm sections" "task #241"
Write-Host ""
Write-Host "Cross-cutting"
Write-Host " Running full solution test suite..." -ForegroundColor DarkGray
$prevPref = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
$testOutput = & dotnet test (Join-Path $repoRoot 'ZB.MOM.WW.OtOpcUa.slnx') --nologo 2>&1
$ErrorActionPreference = $prevPref
$passLine = $testOutput | Select-String 'Passed:\s+(\d+)' -AllMatches
$failLine = $testOutput | Select-String 'Failed:\s+(\d+)' -AllMatches
$passCount = 0; foreach ($m in $passLine.Matches) { $passCount += [int]$m.Groups[1].Value }
$failCount = 0; foreach ($m in $failLine.Matches) { $failCount += [int]$m.Groups[1].Value }
# Phase 6.4 exit-gate baseline was 1137; Phase 7 adds ~197 across 7 streams.
$baseline = 1300
if ($passCount -ge $baseline) { Assert-Pass "No test-count regression ($passCount >= $baseline pre-Phase-7-exit baseline)" }
else { Assert-Fail "Test-count regression" "passed $passCount < baseline $baseline" }
if ($failCount -le 1) { Assert-Pass "No new failing tests (pre-existing CLI flake tolerated)" }
else { Assert-Fail "New failing tests" "$failCount failures > 1 tolerated" }
Write-Host ""
if ($script:failures -eq 0) {
Write-Host "Phase 7 compliance: PASS" -ForegroundColor Green
exit 0
}
Write-Host "Phase 7 compliance: $script:failures FAIL(s)" -ForegroundColor Red
exit 1

View File

@@ -0,0 +1,79 @@
@page "/alarms/historian"
@using ZB.MOM.WW.OtOpcUa.Admin.Services
@using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian
@inject HistorianDiagnosticsService Diag
<h1>Alarm historian</h1>
<p class="text-muted">Local store-and-forward queue that ships alarm events to Aveva Historian via Galaxy.Host.</p>
<div class="card mb-3">
<div class="card-body">
<div class="row">
<div class="col-md-3">
<small class="text-muted">Drain state</small>
<h4><span class="badge @BadgeFor(_status.DrainState)">@_status.DrainState</span></h4>
</div>
<div class="col-md-3">
<small class="text-muted">Queue depth</small>
<h4>@_status.QueueDepth.ToString("N0")</h4>
</div>
<div class="col-md-3">
<small class="text-muted">Dead-letter depth</small>
<h4 class="@(_status.DeadLetterDepth > 0 ? "text-warning" : "")">@_status.DeadLetterDepth.ToString("N0")</h4>
</div>
<div class="col-md-3">
<small class="text-muted">Last success</small>
<h4>@(_status.LastSuccessUtc?.ToString("u") ?? "—")</h4>
</div>
</div>
@if (!string.IsNullOrEmpty(_status.LastError))
{
<div class="alert alert-warning mt-3 mb-0">
<strong>Last error:</strong> @_status.LastError
</div>
}
</div>
</div>
<div class="d-flex gap-2">
<button class="btn btn-outline-secondary" @onclick="RefreshAsync">Refresh</button>
<button class="btn btn-warning" disabled="@(_status.DeadLetterDepth == 0)" @onclick="RetryDeadLetteredAsync">
Retry dead-lettered (@_status.DeadLetterDepth)
</button>
</div>
@if (_retryResult is not null)
{
<div class="alert alert-success mt-3">Requeued @_retryResult row(s) for retry.</div>
}
@code {
private HistorianSinkStatus _status = new(0, 0, null, null, null, HistorianDrainState.Disabled);
private int? _retryResult;
protected override void OnInitialized() => _status = Diag.GetStatus();
private Task RefreshAsync()
{
_status = Diag.GetStatus();
_retryResult = null;
return Task.CompletedTask;
}
private Task RetryDeadLetteredAsync()
{
_retryResult = Diag.TryRetryDeadLettered();
_status = Diag.GetStatus();
return Task.CompletedTask;
}
private static string BadgeFor(HistorianDrainState s) => s switch
{
HistorianDrainState.Idle => "bg-success",
HistorianDrainState.Draining => "bg-info",
HistorianDrainState.BackingOff => "bg-warning text-dark",
HistorianDrainState.Disabled => "bg-secondary",
_ => "bg-secondary",
};
}

View File

@@ -23,6 +23,7 @@
<li class="nav-item"><button class="nav-link @Active("namespaces")" @onclick='() => _tab = "namespaces"'>Namespaces</button></li>
<li class="nav-item"><button class="nav-link @Active("drivers")" @onclick='() => _tab = "drivers"'>Drivers</button></li>
<li class="nav-item"><button class="nav-link @Active("acls")" @onclick='() => _tab = "acls"'>ACLs</button></li>
<li class="nav-item"><button class="nav-link @Active("scripts")" @onclick='() => _tab = "scripts"'>Scripts</button></li>
</ul>
<div class="row">
@@ -32,6 +33,7 @@
else if (_tab == "namespaces") { <NamespacesTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
else if (_tab == "drivers") { <DriversTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
else if (_tab == "acls") { <AclsTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
else if (_tab == "scripts") { <ScriptsTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
</div>
<div class="col-md-4">
<div class="card sticky-top">

View File

@@ -0,0 +1,41 @@
@using Microsoft.AspNetCore.Components.Web
@inject IJSRuntime JS
@*
Monaco-backed C# code editor (Phase 7 Stream F). Progressive enhancement:
textarea renders immediately, Monaco mounts via JS interop after first render.
Monaco script tags are loaded once from the parent layout (wwwroot/js/monaco-loader.js
pulls the CDN bundle).
Stream F keeps the interop surface small — bind `Source` two-way, and the parent
tab re-renders on change for the dependency preview. The test-harness button
lives in the parent so one editor can drive multiple script types.
*@
<div class="script-editor">
<textarea class="form-control font-monospace" rows="14" spellcheck="false"
@bind="Source" @bind:event="oninput" id="@_editorId">@Source</textarea>
</div>
@code {
[Parameter] public string Source { get; set; } = string.Empty;
[Parameter] public EventCallback<string> SourceChanged { get; set; }
private readonly string _editorId = $"script-editor-{Guid.NewGuid():N}";
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
try
{
await JS.InvokeVoidAsync("otOpcUaScriptEditor.attach", _editorId);
}
catch (JSException)
{
// Monaco bundle not yet loaded on this page — textarea fallback is
// still functional.
}
}
}
}

View File

@@ -0,0 +1,224 @@
@using ZB.MOM.WW.OtOpcUa.Admin.Services
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@using ZB.MOM.WW.OtOpcUa.Core.Abstractions
@using ZB.MOM.WW.OtOpcUa.Core.Scripting
@inject ScriptService ScriptSvc
@inject ScriptTestHarnessService Harness
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h4 class="mb-0">Scripts</h4>
<small class="text-muted">C# (Roslyn). Used by virtual tags + scripted alarms.</small>
</div>
<button class="btn btn-primary" @onclick="StartNew">+ New script</button>
</div>
<script src="/js/monaco-loader.js"></script>
@if (_loading) { <p class="text-muted">Loading…</p> }
else if (_scripts.Count == 0 && _editing is null)
{
<div class="alert alert-info">No scripts yet in this draft.</div>
}
else
{
<div class="row">
<div class="col-md-4">
<div class="list-group">
@foreach (var s in _scripts)
{
<button class="list-group-item list-group-item-action @(_editing?.ScriptId == s.ScriptId ? "active" : "")"
@onclick="() => Open(s)">
<strong>@s.Name</strong>
<div class="small text-muted font-monospace">@s.ScriptId</div>
</button>
}
</div>
</div>
<div class="col-md-8">
@if (_editing is not null)
{
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<strong>@(_isNew ? "New script" : _editing.Name)</strong>
<div>
@if (!_isNew)
{
<button class="btn btn-sm btn-outline-danger me-2" @onclick="DeleteAsync">Delete</button>
}
<button class="btn btn-sm btn-primary" disabled="@_busy" @onclick="SaveAsync">Save</button>
</div>
</div>
<div class="card-body">
<div class="mb-2">
<label class="form-label">Name</label>
<input class="form-control" @bind="_editing.Name"/>
</div>
<label class="form-label">Source</label>
<ScriptEditor @bind-Source="_editing.SourceCode"/>
<div class="mt-3">
<button class="btn btn-sm btn-outline-secondary" @onclick="PreviewDependencies">Analyze dependencies</button>
<button class="btn btn-sm btn-outline-info ms-2" @onclick="RunHarnessAsync" disabled="@_harnessBusy">Run test harness</button>
</div>
@if (_dependencies is not null)
{
<div class="mt-3">
<strong>Inferred reads</strong>
@if (_dependencies.Reads.Count == 0) { <span class="text-muted ms-2">none</span> }
else
{
<ul class="mb-1">
@foreach (var r in _dependencies.Reads) { <li><code>@r</code></li> }
</ul>
}
<strong>Inferred writes</strong>
@if (_dependencies.Writes.Count == 0) { <span class="text-muted ms-2">none</span> }
else
{
<ul class="mb-1">
@foreach (var w in _dependencies.Writes) { <li><code>@w</code></li> }
</ul>
}
@if (_dependencies.Rejections.Count > 0)
{
<div class="alert alert-danger mt-2">
<strong>Non-literal paths rejected:</strong>
<ul class="mb-0">
@foreach (var r in _dependencies.Rejections) { <li>@r.Message</li> }
</ul>
</div>
}
</div>
}
@if (_testResult is not null)
{
<div class="mt-3 border-top pt-3">
<strong>Harness result:</strong> <span class="badge bg-secondary">@_testResult.Outcome</span>
@if (_testResult.Outcome == ScriptTestOutcome.Success)
{
<div>Output: <code>@(_testResult.Output?.ToString() ?? "null")</code></div>
@if (_testResult.Writes.Count > 0)
{
<div class="mt-1"><strong>Writes:</strong>
<ul class="mb-0">
@foreach (var kv in _testResult.Writes) { <li><code>@kv.Key</code> = <code>@(kv.Value?.ToString() ?? "null")</code></li> }
</ul>
</div>
}
}
@if (_testResult.Errors.Count > 0)
{
<div class="alert alert-warning mt-2 mb-0">
@foreach (var e in _testResult.Errors) { <div>@e</div> }
</div>
}
@if (_testResult.LogEvents.Count > 0)
{
<div class="mt-2"><strong>Script log output:</strong>
<ul class="small mb-0">
@foreach (var e in _testResult.LogEvents) { <li>[@e.Level] @e.RenderMessage()</li> }
</ul>
</div>
}
</div>
}
</div>
</div>
}
</div>
</div>
}
@code {
[Parameter] public long GenerationId { get; set; }
[Parameter] public string ClusterId { get; set; } = string.Empty;
private bool _loading = true;
private bool _busy;
private bool _harnessBusy;
private bool _isNew;
private List<Script> _scripts = [];
private Script? _editing;
private DependencyExtractionResult? _dependencies;
private ScriptTestResult? _testResult;
protected override async Task OnParametersSetAsync()
{
_loading = true;
_scripts = await ScriptSvc.ListAsync(GenerationId, CancellationToken.None);
_loading = false;
}
private void Open(Script s)
{
_editing = new Script
{
ScriptRowId = s.ScriptRowId, GenerationId = s.GenerationId,
ScriptId = s.ScriptId, Name = s.Name, SourceCode = s.SourceCode,
SourceHash = s.SourceHash, Language = s.Language,
};
_isNew = false;
_dependencies = null;
_testResult = null;
}
private void StartNew()
{
_editing = new Script
{
GenerationId = GenerationId, ScriptId = "",
Name = "new-script", SourceCode = "return 0;", SourceHash = "",
};
_isNew = true;
_dependencies = null;
_testResult = null;
}
private async Task SaveAsync()
{
if (_editing is null) return;
_busy = true;
try
{
if (_isNew)
await ScriptSvc.AddAsync(GenerationId, _editing.Name, _editing.SourceCode, CancellationToken.None);
else
await ScriptSvc.UpdateAsync(GenerationId, _editing.ScriptId, _editing.Name, _editing.SourceCode, CancellationToken.None);
_scripts = await ScriptSvc.ListAsync(GenerationId, CancellationToken.None);
_isNew = false;
}
finally { _busy = false; }
}
private async Task DeleteAsync()
{
if (_editing is null || _isNew) return;
await ScriptSvc.DeleteAsync(GenerationId, _editing.ScriptId, CancellationToken.None);
_editing = null;
_scripts = await ScriptSvc.ListAsync(GenerationId, CancellationToken.None);
}
private void PreviewDependencies()
{
if (_editing is null) return;
_dependencies = DependencyExtractor.Extract(_editing.SourceCode);
}
private async Task RunHarnessAsync()
{
if (_editing is null) return;
_harnessBusy = true;
try
{
_dependencies ??= DependencyExtractor.Extract(_editing.SourceCode);
var inputs = new Dictionary<string, DataValueSnapshot>();
foreach (var read in _dependencies.Reads)
inputs[read] = new DataValueSnapshot(0.0, 0u, DateTime.UtcNow, DateTime.UtcNow);
_testResult = await Harness.RunVirtualTagAsync(_editing.SourceCode, inputs, CancellationToken.None);
}
finally { _harnessBusy = false; }
}
}

View File

@@ -57,6 +57,18 @@ builder.Services.AddScoped<EquipmentImportBatchService>();
builder.Services.AddScoped<ZB.MOM.WW.OtOpcUa.Configuration.Services.ILdapGroupRoleMappingService,
ZB.MOM.WW.OtOpcUa.Configuration.Services.LdapGroupRoleMappingService>();
// Phase 7 Stream F — scripting + virtual tag + scripted alarm draft services, test
// harness, and historian diagnostics. The historian sink is the Null variant here —
// the real SqliteStoreAndForwardSink lives in the server process. Admin reads status
// from whichever sink is provided at composition time.
builder.Services.AddScoped<ScriptService>();
builder.Services.AddScoped<VirtualTagService>();
builder.Services.AddScoped<ScriptedAlarmService>();
builder.Services.AddScoped<ScriptTestHarnessService>();
builder.Services.AddScoped<HistorianDiagnosticsService>();
builder.Services.AddSingleton<ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.IAlarmHistorianSink>(
ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.NullAlarmHistorianSink.Instance);
// Cert-trust management — reads the OPC UA server's PKI store root so rejected client certs
// can be promoted to trusted via the Admin UI. Singleton: no per-request state, just
// filesystem operations.

View File

@@ -0,0 +1,32 @@
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
/// <summary>
/// Surfaces the local-node historian queue health on the Admin UI's
/// <c>/alarms/historian</c> diagnostics page (Phase 7 plan decisions #16/#21).
/// Exposes queue depth / drain state / last-error, and lets the operator retry
/// dead-lettered rows without restarting the node.
/// </summary>
/// <remarks>
/// The sink injected here is the server-process <see cref="IAlarmHistorianSink"/>.
/// When <see cref="NullAlarmHistorianSink"/> is bound (historian disabled for this
/// deployment), <see cref="TryRetryDeadLettered"/> silently returns 0 and
/// <see cref="GetStatus"/> reports <see cref="HistorianDrainState.Disabled"/>.
/// </remarks>
public sealed class HistorianDiagnosticsService(IAlarmHistorianSink sink)
{
public HistorianSinkStatus GetStatus() => sink.GetStatus();
/// <summary>
/// Operator action from the UI's "Retry dead-lettered" button. Returns the number
/// of rows revived so the UI can flash a confirmation. When the live sink doesn't
/// implement retry (test doubles, Null sink), returns 0.
/// </summary>
public int TryRetryDeadLettered()
{
if (sink is SqliteStoreAndForwardSink concrete)
return concrete.RetryDeadLettered();
return 0;
}
}

View File

@@ -0,0 +1,66 @@
using System.Security.Cryptography;
using System.Text;
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
/// <summary>
/// Draft-generation CRUD for <see cref="Script"/> rows — the C# source code referenced
/// by Phase 7 virtual tags and scripted alarms. <see cref="Script.SourceHash"/> is
/// recomputed on every save so Core.Scripting's compile cache sees a fresh key when
/// source changes and reuses the compile when it doesn't.
/// </summary>
public sealed class ScriptService(OtOpcUaConfigDbContext db)
{
public Task<List<Script>> ListAsync(long generationId, CancellationToken ct) =>
db.Scripts.AsNoTracking()
.Where(s => s.GenerationId == generationId)
.OrderBy(s => s.Name)
.ToListAsync(ct);
public Task<Script?> GetAsync(long generationId, string scriptId, CancellationToken ct) =>
db.Scripts.AsNoTracking()
.FirstOrDefaultAsync(s => s.GenerationId == generationId && s.ScriptId == scriptId, ct);
public async Task<Script> AddAsync(long generationId, string name, string sourceCode, CancellationToken ct)
{
var s = new Script
{
GenerationId = generationId,
ScriptId = $"scr-{Guid.NewGuid():N}"[..20],
Name = name,
SourceCode = sourceCode,
SourceHash = ComputeHash(sourceCode),
};
db.Scripts.Add(s);
await db.SaveChangesAsync(ct);
return s;
}
public async Task<Script> UpdateAsync(long generationId, string scriptId, string name, string sourceCode, CancellationToken ct)
{
var s = await db.Scripts.FirstOrDefaultAsync(x => x.GenerationId == generationId && x.ScriptId == scriptId, ct)
?? throw new InvalidOperationException($"Script '{scriptId}' not found in generation {generationId}");
s.Name = name;
s.SourceCode = sourceCode;
s.SourceHash = ComputeHash(sourceCode);
await db.SaveChangesAsync(ct);
return s;
}
public async Task DeleteAsync(long generationId, string scriptId, CancellationToken ct)
{
var s = await db.Scripts.FirstOrDefaultAsync(x => x.GenerationId == generationId && x.ScriptId == scriptId, ct);
if (s is null) return;
db.Scripts.Remove(s);
await db.SaveChangesAsync(ct);
}
internal static string ComputeHash(string source)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(source ?? string.Empty));
return Convert.ToHexString(bytes);
}
}

View File

@@ -0,0 +1,121 @@
using Serilog; // resolves Serilog.ILogger explicitly in signatures
using Serilog.Core;
using Serilog.Events;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
/// <summary>
/// Dry-run harness for the Phase 7 scripting UI. Takes a script + a synthetic input
/// map + evaluates once, returns the output (or rejection / exception) plus any
/// logger emissions the script produced. Per Phase 7 plan decision #22: only inputs
/// the <see cref="DependencyExtractor"/> identified can be supplied, so a dependency
/// the harness can't prove statically surfaces as a harness error, not a runtime
/// surprise later.
/// </summary>
public sealed class ScriptTestHarnessService
{
/// <summary>
/// Evaluate <paramref name="source"/> as a virtual-tag script (return value is the
/// tag's new value). <paramref name="inputs"/> supplies synthetic
/// <see cref="DataValueSnapshot"/>s for every path the extractor found.
/// </summary>
public async Task<ScriptTestResult> RunVirtualTagAsync(
string source, IDictionary<string, DataValueSnapshot> inputs, CancellationToken ct)
{
var deps = DependencyExtractor.Extract(source);
if (!deps.IsValid)
return ScriptTestResult.DependencyRejections(deps.Rejections);
var missing = deps.Reads.Where(r => !inputs.ContainsKey(r)).ToArray();
if (missing.Length > 0)
return ScriptTestResult.MissingInputs(missing);
var extra = inputs.Keys.Where(k => !deps.Reads.Contains(k)).ToArray();
if (extra.Length > 0)
return ScriptTestResult.UnknownInputs(extra);
ScriptEvaluator<HarnessVirtualTagContext, object?> evaluator;
try
{
evaluator = ScriptEvaluator<HarnessVirtualTagContext, object?>.Compile(source);
}
catch (Exception compileEx)
{
return ScriptTestResult.Threw(compileEx.Message, []);
}
var capturing = new CapturingSink();
var logger = new LoggerConfiguration().MinimumLevel.Verbose().WriteTo.Sink(capturing).CreateLogger();
var ctx = new HarnessVirtualTagContext(inputs, logger);
try
{
var result = await evaluator.RunAsync(ctx, ct);
return ScriptTestResult.Ok(result, ctx.Writes, capturing.Events);
}
catch (Exception ex)
{
return ScriptTestResult.Threw(ex.Message, capturing.Events);
}
}
// Public so Roslyn's script compilation can reference the context type through the
// ScriptGlobals<T> surface. The harness instantiates this directly; operators never see it.
public sealed class HarnessVirtualTagContext(
IDictionary<string, DataValueSnapshot> inputs, Serilog.ILogger logger) : ScriptContext
{
public Dictionary<string, object?> Writes { get; } = [];
public override DataValueSnapshot GetTag(string path) =>
inputs.TryGetValue(path, out var v)
? v
: new DataValueSnapshot(null, Ua.StatusCodes.BadNotFound, null, DateTime.UtcNow);
public override void SetVirtualTag(string path, object? value) => Writes[path] = value;
public override DateTime Now => DateTime.UtcNow;
public override Serilog.ILogger Logger => logger;
}
private sealed class CapturingSink : ILogEventSink
{
public List<LogEvent> Events { get; } = [];
public void Emit(LogEvent e) => Events.Add(e);
}
}
/// <summary>Harness outcome: outputs, write-set, logger events, or a rejection/throw reason.</summary>
public sealed record ScriptTestResult(
ScriptTestOutcome Outcome,
object? Output,
IReadOnlyDictionary<string, object?> Writes,
IReadOnlyList<LogEvent> LogEvents,
IReadOnlyList<string> Errors)
{
public static ScriptTestResult Ok(object? output, IReadOnlyDictionary<string, object?> writes, IReadOnlyList<LogEvent> logs) =>
new(ScriptTestOutcome.Success, output, writes, logs, []);
public static ScriptTestResult Threw(string reason, IReadOnlyList<LogEvent> logs) =>
new(ScriptTestOutcome.Threw, null, new Dictionary<string, object?>(), logs, [reason]);
public static ScriptTestResult DependencyRejections(IReadOnlyList<DependencyRejection> rejs) =>
new(ScriptTestOutcome.DependencyRejected, null, new Dictionary<string, object?>(), [],
rejs.Select(r => r.Message).ToArray());
public static ScriptTestResult MissingInputs(string[] paths) =>
new(ScriptTestOutcome.MissingInputs, null, new Dictionary<string, object?>(), [],
paths.Select(p => $"Missing synthetic input: {p}").ToArray());
public static ScriptTestResult UnknownInputs(string[] paths) =>
new(ScriptTestOutcome.UnknownInputs, null, new Dictionary<string, object?>(), [],
paths.Select(p => $"Input '{p}' is not referenced by the script — remove it").ToArray());
}
public enum ScriptTestOutcome
{
Success,
Threw,
DependencyRejected,
MissingInputs,
UnknownInputs,
}
file static class Ua
{
// Mirrors OPC UA StatusCodes.BadNotFound without pulling the OPC stack into Admin.
public static class StatusCodes { public const uint BadNotFound = 0x803E0000; }
}

View File

@@ -0,0 +1,55 @@
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
/// <summary>Draft-generation CRUD for <see cref="ScriptedAlarm"/> rows.</summary>
public sealed class ScriptedAlarmService(OtOpcUaConfigDbContext db)
{
public Task<List<ScriptedAlarm>> ListAsync(long generationId, CancellationToken ct) =>
db.ScriptedAlarms.AsNoTracking()
.Where(a => a.GenerationId == generationId)
.OrderBy(a => a.EquipmentId).ThenBy(a => a.Name)
.ToListAsync(ct);
public async Task<ScriptedAlarm> AddAsync(
long generationId, string equipmentId, string name, string alarmType,
int severity, string messageTemplate, string predicateScriptId,
bool historizeToAveva, bool retain, CancellationToken ct)
{
var a = new ScriptedAlarm
{
GenerationId = generationId,
ScriptedAlarmId = $"sal-{Guid.NewGuid():N}"[..20],
EquipmentId = equipmentId,
Name = name,
AlarmType = alarmType,
Severity = severity,
MessageTemplate = messageTemplate,
PredicateScriptId = predicateScriptId,
HistorizeToAveva = historizeToAveva,
Retain = retain,
};
db.ScriptedAlarms.Add(a);
await db.SaveChangesAsync(ct);
return a;
}
public async Task DeleteAsync(long generationId, string scriptedAlarmId, CancellationToken ct)
{
var a = await db.ScriptedAlarms.FirstOrDefaultAsync(x => x.GenerationId == generationId && x.ScriptedAlarmId == scriptedAlarmId, ct);
if (a is null) return;
db.ScriptedAlarms.Remove(a);
await db.SaveChangesAsync(ct);
}
/// <summary>
/// Returns the persistent state row (ack/confirm/shelve) for this alarm identity —
/// alarm state is NOT generation-scoped per Phase 7 plan decision #14, so the
/// lookup is by <see cref="ScriptedAlarm.ScriptedAlarmId"/> only.
/// </summary>
public Task<ScriptedAlarmState?> GetStateAsync(string scriptedAlarmId, CancellationToken ct) =>
db.ScriptedAlarmStates.AsNoTracking()
.FirstOrDefaultAsync(s => s.ScriptedAlarmId == scriptedAlarmId, ct);
}

View File

@@ -0,0 +1,53 @@
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
/// <summary>Draft-generation CRUD for <see cref="VirtualTag"/> rows.</summary>
public sealed class VirtualTagService(OtOpcUaConfigDbContext db)
{
public Task<List<VirtualTag>> ListAsync(long generationId, CancellationToken ct) =>
db.VirtualTags.AsNoTracking()
.Where(v => v.GenerationId == generationId)
.OrderBy(v => v.EquipmentId).ThenBy(v => v.Name)
.ToListAsync(ct);
public async Task<VirtualTag> AddAsync(
long generationId, string equipmentId, string name, string dataType, string scriptId,
bool changeTriggered, int? timerIntervalMs, bool historize, CancellationToken ct)
{
var v = new VirtualTag
{
GenerationId = generationId,
VirtualTagId = $"vt-{Guid.NewGuid():N}"[..20],
EquipmentId = equipmentId,
Name = name,
DataType = dataType,
ScriptId = scriptId,
ChangeTriggered = changeTriggered,
TimerIntervalMs = timerIntervalMs,
Historize = historize,
};
db.VirtualTags.Add(v);
await db.SaveChangesAsync(ct);
return v;
}
public async Task DeleteAsync(long generationId, string virtualTagId, CancellationToken ct)
{
var v = await db.VirtualTags.FirstOrDefaultAsync(x => x.GenerationId == generationId && x.VirtualTagId == virtualTagId, ct);
if (v is null) return;
db.VirtualTags.Remove(v);
await db.SaveChangesAsync(ct);
}
public async Task<VirtualTag> UpdateEnabledAsync(long generationId, string virtualTagId, bool enabled, CancellationToken ct)
{
var v = await db.VirtualTags.FirstOrDefaultAsync(x => x.GenerationId == generationId && x.VirtualTagId == virtualTagId, ct)
?? throw new InvalidOperationException($"VirtualTag '{virtualTagId}' not found in generation {generationId}");
v.Enabled = enabled;
await db.SaveChangesAsync(ct);
return v;
}
}

View File

@@ -23,6 +23,8 @@
<ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Configuration\ZB.MOM.WW.OtOpcUa.Configuration.csproj"/>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Scripting\ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj"/>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.csproj"/>
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,59 @@
// Phase 7 Stream F — Monaco editor loader for ScriptEditor.razor.
// Progressive enhancement: the textarea is authoritative until Monaco attaches;
// after attach, Monaco syncs back into the textarea on every change so Blazor's
// @bind still sees the latest value.
(function () {
if (window.otOpcUaScriptEditor) return;
const MONACO_CDN = 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs';
let loaderPromise = null;
function ensureLoader() {
if (loaderPromise) return loaderPromise;
loaderPromise = new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = `${MONACO_CDN}/loader.js`;
script.onload = () => {
window.require.config({ paths: { vs: MONACO_CDN } });
window.require(['vs/editor/editor.main'], () => resolve(window.monaco));
};
script.onerror = () => reject(new Error('Monaco CDN unreachable'));
document.head.appendChild(script);
});
return loaderPromise;
}
window.otOpcUaScriptEditor = {
attach: async function (textareaId) {
const ta = document.getElementById(textareaId);
if (!ta) return;
const monaco = await ensureLoader();
// Mount Monaco over the textarea. The textarea stays in the DOM as the
// source of truth for Blazor's @bind — Monaco mirrors into it on every
// keystroke so server-side state stays in sync.
const host = document.createElement('div');
host.style.height = '340px';
host.style.border = '1px solid #ced4da';
host.style.borderRadius = '0.25rem';
ta.style.display = 'none';
ta.parentNode.insertBefore(host, ta);
const editor = monaco.editor.create(host, {
value: ta.value,
language: 'csharp',
theme: 'vs',
automaticLayout: true,
fontSize: 13,
minimap: { enabled: false },
scrollBeyondLastLine: false,
});
editor.onDidChangeModelContent(() => {
ta.value = editor.getValue();
ta.dispatchEvent(new Event('input', { bubbles: true }));
});
},
};
})();

View File

@@ -0,0 +1,38 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>
/// Per Phase 7 plan decision #8 — user-authored C# script source, referenced by
/// <see cref="VirtualTag"/> and <see cref="ScriptedAlarm"/>. One row per script,
/// per generation. <c>SourceHash</c> is the compile-cache key.
/// </summary>
/// <remarks>
/// <para>
/// Scripts are generation-scoped: a draft's edit creates a new row in the draft
/// generation, the old row stays frozen in the published generation. Shape mirrors
/// the other generation-scoped entities (Equipment, Tag, etc.) — <c>ScriptId</c> is
/// the stable logical id that carries across generations; <c>ScriptRowId</c> is the
/// row identity.
/// </para>
/// </remarks>
public sealed class Script
{
public Guid ScriptRowId { get; set; }
public long GenerationId { get; set; }
/// <summary>Stable logical id. Carries across generations.</summary>
public required string ScriptId { get; set; }
/// <summary>Operator-friendly name for log filtering + Admin UI list view.</summary>
public required string Name { get; set; }
/// <summary>Raw C# source. Size bounded by the DB column (nvarchar(max)).</summary>
public required string SourceCode { get; set; }
/// <summary>SHA-256 of <see cref="SourceCode"/> — compile-cache key for Phase 7 Stream A's <c>CompiledScriptCache</c>.</summary>
public required string SourceHash { get; set; }
/// <summary>Language — always "CSharp" today; placeholder for future engines (Python/Lua).</summary>
public string Language { get; set; } = "CSharp";
public ConfigGeneration? Generation { get; set; }
}

View File

@@ -0,0 +1,59 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>
/// Per Phase 7 plan decisions #5, #13, #15 — a scripted OPC UA Part 9 alarm whose
/// condition is the predicate <see cref="Script"/> referenced by
/// <see cref="PredicateScriptId"/>. Materialized by <c>Core.ScriptedAlarms</c> as a
/// concrete <c>AlarmConditionType</c> subtype per <see cref="AlarmType"/>.
/// </summary>
/// <remarks>
/// <para>
/// Message tokens (<c>{TagPath}</c>) resolved at emission time per plan decision #13.
/// <see cref="HistorizeToAveva"/> (plan decision #15) gates whether transitions
/// route through the Core.AlarmHistorian SQLite queue + Galaxy.Host to the Aveva
/// Historian alarm schema.
/// </para>
/// </remarks>
public sealed class ScriptedAlarm
{
public Guid ScriptedAlarmRowId { get; set; }
public long GenerationId { get; set; }
/// <summary>Stable logical id — drives <c>AlarmConditionType.ConditionName</c>.</summary>
public required string ScriptedAlarmId { get; set; }
/// <summary>Logical FK to <see cref="Equipment.EquipmentId"/> — owner of this alarm.</summary>
public required string EquipmentId { get; set; }
/// <summary>Operator-facing alarm name.</summary>
public required string Name { get; set; }
/// <summary>Concrete Part 9 type — "AlarmCondition" / "LimitAlarm" / "OffNormalAlarm" / "DiscreteAlarm".</summary>
public required string AlarmType { get; set; }
/// <summary>Numeric severity 1..1000 per OPC UA Part 9 (usual bands: 1-250 Low, 251-500 Medium, 501-750 High, 751-1000 Critical).</summary>
public int Severity { get; set; } = 500;
/// <summary>Template with <c>{TagPath}</c> tokens resolved at emission time.</summary>
public required string MessageTemplate { get; set; }
/// <summary>Logical FK to <see cref="Script.ScriptId"/> — predicate script returning <c>bool</c>.</summary>
public required string PredicateScriptId { get; set; }
/// <summary>
/// Plan decision #15 — when true, transitions route through the SQLite store-and-forward
/// queue to the Aveva Historian. Defaults on for scripted alarms because they are the
/// primary motivation for the historian sink; operator can disable per alarm.
/// </summary>
public bool HistorizeToAveva { get; set; } = true;
/// <summary>
/// OPC UA Part 9 <c>Retain</c> flag — whether the alarm keeps active-state between
/// sessions. Most plant alarms are retained; one-shot event-style alarms are not.
/// </summary>
public bool Retain { get; set; } = true;
public bool Enabled { get; set; } = true;
public ConfigGeneration? Generation { get; set; }
}

View File

@@ -0,0 +1,62 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>
/// Per Phase 7 plan decision #14 — persistent runtime state for each scripted alarm.
/// Survives process restart so operators don't re-ack and ack history survives for
/// GxP / 21 CFR Part 11 compliance. Keyed on <c>ScriptedAlarmId</c> logically (not
/// per-generation) because ack state follows the alarm's stable identity across
/// generations — a Modified alarm keeps its ack history.
/// </summary>
/// <remarks>
/// <para>
/// <c>ActiveState</c> is deliberately NOT persisted — it rederives from the current
/// predicate evaluation on startup. Only operator-supplied state (<see cref="AckedState"/>,
/// <see cref="ConfirmedState"/>, <see cref="ShelvingState"/>) + audit trail persist.
/// </para>
/// <para>
/// <see cref="CommentsJson"/> is an append-only JSON array of <c>{user, utc, text}</c>
/// tuples — one per operator comment. Core.ScriptedAlarms' <c>AlarmConditionState.Comments</c>
/// serializes directly into this column.
/// </para>
/// </remarks>
public sealed class ScriptedAlarmState
{
/// <summary>Logical FK — matches <see cref="ScriptedAlarm.ScriptedAlarmId"/>. One row per alarm identity.</summary>
public required string ScriptedAlarmId { get; set; }
/// <summary>Enabled/Disabled. Persists across restart per plan decision #14.</summary>
public required string EnabledState { get; set; } = "Enabled";
/// <summary>Unacknowledged / Acknowledged.</summary>
public required string AckedState { get; set; } = "Unacknowledged";
/// <summary>Unconfirmed / Confirmed.</summary>
public required string ConfirmedState { get; set; } = "Unconfirmed";
/// <summary>Unshelved / OneShotShelved / TimedShelved.</summary>
public required string ShelvingState { get; set; } = "Unshelved";
/// <summary>When a TimedShelve expires — null if not shelved or OneShotShelved.</summary>
public DateTime? ShelvingExpiresUtc { get; set; }
/// <summary>User who last acknowledged. Null if never acked.</summary>
public string? LastAckUser { get; set; }
/// <summary>Operator-supplied ack comment. Null if no comment or never acked.</summary>
public string? LastAckComment { get; set; }
public DateTime? LastAckUtc { get; set; }
/// <summary>User who last confirmed.</summary>
public string? LastConfirmUser { get; set; }
public string? LastConfirmComment { get; set; }
public DateTime? LastConfirmUtc { get; set; }
/// <summary>JSON array of operator comments, append-only (GxP audit).</summary>
public string CommentsJson { get; set; } = "[]";
/// <summary>Row write timestamp — tracks last state change.</summary>
public DateTime UpdatedAtUtc { get; set; }
}

View File

@@ -0,0 +1,53 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>
/// Per Phase 7 plan decision #2 — a virtual (calculated) tag that lives in the
/// Equipment tree alongside driver tags. Value is produced by the
/// <see cref="Script"/> referenced by <see cref="ScriptId"/>.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="EquipmentId"/> is mandatory — virtual tags are always scoped to an
/// Equipment node per plan decision #2 (unified Equipment tree, not a separate
/// /Virtual namespace). <see cref="DataType"/> matches the shape used by
/// <c>Tag.DataType</c>.
/// </para>
/// <para>
/// <see cref="ChangeTriggered"/> and <see cref="TimerIntervalMs"/> together realize
/// plan decision #3 (change + timer). At least one must produce evaluations; the
/// Core.VirtualTags engine rejects an all-disabled tag at load time.
/// </para>
/// </remarks>
public sealed class VirtualTag
{
public Guid VirtualTagRowId { get; set; }
public long GenerationId { get; set; }
/// <summary>Stable logical id.</summary>
public required string VirtualTagId { get; set; }
/// <summary>Logical FK to <see cref="Equipment.EquipmentId"/> — owner of this virtual tag.</summary>
public required string EquipmentId { get; set; }
/// <summary>Browse name — unique within owning Equipment.</summary>
public required string Name { get; set; }
/// <summary>DataType string — same vocabulary as <see cref="Tag.DataType"/>.</summary>
public required string DataType { get; set; }
/// <summary>Logical FK to <see cref="Script.ScriptId"/> — the script that computes this tag's value.</summary>
public required string ScriptId { get; set; }
/// <summary>Re-evaluate when any referenced input tag changes. Default on.</summary>
public bool ChangeTriggered { get; set; } = true;
/// <summary>Timer re-evaluation cadence in milliseconds. <c>null</c> = no timer.</summary>
public int? TimerIntervalMs { get; set; }
/// <summary>Per plan decision #10 — checkbox to route this tag's values through <c>IHistoryWriter</c>.</summary>
public bool Historize { get; set; }
public bool Enabled { get; set; } = true;
public ConfigGeneration? Generation { get; set; }
}

View File

@@ -0,0 +1,186 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
{
/// <inheritdoc />
public partial class AddPhase7ScriptingTables : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Script",
columns: table => new
{
ScriptRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
GenerationId = table.Column<long>(type: "bigint", nullable: false),
ScriptId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
SourceCode = table.Column<string>(type: "nvarchar(max)", nullable: false),
SourceHash = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Language = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Script", x => x.ScriptRowId);
table.ForeignKey(
name: "FK_Script_ConfigGeneration_GenerationId",
column: x => x.GenerationId,
principalTable: "ConfigGeneration",
principalColumn: "GenerationId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "ScriptedAlarm",
columns: table => new
{
ScriptedAlarmRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
GenerationId = table.Column<long>(type: "bigint", nullable: false),
ScriptedAlarmId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
EquipmentId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
AlarmType = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
Severity = table.Column<int>(type: "int", nullable: false),
MessageTemplate = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: false),
PredicateScriptId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
HistorizeToAveva = table.Column<bool>(type: "bit", nullable: false),
Retain = table.Column<bool>(type: "bit", nullable: false),
Enabled = table.Column<bool>(type: "bit", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ScriptedAlarm", x => x.ScriptedAlarmRowId);
table.CheckConstraint("CK_ScriptedAlarm_AlarmType", "AlarmType IN ('AlarmCondition','LimitAlarm','OffNormalAlarm','DiscreteAlarm')");
table.CheckConstraint("CK_ScriptedAlarm_Severity_Range", "Severity BETWEEN 1 AND 1000");
table.ForeignKey(
name: "FK_ScriptedAlarm_ConfigGeneration_GenerationId",
column: x => x.GenerationId,
principalTable: "ConfigGeneration",
principalColumn: "GenerationId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "ScriptedAlarmState",
columns: table => new
{
ScriptedAlarmId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
EnabledState = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
AckedState = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
ConfirmedState = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
ShelvingState = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
ShelvingExpiresUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: true),
LastAckUser = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: true),
LastAckComment = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: true),
LastAckUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: true),
LastConfirmUser = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: true),
LastConfirmComment = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: true),
LastConfirmUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: true),
CommentsJson = table.Column<string>(type: "nvarchar(max)", nullable: false),
UpdatedAtUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: false, defaultValueSql: "SYSUTCDATETIME()")
},
constraints: table =>
{
table.PrimaryKey("PK_ScriptedAlarmState", x => x.ScriptedAlarmId);
table.CheckConstraint("CK_ScriptedAlarmState_CommentsJson_IsJson", "ISJSON(CommentsJson) = 1");
});
migrationBuilder.CreateTable(
name: "VirtualTag",
columns: table => new
{
VirtualTagRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
GenerationId = table.Column<long>(type: "bigint", nullable: false),
VirtualTagId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
EquipmentId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
DataType = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
ScriptId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
ChangeTriggered = table.Column<bool>(type: "bit", nullable: false),
TimerIntervalMs = table.Column<int>(type: "int", nullable: true),
Historize = table.Column<bool>(type: "bit", nullable: false),
Enabled = table.Column<bool>(type: "bit", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_VirtualTag", x => x.VirtualTagRowId);
table.CheckConstraint("CK_VirtualTag_TimerInterval_Min", "TimerIntervalMs IS NULL OR TimerIntervalMs >= 50");
table.CheckConstraint("CK_VirtualTag_Trigger_AtLeastOne", "ChangeTriggered = 1 OR TimerIntervalMs IS NOT NULL");
table.ForeignKey(
name: "FK_VirtualTag_ConfigGeneration_GenerationId",
column: x => x.GenerationId,
principalTable: "ConfigGeneration",
principalColumn: "GenerationId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_Script_Generation_SourceHash",
table: "Script",
columns: new[] { "GenerationId", "SourceHash" });
migrationBuilder.CreateIndex(
name: "UX_Script_Generation_LogicalId",
table: "Script",
columns: new[] { "GenerationId", "ScriptId" },
unique: true,
filter: "[ScriptId] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_ScriptedAlarm_Generation_Script",
table: "ScriptedAlarm",
columns: new[] { "GenerationId", "PredicateScriptId" });
migrationBuilder.CreateIndex(
name: "UX_ScriptedAlarm_Generation_EquipmentPath",
table: "ScriptedAlarm",
columns: new[] { "GenerationId", "EquipmentId", "Name" },
unique: true);
migrationBuilder.CreateIndex(
name: "UX_ScriptedAlarm_Generation_LogicalId",
table: "ScriptedAlarm",
columns: new[] { "GenerationId", "ScriptedAlarmId" },
unique: true,
filter: "[ScriptedAlarmId] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_VirtualTag_Generation_Script",
table: "VirtualTag",
columns: new[] { "GenerationId", "ScriptId" });
migrationBuilder.CreateIndex(
name: "UX_VirtualTag_Generation_EquipmentPath",
table: "VirtualTag",
columns: new[] { "GenerationId", "EquipmentId", "Name" },
unique: true);
migrationBuilder.CreateIndex(
name: "UX_VirtualTag_Generation_LogicalId",
table: "VirtualTag",
columns: new[] { "GenerationId", "VirtualTagId" },
unique: true,
filter: "[VirtualTagId] IS NOT NULL");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Script");
migrationBuilder.DropTable(
name: "ScriptedAlarm");
migrationBuilder.DropTable(
name: "ScriptedAlarmState");
migrationBuilder.DropTable(
name: "VirtualTag");
}
}
}

View File

@@ -0,0 +1,232 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
{
/// <summary>
/// Phase 7 follow-up (task #241) — extends <c>dbo.sp_ComputeGenerationDiff</c> to emit
/// Script / VirtualTag / ScriptedAlarm rows alongside the existing Namespace /
/// DriverInstance / Equipment / Tag / NodeAcl output. Admin DiffViewer now shows
/// Phase 7 changes between generations.
/// </summary>
/// <remarks>
/// Logical ids: ScriptId, VirtualTagId, ScriptedAlarmId — stable across generations
/// so a Script whose source changes surfaces as Modified (CHECKSUM picks up the
/// SourceHash delta) while a renamed script surfaces as Modified on Name alone.
/// ScriptedAlarmState is deliberately excluded — it's not generation-scoped, so
/// diffing it between generations is meaningless.
/// </remarks>
/// <inheritdoc />
public partial class ExtendComputeGenerationDiffWithPhase7 : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(Procs.ComputeGenerationDiffV3);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(Procs.ComputeGenerationDiffV2);
}
private static class Procs
{
/// <summary>V3 — adds Script / VirtualTag / ScriptedAlarm sections.</summary>
public const string ComputeGenerationDiffV3 = @"
CREATE OR ALTER PROCEDURE dbo.sp_ComputeGenerationDiff
@FromGenerationId bigint,
@ToGenerationId bigint
AS
BEGIN
SET NOCOUNT ON;
CREATE TABLE #diff (TableName nvarchar(32), LogicalId nvarchar(128), ChangeKind nvarchar(16));
WITH f AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @FromGenerationId),
t AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'Namespace', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
WITH f AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @FromGenerationId),
t AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'DriverInstance', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
WITH f AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @FromGenerationId),
t AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'Equipment', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
WITH f AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @FromGenerationId),
t AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'Tag', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
WITH f AS (
SELECT CONVERT(nvarchar(128), LdapGroup + '|' + CONVERT(nvarchar(16), ScopeKind) + '|' + ISNULL(ScopeId, '(cluster)')) AS LogicalId,
CHECKSUM(ClusterId, PermissionFlags, Notes) AS Sig
FROM dbo.NodeAcl WHERE GenerationId = @FromGenerationId),
t AS (
SELECT CONVERT(nvarchar(128), LdapGroup + '|' + CONVERT(nvarchar(16), ScopeKind) + '|' + ISNULL(ScopeId, '(cluster)')) AS LogicalId,
CHECKSUM(ClusterId, PermissionFlags, Notes) AS Sig
FROM dbo.NodeAcl WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'NodeAcl', COALESCE(f.LogicalId, t.LogicalId),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
-- Phase 7 — Script section. CHECKSUM picks up source changes via SourceHash + rename
-- via Name; Language future-proofs for non-C# engines. Same Name + same Source =
-- Unchanged (identical hash).
WITH f AS (SELECT ScriptId AS LogicalId, CHECKSUM(Name, SourceHash, Language) AS Sig FROM dbo.Script WHERE GenerationId = @FromGenerationId),
t AS (SELECT ScriptId AS LogicalId, CHECKSUM(Name, SourceHash, Language) AS Sig FROM dbo.Script WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'Script', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
-- Phase 7 — VirtualTag section.
WITH f AS (SELECT VirtualTagId AS LogicalId, CHECKSUM(EquipmentId, Name, DataType, ScriptId, ChangeTriggered, TimerIntervalMs, Historize, Enabled) AS Sig FROM dbo.VirtualTag WHERE GenerationId = @FromGenerationId),
t AS (SELECT VirtualTagId AS LogicalId, CHECKSUM(EquipmentId, Name, DataType, ScriptId, ChangeTriggered, TimerIntervalMs, Historize, Enabled) AS Sig FROM dbo.VirtualTag WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'VirtualTag', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
-- Phase 7 — ScriptedAlarm section. ScriptedAlarmState (operator ack trail) is
-- logical-id keyed outside the generation scope + intentionally excluded here —
-- diffing ack state between generations is semantically meaningless.
WITH f AS (SELECT ScriptedAlarmId AS LogicalId, CHECKSUM(EquipmentId, Name, AlarmType, Severity, MessageTemplate, PredicateScriptId, HistorizeToAveva, Retain, Enabled) AS Sig FROM dbo.ScriptedAlarm WHERE GenerationId = @FromGenerationId),
t AS (SELECT ScriptedAlarmId AS LogicalId, CHECKSUM(EquipmentId, Name, AlarmType, Severity, MessageTemplate, PredicateScriptId, HistorizeToAveva, Retain, Enabled) AS Sig FROM dbo.ScriptedAlarm WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'ScriptedAlarm', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
SELECT TableName, LogicalId, ChangeKind FROM #diff;
DROP TABLE #diff;
END
";
/// <summary>V2 — restores the pre-Phase-7 proc on Down().</summary>
public const string ComputeGenerationDiffV2 = @"
CREATE OR ALTER PROCEDURE dbo.sp_ComputeGenerationDiff
@FromGenerationId bigint,
@ToGenerationId bigint
AS
BEGIN
SET NOCOUNT ON;
CREATE TABLE #diff (TableName nvarchar(32), LogicalId nvarchar(128), ChangeKind nvarchar(16));
WITH f AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @FromGenerationId),
t AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'Namespace', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
WITH f AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @FromGenerationId),
t AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'DriverInstance', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
WITH f AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @FromGenerationId),
t AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'Equipment', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
WITH f AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @FromGenerationId),
t AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'Tag', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
WITH f AS (
SELECT CONVERT(nvarchar(128), LdapGroup + '|' + CONVERT(nvarchar(16), ScopeKind) + '|' + ISNULL(ScopeId, '(cluster)')) AS LogicalId,
CHECKSUM(ClusterId, PermissionFlags, Notes) AS Sig
FROM dbo.NodeAcl WHERE GenerationId = @FromGenerationId),
t AS (
SELECT CONVERT(nvarchar(128), LdapGroup + '|' + CONVERT(nvarchar(16), ScopeKind) + '|' + ISNULL(ScopeId, '(cluster)')) AS LogicalId,
CHECKSUM(ClusterId, PermissionFlags, Notes) AS Sig
FROM dbo.NodeAcl WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'NodeAcl', COALESCE(f.LogicalId, t.LogicalId),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
SELECT TableName, LogicalId, ChangeKind FROM #diff;
DROP TABLE #diff;
END
";
}
}
}

View File

@@ -1027,6 +1027,193 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
});
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Script", b =>
{
b.Property<Guid>("ScriptRowId")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier")
.HasDefaultValueSql("NEWSEQUENTIALID()");
b.Property<long>("GenerationId")
.HasColumnType("bigint");
b.Property<string>("Language")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("nvarchar(16)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("ScriptId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("SourceCode")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("SourceHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.HasKey("ScriptRowId");
b.HasIndex("GenerationId", "ScriptId")
.IsUnique()
.HasDatabaseName("UX_Script_Generation_LogicalId")
.HasFilter("[ScriptId] IS NOT NULL");
b.HasIndex("GenerationId", "SourceHash")
.HasDatabaseName("IX_Script_Generation_SourceHash");
b.ToTable("Script", (string)null);
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ScriptedAlarm", b =>
{
b.Property<Guid>("ScriptedAlarmRowId")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier")
.HasDefaultValueSql("NEWSEQUENTIALID()");
b.Property<string>("AlarmType")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<bool>("Enabled")
.HasColumnType("bit");
b.Property<string>("EquipmentId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<long>("GenerationId")
.HasColumnType("bigint");
b.Property<bool>("HistorizeToAveva")
.HasColumnType("bit");
b.Property<string>("MessageTemplate")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("PredicateScriptId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<bool>("Retain")
.HasColumnType("bit");
b.Property<string>("ScriptedAlarmId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<int>("Severity")
.HasColumnType("int");
b.HasKey("ScriptedAlarmRowId");
b.HasIndex("GenerationId", "PredicateScriptId")
.HasDatabaseName("IX_ScriptedAlarm_Generation_Script");
b.HasIndex("GenerationId", "ScriptedAlarmId")
.IsUnique()
.HasDatabaseName("UX_ScriptedAlarm_Generation_LogicalId")
.HasFilter("[ScriptedAlarmId] IS NOT NULL");
b.HasIndex("GenerationId", "EquipmentId", "Name")
.IsUnique()
.HasDatabaseName("UX_ScriptedAlarm_Generation_EquipmentPath");
b.ToTable("ScriptedAlarm", null, t =>
{
t.HasCheckConstraint("CK_ScriptedAlarm_AlarmType", "AlarmType IN ('AlarmCondition','LimitAlarm','OffNormalAlarm','DiscreteAlarm')");
t.HasCheckConstraint("CK_ScriptedAlarm_Severity_Range", "Severity BETWEEN 1 AND 1000");
});
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ScriptedAlarmState", b =>
{
b.Property<string>("ScriptedAlarmId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("AckedState")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("nvarchar(16)");
b.Property<string>("CommentsJson")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("ConfirmedState")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("nvarchar(16)");
b.Property<string>("EnabledState")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("nvarchar(16)");
b.Property<string>("LastAckComment")
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.Property<string>("LastAckUser")
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<DateTime?>("LastAckUtc")
.HasColumnType("datetime2(3)");
b.Property<string>("LastConfirmComment")
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.Property<string>("LastConfirmUser")
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<DateTime?>("LastConfirmUtc")
.HasColumnType("datetime2(3)");
b.Property<DateTime?>("ShelvingExpiresUtc")
.HasColumnType("datetime2(3)");
b.Property<string>("ShelvingState")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("nvarchar(16)");
b.Property<DateTime>("UpdatedAtUtc")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2(3)")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.HasKey("ScriptedAlarmId");
b.ToTable("ScriptedAlarmState", null, t =>
{
t.HasCheckConstraint("CK_ScriptedAlarmState_CommentsJson_IsJson", "ISJSON(CommentsJson) = 1");
});
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", b =>
{
b.Property<string>("ClusterId")
@@ -1274,6 +1461,74 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
b.ToTable("UnsLine", (string)null);
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.VirtualTag", b =>
{
b.Property<Guid>("VirtualTagRowId")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier")
.HasDefaultValueSql("NEWSEQUENTIALID()");
b.Property<bool>("ChangeTriggered")
.HasColumnType("bit");
b.Property<string>("DataType")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<bool>("Enabled")
.HasColumnType("bit");
b.Property<string>("EquipmentId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<long>("GenerationId")
.HasColumnType("bigint");
b.Property<bool>("Historize")
.HasColumnType("bit");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("ScriptId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<int?>("TimerIntervalMs")
.HasColumnType("int");
b.Property<string>("VirtualTagId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.HasKey("VirtualTagRowId");
b.HasIndex("GenerationId", "ScriptId")
.HasDatabaseName("IX_VirtualTag_Generation_Script");
b.HasIndex("GenerationId", "VirtualTagId")
.IsUnique()
.HasDatabaseName("UX_VirtualTag_Generation_LogicalId")
.HasFilter("[VirtualTagId] IS NOT NULL");
b.HasIndex("GenerationId", "EquipmentId", "Name")
.IsUnique()
.HasDatabaseName("UX_VirtualTag_Generation_EquipmentPath");
b.ToTable("VirtualTag", null, t =>
{
t.HasCheckConstraint("CK_VirtualTag_TimerInterval_Min", "TimerIntervalMs IS NULL OR TimerIntervalMs >= 50");
t.HasCheckConstraint("CK_VirtualTag_Trigger_AtLeastOne", "ChangeTriggered = 1 OR TimerIntervalMs IS NOT NULL");
});
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", b =>
{
b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster")
@@ -1435,6 +1690,28 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
b.Navigation("Generation");
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Script", b =>
{
b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation")
.WithMany()
.HasForeignKey("GenerationId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Generation");
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ScriptedAlarm", b =>
{
b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation")
.WithMany()
.HasForeignKey("GenerationId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Generation");
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Tag", b =>
{
b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation")
@@ -1476,6 +1753,17 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
b.Navigation("Generation");
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.VirtualTag", b =>
{
b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation")
.WithMany()
.HasForeignKey("GenerationId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Generation");
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", b =>
{
b.Navigation("Credentials");

View File

@@ -32,6 +32,10 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
public DbSet<LdapGroupRoleMapping> LdapGroupRoleMappings => Set<LdapGroupRoleMapping>();
public DbSet<EquipmentImportBatch> EquipmentImportBatches => Set<EquipmentImportBatch>();
public DbSet<EquipmentImportRow> EquipmentImportRows => Set<EquipmentImportRow>();
public DbSet<Script> Scripts => Set<Script>();
public DbSet<VirtualTag> VirtualTags => Set<VirtualTag>();
public DbSet<ScriptedAlarm> ScriptedAlarms => Set<ScriptedAlarm>();
public DbSet<ScriptedAlarmState> ScriptedAlarmStates => Set<ScriptedAlarmState>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -56,6 +60,10 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
ConfigureDriverInstanceResilienceStatus(modelBuilder);
ConfigureLdapGroupRoleMapping(modelBuilder);
ConfigureEquipmentImportBatch(modelBuilder);
ConfigureScript(modelBuilder);
ConfigureVirtualTag(modelBuilder);
ConfigureScriptedAlarm(modelBuilder);
ConfigureScriptedAlarmState(modelBuilder);
}
private static void ConfigureServerCluster(ModelBuilder modelBuilder)
@@ -619,4 +627,106 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
e.HasIndex(x => x.BatchId).HasDatabaseName("IX_EquipmentImportRow_Batch");
});
}
private static void ConfigureScript(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Script>(e =>
{
e.ToTable("Script");
e.HasKey(x => x.ScriptRowId);
e.Property(x => x.ScriptRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
e.Property(x => x.ScriptId).HasMaxLength(64);
e.Property(x => x.Name).HasMaxLength(128);
e.Property(x => x.SourceCode).HasColumnType("nvarchar(max)");
e.Property(x => x.SourceHash).HasMaxLength(64);
e.Property(x => x.Language).HasMaxLength(16);
e.HasOne(x => x.Generation).WithMany().HasForeignKey(x => x.GenerationId).OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => new { x.GenerationId, x.ScriptId }).IsUnique().HasDatabaseName("UX_Script_Generation_LogicalId");
e.HasIndex(x => new { x.GenerationId, x.SourceHash }).HasDatabaseName("IX_Script_Generation_SourceHash");
});
}
private static void ConfigureVirtualTag(ModelBuilder modelBuilder)
{
modelBuilder.Entity<VirtualTag>(e =>
{
e.ToTable("VirtualTag", t =>
{
t.HasCheckConstraint("CK_VirtualTag_Trigger_AtLeastOne",
"ChangeTriggered = 1 OR TimerIntervalMs IS NOT NULL");
t.HasCheckConstraint("CK_VirtualTag_TimerInterval_Min",
"TimerIntervalMs IS NULL OR TimerIntervalMs >= 50");
});
e.HasKey(x => x.VirtualTagRowId);
e.Property(x => x.VirtualTagRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
e.Property(x => x.VirtualTagId).HasMaxLength(64);
e.Property(x => x.EquipmentId).HasMaxLength(64);
e.Property(x => x.Name).HasMaxLength(128);
e.Property(x => x.DataType).HasMaxLength(32);
e.Property(x => x.ScriptId).HasMaxLength(64);
e.HasOne(x => x.Generation).WithMany().HasForeignKey(x => x.GenerationId).OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => new { x.GenerationId, x.VirtualTagId }).IsUnique().HasDatabaseName("UX_VirtualTag_Generation_LogicalId");
e.HasIndex(x => new { x.GenerationId, x.EquipmentId, x.Name }).IsUnique().HasDatabaseName("UX_VirtualTag_Generation_EquipmentPath");
e.HasIndex(x => new { x.GenerationId, x.ScriptId }).HasDatabaseName("IX_VirtualTag_Generation_Script");
});
}
private static void ConfigureScriptedAlarm(ModelBuilder modelBuilder)
{
modelBuilder.Entity<ScriptedAlarm>(e =>
{
e.ToTable("ScriptedAlarm", t =>
{
t.HasCheckConstraint("CK_ScriptedAlarm_Severity_Range", "Severity BETWEEN 1 AND 1000");
t.HasCheckConstraint("CK_ScriptedAlarm_AlarmType",
"AlarmType IN ('AlarmCondition','LimitAlarm','OffNormalAlarm','DiscreteAlarm')");
});
e.HasKey(x => x.ScriptedAlarmRowId);
e.Property(x => x.ScriptedAlarmRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
e.Property(x => x.ScriptedAlarmId).HasMaxLength(64);
e.Property(x => x.EquipmentId).HasMaxLength(64);
e.Property(x => x.Name).HasMaxLength(128);
e.Property(x => x.AlarmType).HasMaxLength(32);
e.Property(x => x.MessageTemplate).HasMaxLength(1024);
e.Property(x => x.PredicateScriptId).HasMaxLength(64);
e.HasOne(x => x.Generation).WithMany().HasForeignKey(x => x.GenerationId).OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => new { x.GenerationId, x.ScriptedAlarmId }).IsUnique().HasDatabaseName("UX_ScriptedAlarm_Generation_LogicalId");
e.HasIndex(x => new { x.GenerationId, x.EquipmentId, x.Name }).IsUnique().HasDatabaseName("UX_ScriptedAlarm_Generation_EquipmentPath");
e.HasIndex(x => new { x.GenerationId, x.PredicateScriptId }).HasDatabaseName("IX_ScriptedAlarm_Generation_Script");
});
}
private static void ConfigureScriptedAlarmState(ModelBuilder modelBuilder)
{
modelBuilder.Entity<ScriptedAlarmState>(e =>
{
// Logical-id keyed (not generation-scoped) because ack state follows the alarm's
// stable identity across generations — Modified alarms keep their ack audit trail.
e.ToTable("ScriptedAlarmState", t =>
{
t.HasCheckConstraint("CK_ScriptedAlarmState_CommentsJson_IsJson", "ISJSON(CommentsJson) = 1");
});
e.HasKey(x => x.ScriptedAlarmId);
e.Property(x => x.ScriptedAlarmId).HasMaxLength(64);
e.Property(x => x.EnabledState).HasMaxLength(16);
e.Property(x => x.AckedState).HasMaxLength(16);
e.Property(x => x.ConfirmedState).HasMaxLength(16);
e.Property(x => x.ShelvingState).HasMaxLength(16);
e.Property(x => x.ShelvingExpiresUtc).HasColumnType("datetime2(3)");
e.Property(x => x.LastAckUser).HasMaxLength(128);
e.Property(x => x.LastAckComment).HasMaxLength(1024);
e.Property(x => x.LastAckUtc).HasColumnType("datetime2(3)");
e.Property(x => x.LastConfirmUser).HasMaxLength(128);
e.Property(x => x.LastConfirmComment).HasMaxLength(1024);
e.Property(x => x.LastConfirmUtc).HasColumnType("datetime2(3)");
e.Property(x => x.CommentsJson).HasColumnType("nvarchar(max)");
e.Property(x => x.UpdatedAtUtc).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()");
});
}
}

View File

@@ -33,6 +33,18 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// (holding registers with level-set values, set-point writes to analog tags) — the
/// capability invoker respects this flag when deciding whether to apply Polly retry.
/// </param>
/// <param name="Source">
/// Per ADR-002 — discriminates which runtime subsystem owns this node's dispatch.
/// Defaults to <see cref="NodeSourceKind.Driver"/> so existing callers are unchanged.
/// </param>
/// <param name="VirtualTagId">
/// Set when <paramref name="Source"/> is <see cref="NodeSourceKind.Virtual"/> — stable
/// logical id the VirtualTagEngine addresses by. Null otherwise.
/// </param>
/// <param name="ScriptedAlarmId">
/// Set when <paramref name="Source"/> is <see cref="NodeSourceKind.ScriptedAlarm"/> —
/// stable logical id the ScriptedAlarmEngine addresses by. Null otherwise.
/// </param>
public sealed record DriverAttributeInfo(
string FullName,
DriverDataType DriverDataType,
@@ -41,4 +53,21 @@ public sealed record DriverAttributeInfo(
SecurityClassification SecurityClass,
bool IsHistorized,
bool IsAlarm = false,
bool WriteIdempotent = false);
bool WriteIdempotent = false,
NodeSourceKind Source = NodeSourceKind.Driver,
string? VirtualTagId = null,
string? ScriptedAlarmId = null);
/// <summary>
/// Per ADR-002 — discriminates which runtime subsystem owns this node's Read/Write/
/// Subscribe dispatch. <c>Driver</c> = a real IDriver capability surface;
/// <c>Virtual</c> = a Phase 7 <see cref="DriverAttributeInfo"/>.VirtualTagId'd tag
/// computed by the VirtualTagEngine; <c>ScriptedAlarm</c> = a scripted Part 9 alarm
/// materialized by the ScriptedAlarmEngine.
/// </summary>
public enum NodeSourceKind
{
Driver = 0,
Virtual = 1,
ScriptedAlarm = 2,
}

View File

@@ -0,0 +1,36 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
/// <summary>
/// The event shape the historian sink consumes — source-agnostic across scripted
/// alarms + Galaxy-native + AB CIP ALMD + any future IAlarmSource per Phase 7 plan
/// decision #15 (sink scope = all alarm sources, not just scripted). A per-alarm
/// <c>HistorizeToAveva</c> toggle on the producer side gates which events flow.
/// </summary>
/// <param name="AlarmId">Stable condition identity.</param>
/// <param name="EquipmentPath">UNS path of the Equipment node the alarm hangs under. Doubles as the "SourceNode" in Historian's alarm schema.</param>
/// <param name="AlarmName">Human-readable alarm name.</param>
/// <param name="AlarmTypeName">Concrete Part 9 subtype — "LimitAlarm" / "DiscreteAlarm" / "OffNormalAlarm" / "AlarmCondition". Used as the Historian "AlarmType" column.</param>
/// <param name="Severity">Mapped to Historian's numeric priority on the sink side.</param>
/// <param name="EventKind">
/// Which state transition this event represents — "Activated" / "Cleared" /
/// "Acknowledged" / "Confirmed" / "Shelved" / "Unshelved" / "Disabled" / "Enabled" /
/// "CommentAdded". Free-form string because different alarm sources use different
/// vocabularies; the Galaxy.Host handler maps to the historian's enum on the wire.
/// </param>
/// <param name="Message">Fully-rendered message text — template tokens already resolved upstream.</param>
/// <param name="User">Operator who triggered the transition. "system" for engine-driven events (shelving expiry, predicate change).</param>
/// <param name="Comment">Operator-supplied free-form text, if any.</param>
/// <param name="TimestampUtc">When the transition occurred.</param>
public sealed record AlarmHistorianEvent(
string AlarmId,
string EquipmentPath,
string AlarmName,
string AlarmTypeName,
AlarmSeverity Severity,
string EventKind,
string Message,
string User,
string? Comment,
DateTime TimestampUtc);

View File

@@ -0,0 +1,82 @@
namespace ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
/// <summary>
/// The historian sink contract — where qualifying alarm events land. Phase 7 plan
/// decision #17: ingestion routes through Galaxy.Host's pipe so we reuse the
/// already-loaded <c>aahClientManaged</c> DLLs without loading 32-bit native code
/// in the main .NET 10 server. Tests use an in-memory fake; production uses
/// <see cref="SqliteStoreAndForwardSink"/>.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="EnqueueAsync"/> is fire-and-forget from the engine's perspective —
/// the sink MUST NOT block the emitting thread. Production implementations
/// (<see cref="SqliteStoreAndForwardSink"/>) persist to a local SQLite queue
/// first, then drain asynchronously to the actual historian. Per Phase 7 plan
/// decision #16, failed downstream writes replay with exponential backoff;
/// operator actions are never blocked waiting on the historian.
/// </para>
/// <para>
/// <see cref="GetStatus"/> exposes queue depth + drain rate + last error
/// for the Admin UI <c>/alarms/historian</c> diagnostics page (Stream F).
/// </para>
/// </remarks>
public interface IAlarmHistorianSink
{
/// <summary>Durably enqueue the event. Returns as soon as the queue row is committed.</summary>
Task EnqueueAsync(AlarmHistorianEvent evt, CancellationToken cancellationToken);
/// <summary>Snapshot of current queue depth + drain health.</summary>
HistorianSinkStatus GetStatus();
}
/// <summary>No-op default for tests or deployments that don't historize alarms.</summary>
public sealed class NullAlarmHistorianSink : IAlarmHistorianSink
{
public static readonly NullAlarmHistorianSink Instance = new();
public Task EnqueueAsync(AlarmHistorianEvent evt, CancellationToken cancellationToken) => Task.CompletedTask;
public HistorianSinkStatus GetStatus() => new(
QueueDepth: 0,
DeadLetterDepth: 0,
LastDrainUtc: null,
LastSuccessUtc: null,
LastError: null,
DrainState: HistorianDrainState.Disabled);
}
/// <summary>Diagnostic snapshot surfaced to the Admin UI + /healthz endpoints.</summary>
public sealed record HistorianSinkStatus(
long QueueDepth,
long DeadLetterDepth,
DateTime? LastDrainUtc,
DateTime? LastSuccessUtc,
string? LastError,
HistorianDrainState DrainState);
/// <summary>Where the drain worker is in its state machine.</summary>
public enum HistorianDrainState
{
Disabled,
Idle,
Draining,
BackingOff,
}
/// <summary>Signaled by the Galaxy.Host-side handler when it fails a batch — drain worker uses this to decide retry cadence.</summary>
public enum HistorianWriteOutcome
{
/// <summary>Successfully persisted to the historian. Remove from queue.</summary>
Ack,
/// <summary>Transient failure (historian disconnected, timeout, busy). Leave queued; retry after backoff.</summary>
RetryPlease,
/// <summary>Permanent failure (malformed event, unrecoverable SDK error). Move to dead-letter table.</summary>
PermanentFail,
}
/// <summary>What the drain worker delegates writes to — Stream G wires this to the Galaxy.Host IPC client.</summary>
public interface IAlarmHistorianWriter
{
/// <summary>Push a batch of events to the historian. Returns one outcome per event, same order.</summary>
Task<IReadOnlyList<HistorianWriteOutcome>> WriteBatchAsync(
IReadOnlyList<AlarmHistorianEvent> batch, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,397 @@
using System.Text.Json;
using Microsoft.Data.Sqlite;
using Serilog;
namespace ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
/// <summary>
/// Phase 7 plan decisions #16#17 implementation: durable SQLite queue on the node
/// absorbs every qualifying alarm event, a drain worker batches rows to Galaxy.Host
/// via <see cref="IAlarmHistorianWriter"/> on an exponential-backoff cadence, and
/// operator acks never block on the historian being reachable.
/// </summary>
/// <remarks>
/// <para>
/// Queue schema:
/// <code>
/// CREATE TABLE Queue (
/// RowId INTEGER PRIMARY KEY AUTOINCREMENT,
/// AlarmId TEXT NOT NULL,
/// EnqueuedUtc TEXT NOT NULL,
/// PayloadJson TEXT NOT NULL,
/// AttemptCount INTEGER NOT NULL DEFAULT 0,
/// LastAttemptUtc TEXT NULL,
/// LastError TEXT NULL,
/// DeadLettered INTEGER NOT NULL DEFAULT 0
/// );
/// </code>
/// Dead-lettered rows stay in place for the configured retention window (default
/// 30 days per Phase 7 plan decision #21) so operators can inspect + manually
/// retry before the sweeper purges them. Regular queue capacity is bounded —
/// overflow evicts the oldest non-dead-lettered rows with a WARN log.
/// </para>
/// <para>
/// Drain runs on a shared <see cref="System.Threading.Timer"/>. Exponential
/// backoff on <see cref="HistorianWriteOutcome.RetryPlease"/>: 1s → 2s → 5s →
/// 15s → 60s cap. <see cref="HistorianWriteOutcome.PermanentFail"/> rows flip
/// the <c>DeadLettered</c> flag on the individual row; neighbors in the batch
/// still retry on their own cadence.
/// </para>
/// </remarks>
public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable
{
/// <summary>Default queue capacity — oldest non-dead-lettered rows evicted past this.</summary>
public const long DefaultCapacity = 1_000_000;
public static readonly TimeSpan DefaultDeadLetterRetention = TimeSpan.FromDays(30);
private static readonly TimeSpan[] BackoffLadder =
[
TimeSpan.FromSeconds(1),
TimeSpan.FromSeconds(2),
TimeSpan.FromSeconds(5),
TimeSpan.FromSeconds(15),
TimeSpan.FromSeconds(60),
];
private readonly string _connectionString;
private readonly IAlarmHistorianWriter _writer;
private readonly ILogger _logger;
private readonly int _batchSize;
private readonly long _capacity;
private readonly TimeSpan _deadLetterRetention;
private readonly Func<DateTime> _clock;
private readonly SemaphoreSlim _drainGate = new(1, 1);
private Timer? _drainTimer;
private int _backoffIndex;
private DateTime? _lastDrainUtc;
private DateTime? _lastSuccessUtc;
private string? _lastError;
private HistorianDrainState _drainState = HistorianDrainState.Idle;
private bool _disposed;
public SqliteStoreAndForwardSink(
string databasePath,
IAlarmHistorianWriter writer,
ILogger logger,
int batchSize = 100,
long capacity = DefaultCapacity,
TimeSpan? deadLetterRetention = null,
Func<DateTime>? clock = null)
{
if (string.IsNullOrWhiteSpace(databasePath))
throw new ArgumentException("Database path required.", nameof(databasePath));
_writer = writer ?? throw new ArgumentNullException(nameof(writer));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_batchSize = batchSize > 0 ? batchSize : throw new ArgumentOutOfRangeException(nameof(batchSize));
_capacity = capacity > 0 ? capacity : throw new ArgumentOutOfRangeException(nameof(capacity));
_deadLetterRetention = deadLetterRetention ?? DefaultDeadLetterRetention;
_clock = clock ?? (() => DateTime.UtcNow);
_connectionString = $"Data Source={databasePath}";
InitializeSchema();
}
/// <summary>
/// Start the background drain worker. Not started automatically so tests can
/// drive <see cref="DrainOnceAsync"/> deterministically.
/// </summary>
public void StartDrainLoop(TimeSpan tickInterval)
{
if (_disposed) throw new ObjectDisposedException(nameof(SqliteStoreAndForwardSink));
_drainTimer?.Dispose();
_drainTimer = new Timer(_ => _ = DrainOnceAsync(CancellationToken.None),
null, tickInterval, tickInterval);
}
public Task EnqueueAsync(AlarmHistorianEvent evt, CancellationToken cancellationToken)
{
if (evt is null) throw new ArgumentNullException(nameof(evt));
if (_disposed) throw new ObjectDisposedException(nameof(SqliteStoreAndForwardSink));
using var conn = new SqliteConnection(_connectionString);
conn.Open();
EnforceCapacity(conn);
using var cmd = conn.CreateCommand();
cmd.CommandText = """
INSERT INTO Queue (AlarmId, EnqueuedUtc, PayloadJson, AttemptCount)
VALUES ($alarmId, $enqueued, $payload, 0);
""";
cmd.Parameters.AddWithValue("$alarmId", evt.AlarmId);
cmd.Parameters.AddWithValue("$enqueued", _clock().ToString("O"));
cmd.Parameters.AddWithValue("$payload", JsonSerializer.Serialize(evt));
cmd.ExecuteNonQuery();
return Task.CompletedTask;
}
/// <summary>
/// Read up to <see cref="_batchSize"/> queued rows, forward through the writer,
/// remove Ack'd rows, dead-letter PermanentFail rows, and extend the backoff
/// on RetryPlease. Safe to call from multiple threads; the semaphore enforces
/// serial execution.
/// </summary>
public async Task DrainOnceAsync(CancellationToken ct)
{
if (_disposed) return;
if (!await _drainGate.WaitAsync(0, ct).ConfigureAwait(false)) return;
try
{
_drainState = HistorianDrainState.Draining;
_lastDrainUtc = _clock();
PurgeAgedDeadLetters();
var (rowIds, events) = ReadBatch();
if (rowIds.Count == 0)
{
_drainState = HistorianDrainState.Idle;
return;
}
IReadOnlyList<HistorianWriteOutcome> outcomes;
try
{
outcomes = await _writer.WriteBatchAsync(events, ct).ConfigureAwait(false);
_lastError = null;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
// Writer-side exception — treat entire batch as RetryPlease.
_lastError = ex.Message;
_logger.Warning(ex, "Historian writer threw on batch of {Count}; deferring retry", events.Count);
BumpBackoff();
_drainState = HistorianDrainState.BackingOff;
return;
}
if (outcomes.Count != events.Count)
throw new InvalidOperationException(
$"Writer returned {outcomes.Count} outcomes for {events.Count} events — expected 1:1");
using var conn = new SqliteConnection(_connectionString);
conn.Open();
using var tx = conn.BeginTransaction();
for (var i = 0; i < outcomes.Count; i++)
{
var outcome = outcomes[i];
var rowId = rowIds[i];
switch (outcome)
{
case HistorianWriteOutcome.Ack:
DeleteRow(conn, tx, rowId);
break;
case HistorianWriteOutcome.PermanentFail:
DeadLetterRow(conn, tx, rowId, $"permanent fail at {_clock():O}");
break;
case HistorianWriteOutcome.RetryPlease:
BumpAttempt(conn, tx, rowId, "retry-please");
break;
}
}
tx.Commit();
var acks = outcomes.Count(o => o == HistorianWriteOutcome.Ack);
if (acks > 0) _lastSuccessUtc = _clock();
if (outcomes.Any(o => o == HistorianWriteOutcome.RetryPlease))
{
BumpBackoff();
_drainState = HistorianDrainState.BackingOff;
}
else
{
ResetBackoff();
_drainState = HistorianDrainState.Idle;
}
}
finally
{
_drainGate.Release();
}
}
public HistorianSinkStatus GetStatus()
{
using var conn = new SqliteConnection(_connectionString);
conn.Open();
long queued;
long deadlettered;
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = "SELECT COUNT(*) FROM Queue WHERE DeadLettered = 0";
queued = (long)(cmd.ExecuteScalar() ?? 0L);
}
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = "SELECT COUNT(*) FROM Queue WHERE DeadLettered = 1";
deadlettered = (long)(cmd.ExecuteScalar() ?? 0L);
}
return new HistorianSinkStatus(
QueueDepth: queued,
DeadLetterDepth: deadlettered,
LastDrainUtc: _lastDrainUtc,
LastSuccessUtc: _lastSuccessUtc,
LastError: _lastError,
DrainState: _drainState);
}
/// <summary>Operator action from Admin UI — retry every dead-lettered row. Non-cascading: they rejoin the regular queue + get a fresh backoff.</summary>
public int RetryDeadLettered()
{
using var conn = new SqliteConnection(_connectionString);
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = "UPDATE Queue SET DeadLettered = 0, AttemptCount = 0, LastError = NULL WHERE DeadLettered = 1";
return cmd.ExecuteNonQuery();
}
private (List<long> rowIds, List<AlarmHistorianEvent> events) ReadBatch()
{
var rowIds = new List<long>();
var events = new List<AlarmHistorianEvent>();
using var conn = new SqliteConnection(_connectionString);
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = """
SELECT RowId, PayloadJson FROM Queue
WHERE DeadLettered = 0
ORDER BY RowId ASC
LIMIT $limit
""";
cmd.Parameters.AddWithValue("$limit", _batchSize);
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
rowIds.Add(reader.GetInt64(0));
var payload = reader.GetString(1);
var evt = JsonSerializer.Deserialize<AlarmHistorianEvent>(payload);
if (evt is not null) events.Add(evt);
}
return (rowIds, events);
}
private static void DeleteRow(SqliteConnection conn, SqliteTransaction tx, long rowId)
{
using var cmd = conn.CreateCommand();
cmd.Transaction = tx;
cmd.CommandText = "DELETE FROM Queue WHERE RowId = $id";
cmd.Parameters.AddWithValue("$id", rowId);
cmd.ExecuteNonQuery();
}
private void DeadLetterRow(SqliteConnection conn, SqliteTransaction tx, long rowId, string reason)
{
using var cmd = conn.CreateCommand();
cmd.Transaction = tx;
cmd.CommandText = """
UPDATE Queue SET DeadLettered = 1, LastAttemptUtc = $now, LastError = $err, AttemptCount = AttemptCount + 1
WHERE RowId = $id
""";
cmd.Parameters.AddWithValue("$now", _clock().ToString("O"));
cmd.Parameters.AddWithValue("$err", reason);
cmd.Parameters.AddWithValue("$id", rowId);
cmd.ExecuteNonQuery();
}
private void BumpAttempt(SqliteConnection conn, SqliteTransaction tx, long rowId, string reason)
{
using var cmd = conn.CreateCommand();
cmd.Transaction = tx;
cmd.CommandText = """
UPDATE Queue SET LastAttemptUtc = $now, LastError = $err, AttemptCount = AttemptCount + 1
WHERE RowId = $id
""";
cmd.Parameters.AddWithValue("$now", _clock().ToString("O"));
cmd.Parameters.AddWithValue("$err", reason);
cmd.Parameters.AddWithValue("$id", rowId);
cmd.ExecuteNonQuery();
}
private void EnforceCapacity(SqliteConnection conn)
{
// Count non-dead-lettered rows only — dead-lettered rows retain for
// post-mortem per the configured retention window.
long count;
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = "SELECT COUNT(*) FROM Queue WHERE DeadLettered = 0";
count = (long)(cmd.ExecuteScalar() ?? 0L);
}
if (count < _capacity) return;
var toEvict = count - _capacity + 1;
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = """
DELETE FROM Queue
WHERE RowId IN (
SELECT RowId FROM Queue
WHERE DeadLettered = 0
ORDER BY RowId ASC
LIMIT $n
)
""";
cmd.Parameters.AddWithValue("$n", toEvict);
cmd.ExecuteNonQuery();
}
_logger.Warning(
"Historian queue at capacity {Cap} — evicted {Count} oldest row(s) to make room",
_capacity, toEvict);
}
private void PurgeAgedDeadLetters()
{
var cutoff = (_clock() - _deadLetterRetention).ToString("O");
using var conn = new SqliteConnection(_connectionString);
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = """
DELETE FROM Queue
WHERE DeadLettered = 1 AND LastAttemptUtc IS NOT NULL AND LastAttemptUtc < $cutoff
""";
cmd.Parameters.AddWithValue("$cutoff", cutoff);
var purged = cmd.ExecuteNonQuery();
if (purged > 0)
_logger.Information("Purged {Count} dead-lettered row(s) past retention window", purged);
}
private void InitializeSchema()
{
using var conn = new SqliteConnection(_connectionString);
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = """
CREATE TABLE IF NOT EXISTS Queue (
RowId INTEGER PRIMARY KEY AUTOINCREMENT,
AlarmId TEXT NOT NULL,
EnqueuedUtc TEXT NOT NULL,
PayloadJson TEXT NOT NULL,
AttemptCount INTEGER NOT NULL DEFAULT 0,
LastAttemptUtc TEXT NULL,
LastError TEXT NULL,
DeadLettered INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS IX_Queue_Drain ON Queue (DeadLettered, RowId);
""";
cmd.ExecuteNonQuery();
}
private void BumpBackoff() => _backoffIndex = Math.Min(_backoffIndex + 1, BackoffLadder.Length - 1);
private void ResetBackoff() => _backoffIndex = 0;
public TimeSpan CurrentBackoff => BackoffLadder[_backoffIndex];
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_drainTimer?.Dispose();
_drainGate.Dispose();
}
}

View File

@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" Version="9.0.0"/>
<PackageReference Include="Serilog" Version="4.2.0"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests"/>
</ItemGroup>
<ItemGroup>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,84 @@
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
/// <summary>
/// Persistent per-alarm state tracked by the Part 9 state machine. Every field
/// carried here either participates in the state machine or contributes to the
/// audit trail required by Phase 7 plan decision #14 (GxP / 21 CFR Part 11).
/// </summary>
/// <remarks>
/// <para>
/// <see cref="Active"/> is re-derived from the predicate at startup per Phase 7
/// decision #14 — the engine runs every alarm's predicate against current tag
/// values at <c>Load</c>, overriding whatever Active state is in the store.
/// Every other state field persists verbatim across server restarts so
/// operators don't re-ack active alarms after an outage + shelved alarms stay
/// shelved + audit history survives.
/// </para>
/// <para>
/// <see cref="Comments"/> is append-only; comments + ack/confirm user identities
/// are the audit surface regulators consume. The engine never rewrites past
/// entries.
/// </para>
/// </remarks>
public sealed record AlarmConditionState(
string AlarmId,
AlarmEnabledState Enabled,
AlarmActiveState Active,
AlarmAckedState Acked,
AlarmConfirmedState Confirmed,
ShelvingState Shelving,
DateTime LastTransitionUtc,
DateTime? LastActiveUtc,
DateTime? LastClearedUtc,
DateTime? LastAckUtc,
string? LastAckUser,
string? LastAckComment,
DateTime? LastConfirmUtc,
string? LastConfirmUser,
string? LastConfirmComment,
IReadOnlyList<AlarmComment> Comments)
{
/// <summary>Initial-load state for a newly registered alarm — everything in the "no-event" position.</summary>
public static AlarmConditionState Fresh(string alarmId, DateTime nowUtc) => new(
AlarmId: alarmId,
Enabled: AlarmEnabledState.Enabled,
Active: AlarmActiveState.Inactive,
Acked: AlarmAckedState.Acknowledged,
Confirmed: AlarmConfirmedState.Confirmed,
Shelving: ShelvingState.Unshelved,
LastTransitionUtc: nowUtc,
LastActiveUtc: null,
LastClearedUtc: null,
LastAckUtc: null,
LastAckUser: null,
LastAckComment: null,
LastConfirmUtc: null,
LastConfirmUser: null,
LastConfirmComment: null,
Comments: []);
}
/// <summary>
/// Shelving state — kind plus, for <see cref="ShelvingKind.Timed"/>, the UTC
/// timestamp at which the shelving auto-expires. The engine polls the timer on its
/// evaluation cadence; callers should not rely on millisecond-precision expiry.
/// </summary>
public sealed record ShelvingState(ShelvingKind Kind, DateTime? UnshelveAtUtc)
{
public static readonly ShelvingState Unshelved = new(ShelvingKind.Unshelved, null);
}
/// <summary>
/// A single append-only audit record — acknowledgement / confirmation / explicit
/// comment / shelving action. Every entry carries a monotonic UTC timestamp plus the
/// user identity Phase 6.2 authenticated.
/// </summary>
/// <param name="TimestampUtc">When the action happened.</param>
/// <param name="User">OS / LDAP identity of the actor. For engine-internal events (shelving expiry, startup recovery) this is <c>"system"</c>.</param>
/// <param name="Kind">Human-readable classification — "Acknowledge", "Confirm", "ShelveOneShot", "ShelveTimed", "Unshelve", "AddComment", "Enable", "Disable", "AutoUnshelve".</param>
/// <param name="Text">Operator-supplied comment or engine-generated message.</param>
public sealed record AlarmComment(
DateTime TimestampUtc,
string User,
string Kind,
string Text);

View File

@@ -0,0 +1,55 @@
using Serilog;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
/// <summary>
/// <see cref="ScriptContext"/> subclass for alarm predicate evaluation. Reads from
/// the engine's shared tag cache (driver + virtual tags), writes are rejected —
/// predicates must be side-effect free so their output doesn't depend on evaluation
/// order or drive cascade behavior.
/// </summary>
/// <remarks>
/// Per Phase 7 plan Shape A decision, alarm scripts are one-script-per-alarm
/// returning <c>bool</c>. They read any tag they want but should not write
/// anything (the owning alarm's state is tracked by the engine, not the script).
/// </remarks>
public sealed class AlarmPredicateContext : ScriptContext
{
private readonly IReadOnlyDictionary<string, DataValueSnapshot> _readCache;
private readonly Func<DateTime> _clock;
public AlarmPredicateContext(
IReadOnlyDictionary<string, DataValueSnapshot> readCache,
ILogger logger,
Func<DateTime>? clock = null)
{
_readCache = readCache ?? throw new ArgumentNullException(nameof(readCache));
Logger = logger ?? throw new ArgumentNullException(nameof(logger));
_clock = clock ?? (() => DateTime.UtcNow);
}
public override DataValueSnapshot GetTag(string path)
{
if (string.IsNullOrWhiteSpace(path))
return new DataValueSnapshot(null, 0x80340000u, null, _clock());
return _readCache.TryGetValue(path, out var v)
? v
: new DataValueSnapshot(null, 0x80340000u, null, _clock());
}
public override void SetVirtualTag(string path, object? value)
{
// Predicates must be pure — writing from an alarm script couples alarm state to
// virtual-tag state in a way that's near-impossible to reason about. Rejected
// at runtime with a clear message; operators see it in the scripts-*.log.
throw new InvalidOperationException(
"Alarm predicate scripts cannot write to virtual tags. Move the write logic " +
"into a virtual tag whose value the alarm predicate then reads.");
}
public override DateTime Now => _clock();
public override ILogger Logger { get; }
}

View File

@@ -0,0 +1,40 @@
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
/// <summary>
/// The concrete OPC UA Part 9 alarm subtype a scripted alarm materializes as. The
/// engine's internal state machine is identical regardless of kind — the
/// <c>AlarmKind</c> only affects how the alarm node appears to OPC UA clients
/// (which ObjectType it maps to) and what diagnostic fields are populated.
/// </summary>
public enum AlarmKind
{
/// <summary>Base AlarmConditionType — no numeric or discrete interpretation.</summary>
AlarmCondition,
/// <summary>LimitAlarmType — the condition reflects a numeric setpoint / threshold breach.</summary>
LimitAlarm,
/// <summary>DiscreteAlarmType — the condition reflects a specific discrete value match.</summary>
DiscreteAlarm,
/// <summary>OffNormalAlarmType — the condition reflects deviation from a configured "normal" state.</summary>
OffNormalAlarm,
}
/// <summary>OPC UA Part 9 EnabledState — operator-controlled alarm enable/disable.</summary>
public enum AlarmEnabledState { Enabled, Disabled }
/// <summary>OPC UA Part 9 ActiveState — reflects the current predicate truth.</summary>
public enum AlarmActiveState { Inactive, Active }
/// <summary>OPC UA Part 9 AckedState — operator has acknowledged the active transition.</summary>
public enum AlarmAckedState { Unacknowledged, Acknowledged }
/// <summary>OPC UA Part 9 ConfirmedState — operator has confirmed the clear transition.</summary>
public enum AlarmConfirmedState { Unconfirmed, Confirmed }
/// <summary>
/// OPC UA Part 9 shelving mode.
/// <see cref="OneShot"/> suppresses the next active transition; once cleared
/// the shelving expires and the alarm returns to normal behavior.
/// <see cref="Timed"/> suppresses until a configured expiry timestamp passes.
/// <see cref="Unshelved"/> is the default state — no suppression.
/// </summary>
public enum ShelvingKind { Unshelved, OneShot, Timed }

View File

@@ -0,0 +1,47 @@
using System.Collections.Concurrent;
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
/// <summary>
/// Persistence for <see cref="AlarmConditionState"/> across server restarts. Phase 7
/// plan decision #14: operator-supplied state (EnabledState / AckedState /
/// ConfirmedState / ShelvingState + audit trail) persists; ActiveState is
/// recomputed from the live predicate on startup so operators never re-ack.
/// </summary>
/// <remarks>
/// Stream E wires this to a SQL-backed store against the <c>ScriptedAlarmState</c>
/// table with audit logging through <see cref="Core.Abstractions"/> IAuditLogger.
/// Tests + local dev use <see cref="InMemoryAlarmStateStore"/>.
/// </remarks>
public interface IAlarmStateStore
{
Task<AlarmConditionState?> LoadAsync(string alarmId, CancellationToken ct);
Task<IReadOnlyList<AlarmConditionState>> LoadAllAsync(CancellationToken ct);
Task SaveAsync(AlarmConditionState state, CancellationToken ct);
Task RemoveAsync(string alarmId, CancellationToken ct);
}
/// <summary>In-memory default — used by tests + by dev deployments without a SQL backend.</summary>
public sealed class InMemoryAlarmStateStore : IAlarmStateStore
{
private readonly ConcurrentDictionary<string, AlarmConditionState> _map
= new(StringComparer.Ordinal);
public Task<AlarmConditionState?> LoadAsync(string alarmId, CancellationToken ct)
=> Task.FromResult(_map.TryGetValue(alarmId, out var v) ? v : null);
public Task<IReadOnlyList<AlarmConditionState>> LoadAllAsync(CancellationToken ct)
=> Task.FromResult<IReadOnlyList<AlarmConditionState>>(_map.Values.ToArray());
public Task SaveAsync(AlarmConditionState state, CancellationToken ct)
{
_map[state.AlarmId] = state;
return Task.CompletedTask;
}
public Task RemoveAsync(string alarmId, CancellationToken ct)
{
_map.TryRemove(alarmId, out _);
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,64 @@
using System.Text.RegularExpressions;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
/// <summary>
/// Per Phase 7 plan decision #13, alarm messages are static-with-substitution
/// templates. The engine resolves <c>{TagPath}</c> tokens at event emission time
/// against current tag values; unresolvable tokens become <c>{?}</c> so the event
/// still fires but the operator sees where the reference broke.
/// </summary>
/// <remarks>
/// <para>
/// Token syntax: <c>{path/with/slashes}</c>. Brace-stripped the contents must
/// match a path the caller's resolver function can look up. No escaping
/// currently — if you need literal braces in the message, reach for a feature
/// request.
/// </para>
/// <para>
/// Pure function. Same inputs always produce the same string. Tests verify the
/// edge cases (no tokens / one token / many / nested / unresolvable / bad
/// quality / null value).
/// </para>
/// </remarks>
public static class MessageTemplate
{
private static readonly Regex TokenRegex = new(@"\{([^{}]+)\}",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
/// <summary>
/// Resolve every <c>{path}</c> token in <paramref name="template"/> using
/// <paramref name="resolveTag"/>. Tokens whose returned <see cref="DataValueSnapshot"/>
/// has a non-Good <see cref="DataValueSnapshot.StatusCode"/> or a null
/// <see cref="DataValueSnapshot.Value"/> resolve to <c>{?}</c>.
/// </summary>
public static string Resolve(string template, Func<string, DataValueSnapshot?> resolveTag)
{
if (string.IsNullOrEmpty(template)) return template ?? string.Empty;
if (resolveTag is null) throw new ArgumentNullException(nameof(resolveTag));
return TokenRegex.Replace(template, match =>
{
var path = match.Groups[1].Value.Trim();
if (path.Length == 0) return "{?}";
var snap = resolveTag(path);
if (snap is null) return "{?}";
if (snap.StatusCode != 0u) return "{?}";
return snap.Value?.ToString() ?? "{?}";
});
}
/// <summary>Enumerate the token paths the template references. Used at publish time to validate references exist.</summary>
public static IReadOnlyList<string> ExtractTokenPaths(string? template)
{
if (string.IsNullOrEmpty(template)) return Array.Empty<string>();
var tokens = new List<string>();
foreach (Match m in TokenRegex.Matches(template))
{
var path = m.Groups[1].Value.Trim();
if (path.Length > 0) tokens.Add(path);
}
return tokens;
}
}

View File

@@ -0,0 +1,294 @@
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
/// <summary>
/// Pure functions for OPC UA Part 9 alarm-condition state transitions. Input = the
/// current <see cref="AlarmConditionState"/> + the event; output = the new state +
/// optional emission hint. The engine calls these; persistence happens around them.
/// </summary>
/// <remarks>
/// <para>
/// No instance state, no I/O, no mutation of the input record. Every transition
/// returns a fresh record. Makes the state machine trivially unit-testable —
/// tests assert on (input, event) -> (output) without standing anything else up.
/// </para>
/// <para>
/// Two invariants the machine enforces:
/// (1) Disabled alarms never transition ActiveState / AckedState / ConfirmedState
/// — all predicate evaluations while disabled produce a no-op result and a
/// diagnostic log line. Re-enable restores normal flow with ActiveState
/// re-derived from the next predicate evaluation.
/// (2) Shelved alarms (OneShot / Timed) don't fire active transitions to
/// subscribers, but the state record still advances so that when shelving
/// expires the ActiveState reflects current reality. OneShot expires on the
/// next clear; Timed expires at <see cref="ShelvingState.UnshelveAtUtc"/>.
/// </para>
/// </remarks>
public static class Part9StateMachine
{
/// <summary>
/// Apply a predicate re-evaluation result. Handles activation, clearing,
/// branch-stack increment when a new active arrives while prior active is
/// still un-acked, and shelving suppression.
/// </summary>
public static TransitionResult ApplyPredicate(
AlarmConditionState current,
bool predicateTrue,
DateTime nowUtc)
{
if (current.Enabled == AlarmEnabledState.Disabled)
return TransitionResult.NoOp(current, "disabled — predicate result ignored");
// Expire timed shelving if the configured clock has passed.
var shelving = MaybeExpireShelving(current.Shelving, nowUtc);
var stateWithShelving = current with { Shelving = shelving };
// Shelved alarms still update state but skip event emission.
var shelved = shelving.Kind != ShelvingKind.Unshelved;
if (predicateTrue && current.Active == AlarmActiveState.Inactive)
{
// Inactive -> Active transition.
// OneShotShelving is consumed on the NEXT clear, not activation — so we
// still suppress this transition's emission.
var next = stateWithShelving with
{
Active = AlarmActiveState.Active,
Acked = AlarmAckedState.Unacknowledged,
Confirmed = AlarmConfirmedState.Unconfirmed,
LastActiveUtc = nowUtc,
LastTransitionUtc = nowUtc,
};
return new TransitionResult(next, shelved ? EmissionKind.Suppressed : EmissionKind.Activated);
}
if (!predicateTrue && current.Active == AlarmActiveState.Active)
{
// Active -> Inactive transition.
var next = stateWithShelving with
{
Active = AlarmActiveState.Inactive,
LastClearedUtc = nowUtc,
LastTransitionUtc = nowUtc,
// OneShotShelving expires on clear — resetting here so the next
// activation fires normally.
Shelving = shelving.Kind == ShelvingKind.OneShot
? ShelvingState.Unshelved
: shelving,
};
return new TransitionResult(next, shelved ? EmissionKind.Suppressed : EmissionKind.Cleared);
}
// Predicate matches current Active — no state change beyond possible shelving
// expiry.
return new TransitionResult(stateWithShelving, EmissionKind.None);
}
/// <summary>Operator acknowledges the currently-active transition.</summary>
public static TransitionResult ApplyAcknowledge(
AlarmConditionState current,
string user,
string? comment,
DateTime nowUtc)
{
if (string.IsNullOrWhiteSpace(user))
throw new ArgumentException("User identity required for audit.", nameof(user));
if (current.Acked == AlarmAckedState.Acknowledged)
return TransitionResult.NoOp(current, "already acknowledged");
var audit = AppendComment(current.Comments, nowUtc, user, "Acknowledge", comment);
var next = current with
{
Acked = AlarmAckedState.Acknowledged,
LastAckUtc = nowUtc,
LastAckUser = user,
LastAckComment = comment,
LastTransitionUtc = nowUtc,
Comments = audit,
};
return new TransitionResult(next, EmissionKind.Acknowledged);
}
/// <summary>Operator confirms the cleared transition. Part 9 requires confirm after clear for retain-flag alarms.</summary>
public static TransitionResult ApplyConfirm(
AlarmConditionState current,
string user,
string? comment,
DateTime nowUtc)
{
if (string.IsNullOrWhiteSpace(user))
throw new ArgumentException("User identity required for audit.", nameof(user));
if (current.Confirmed == AlarmConfirmedState.Confirmed)
return TransitionResult.NoOp(current, "already confirmed");
var audit = AppendComment(current.Comments, nowUtc, user, "Confirm", comment);
var next = current with
{
Confirmed = AlarmConfirmedState.Confirmed,
LastConfirmUtc = nowUtc,
LastConfirmUser = user,
LastConfirmComment = comment,
LastTransitionUtc = nowUtc,
Comments = audit,
};
return new TransitionResult(next, EmissionKind.Confirmed);
}
public static TransitionResult ApplyOneShotShelve(
AlarmConditionState current, string user, DateTime nowUtc)
{
if (string.IsNullOrWhiteSpace(user)) throw new ArgumentException("User required.", nameof(user));
if (current.Shelving.Kind == ShelvingKind.OneShot)
return TransitionResult.NoOp(current, "already one-shot shelved");
var audit = AppendComment(current.Comments, nowUtc, user, "ShelveOneShot", null);
var next = current with
{
Shelving = new ShelvingState(ShelvingKind.OneShot, null),
LastTransitionUtc = nowUtc,
Comments = audit,
};
return new TransitionResult(next, EmissionKind.Shelved);
}
public static TransitionResult ApplyTimedShelve(
AlarmConditionState current, string user, DateTime unshelveAtUtc, DateTime nowUtc)
{
if (string.IsNullOrWhiteSpace(user)) throw new ArgumentException("User required.", nameof(user));
if (unshelveAtUtc <= nowUtc)
throw new ArgumentOutOfRangeException(nameof(unshelveAtUtc), "Unshelve time must be in the future.");
var audit = AppendComment(current.Comments, nowUtc, user, "ShelveTimed",
$"UnshelveAtUtc={unshelveAtUtc:O}");
var next = current with
{
Shelving = new ShelvingState(ShelvingKind.Timed, unshelveAtUtc),
LastTransitionUtc = nowUtc,
Comments = audit,
};
return new TransitionResult(next, EmissionKind.Shelved);
}
public static TransitionResult ApplyUnshelve(AlarmConditionState current, string user, DateTime nowUtc)
{
if (string.IsNullOrWhiteSpace(user)) throw new ArgumentException("User required.", nameof(user));
if (current.Shelving.Kind == ShelvingKind.Unshelved)
return TransitionResult.NoOp(current, "not shelved");
var audit = AppendComment(current.Comments, nowUtc, user, "Unshelve", null);
var next = current with
{
Shelving = ShelvingState.Unshelved,
LastTransitionUtc = nowUtc,
Comments = audit,
};
return new TransitionResult(next, EmissionKind.Unshelved);
}
public static TransitionResult ApplyEnable(AlarmConditionState current, string user, DateTime nowUtc)
{
if (string.IsNullOrWhiteSpace(user)) throw new ArgumentException("User required.", nameof(user));
if (current.Enabled == AlarmEnabledState.Enabled)
return TransitionResult.NoOp(current, "already enabled");
var audit = AppendComment(current.Comments, nowUtc, user, "Enable", null);
var next = current with
{
Enabled = AlarmEnabledState.Enabled,
LastTransitionUtc = nowUtc,
Comments = audit,
};
return new TransitionResult(next, EmissionKind.Enabled);
}
public static TransitionResult ApplyDisable(AlarmConditionState current, string user, DateTime nowUtc)
{
if (string.IsNullOrWhiteSpace(user)) throw new ArgumentException("User required.", nameof(user));
if (current.Enabled == AlarmEnabledState.Disabled)
return TransitionResult.NoOp(current, "already disabled");
var audit = AppendComment(current.Comments, nowUtc, user, "Disable", null);
var next = current with
{
Enabled = AlarmEnabledState.Disabled,
LastTransitionUtc = nowUtc,
Comments = audit,
};
return new TransitionResult(next, EmissionKind.Disabled);
}
public static TransitionResult ApplyAddComment(
AlarmConditionState current, string user, string text, DateTime nowUtc)
{
if (string.IsNullOrWhiteSpace(user)) throw new ArgumentException("User required.", nameof(user));
if (string.IsNullOrWhiteSpace(text)) throw new ArgumentException("Comment text required.", nameof(text));
var audit = AppendComment(current.Comments, nowUtc, user, "AddComment", text);
var next = current with { Comments = audit };
return new TransitionResult(next, EmissionKind.CommentAdded);
}
/// <summary>
/// Re-evaluate whether a currently timed-shelved alarm has expired. Returns
/// the (possibly unshelved) state + emission hint so the engine knows to
/// publish an Unshelved event at the right moment.
/// </summary>
public static TransitionResult ApplyShelvingCheck(AlarmConditionState current, DateTime nowUtc)
{
if (current.Shelving.Kind != ShelvingKind.Timed) return TransitionResult.None(current);
if (current.Shelving.UnshelveAtUtc is DateTime t && nowUtc >= t)
{
var audit = AppendComment(current.Comments, nowUtc, "system", "AutoUnshelve",
$"Timed shelving expired at {nowUtc:O}");
var next = current with
{
Shelving = ShelvingState.Unshelved,
LastTransitionUtc = nowUtc,
Comments = audit,
};
return new TransitionResult(next, EmissionKind.Unshelved);
}
return TransitionResult.None(current);
}
private static ShelvingState MaybeExpireShelving(ShelvingState s, DateTime nowUtc)
{
if (s.Kind != ShelvingKind.Timed) return s;
return s.UnshelveAtUtc is DateTime t && nowUtc >= t ? ShelvingState.Unshelved : s;
}
private static IReadOnlyList<AlarmComment> AppendComment(
IReadOnlyList<AlarmComment> existing, DateTime ts, string user, string kind, string? text)
{
var list = new List<AlarmComment>(existing.Count + 1);
list.AddRange(existing);
list.Add(new AlarmComment(ts, user, kind, text ?? string.Empty));
return list;
}
}
/// <summary>Result of a state-machine operation — new state + what to emit (if anything).</summary>
public sealed record TransitionResult(AlarmConditionState State, EmissionKind Emission)
{
public static TransitionResult None(AlarmConditionState state) => new(state, EmissionKind.None);
public static TransitionResult NoOp(AlarmConditionState state, string reason) => new(state, EmissionKind.None);
}
/// <summary>What kind of event, if any, the engine should emit after a transition.</summary>
public enum EmissionKind
{
/// <summary>State did not change meaningfully — no event to emit.</summary>
None,
/// <summary>Predicate transitioned to true while shelving was suppressing events.</summary>
Suppressed,
Activated,
Cleared,
Acknowledged,
Confirmed,
Shelved,
Unshelved,
Enabled,
Disabled,
CommentAdded,
}

View File

@@ -0,0 +1,50 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
/// <summary>
/// Operator-authored scripted-alarm configuration. Phase 7 Stream E (config DB schema)
/// materializes these from the <c>ScriptedAlarm</c> + <c>Script</c> tables on publish.
/// </summary>
/// <param name="AlarmId">
/// Stable identity for the alarm — used as the OPC UA ConditionId + the key in the
/// state store. Should be globally unique within the cluster; convention is
/// <c>{EquipmentPath}::{AlarmName}</c>.
/// </param>
/// <param name="EquipmentPath">
/// UNS path of the Equipment node the alarm hangs under. Alarm browse lives here;
/// ACL binding inherits this equipment's scope per Phase 6.2.
/// </param>
/// <param name="AlarmName">Human-readable alarm name — used in the browse tree + Admin UI.</param>
/// <param name="Kind">Concrete OPC UA Part 9 subtype the alarm materializes as.</param>
/// <param name="Severity">Static severity per Phase 7 plan decision #13; not currently computed by the predicate.</param>
/// <param name="MessageTemplate">
/// Message text with <c>{TagPath}</c> tokens resolved at event-emission time per
/// Phase 7 plan decision #13. Unresolvable tokens emit <c>{?}</c> + a structured
/// error so operators can spot stale references.
/// </param>
/// <param name="PredicateScriptSource">
/// Roslyn C# script returning <c>bool</c>. <c>true</c> = alarm condition currently holds (active);
/// <c>false</c> = condition has cleared. Same sandbox rules as virtual tags per Phase 7 decision #6.
/// </param>
/// <param name="HistorizeToAveva">
/// When true, every transition emission of this alarm flows to the Historian alarm
/// sink (Stream D). Defaults to true — plant alarm history is usually the
/// operator's primary diagnostic. Galaxy-native alarms default false since Galaxy
/// historises them directly.
/// </param>
/// <param name="Retain">
/// Part 9 retain flag — when true, the condition node remains visible after the
/// predicate clears as long as it has un-acknowledged or un-confirmed transitions.
/// Default true.
/// </param>
public sealed record ScriptedAlarmDefinition(
string AlarmId,
string EquipmentPath,
string AlarmName,
AlarmKind Kind,
AlarmSeverity Severity,
string MessageTemplate,
string PredicateScriptSource,
bool HistorizeToAveva = true,
bool Retain = true);

View File

@@ -0,0 +1,429 @@
using System.Collections.Concurrent;
using Serilog;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
/// <summary>
/// Phase 7 scripted-alarm orchestrator. Compiles every configured alarm's predicate
/// against the Stream A sandbox, subscribes to the referenced upstream tags,
/// re-evaluates the predicate on every input change + on a shelving-check timer,
/// applies the resulting transition through <see cref="Part9StateMachine"/>,
/// persists state via <see cref="IAlarmStateStore"/>, and emits the resulting events
/// through <see cref="ScriptedAlarmSource"/> (which wires into the existing
/// <c>IAlarmSource</c> fan-out).
/// </summary>
/// <remarks>
/// <para>
/// Scripted alarms are leaves in the evaluation DAG — no alarm's state drives
/// another alarm's predicate. The engine maintains only an inverse index from
/// upstream tag path → alarms referencing it; no topological sort needed
/// (unlike the virtual-tag engine).
/// </para>
/// <para>
/// Evaluation errors (script throws, timeout, coercion fail) surface as
/// structured errors in the dedicated scripts-*.log sink plus a WARN companion
/// in the main log. The alarm's ActiveState stays at its prior value — the
/// engine does NOT invent a clear transition just because the predicate broke.
/// Operators investigating a broken predicate shouldn't see a phantom
/// clear-event preceding the failure.
/// </para>
/// </remarks>
public sealed class ScriptedAlarmEngine : IDisposable
{
private readonly ITagUpstreamSource _upstream;
private readonly IAlarmStateStore _store;
private readonly ScriptLoggerFactory _loggerFactory;
private readonly ILogger _engineLogger;
private readonly Func<DateTime> _clock;
private readonly TimeSpan _scriptTimeout;
private readonly Dictionary<string, AlarmState> _alarms = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, DataValueSnapshot> _valueCache
= new(StringComparer.Ordinal);
private readonly Dictionary<string, HashSet<string>> _alarmsReferencing
= new(StringComparer.Ordinal); // tag path -> alarm ids
private readonly List<IDisposable> _upstreamSubscriptions = [];
private readonly SemaphoreSlim _evalGate = new(1, 1);
private Timer? _shelvingTimer;
private bool _loaded;
private bool _disposed;
public ScriptedAlarmEngine(
ITagUpstreamSource upstream,
IAlarmStateStore store,
ScriptLoggerFactory loggerFactory,
ILogger engineLogger,
Func<DateTime>? clock = null,
TimeSpan? scriptTimeout = null)
{
_upstream = upstream ?? throw new ArgumentNullException(nameof(upstream));
_store = store ?? throw new ArgumentNullException(nameof(store));
_loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory));
_engineLogger = engineLogger ?? throw new ArgumentNullException(nameof(engineLogger));
_clock = clock ?? (() => DateTime.UtcNow);
_scriptTimeout = scriptTimeout ?? TimedScriptEvaluator<AlarmPredicateContext, bool>.DefaultTimeout;
}
/// <summary>Raised for every emission the Part9StateMachine produces that the engine should publish.</summary>
public event EventHandler<ScriptedAlarmEvent>? OnEvent;
public IReadOnlyCollection<string> LoadedAlarmIds => _alarms.Keys;
/// <summary>
/// Load a batch of alarm definitions. Compiles every predicate, aggregates any
/// compile failures into one <see cref="InvalidOperationException"/>, subscribes
/// to upstream input tags, seeds the value cache, loads persisted state from
/// the store (falling back to Fresh for first-load alarms), and recomputes
/// ActiveState per Phase 7 plan decision #14 (startup recovery).
/// </summary>
public async Task LoadAsync(IReadOnlyList<ScriptedAlarmDefinition> definitions, CancellationToken ct)
{
if (_disposed) throw new ObjectDisposedException(nameof(ScriptedAlarmEngine));
if (definitions is null) throw new ArgumentNullException(nameof(definitions));
await _evalGate.WaitAsync(ct).ConfigureAwait(false);
try
{
UnsubscribeFromUpstream();
_alarms.Clear();
_alarmsReferencing.Clear();
var compileFailures = new List<string>();
foreach (var def in definitions)
{
try
{
var extraction = DependencyExtractor.Extract(def.PredicateScriptSource);
if (!extraction.IsValid)
{
var joined = string.Join("; ", extraction.Rejections.Select(r => r.Message));
compileFailures.Add($"{def.AlarmId}: dependency extraction rejected — {joined}");
continue;
}
var evaluator = ScriptEvaluator<AlarmPredicateContext, bool>.Compile(def.PredicateScriptSource);
var timed = new TimedScriptEvaluator<AlarmPredicateContext, bool>(evaluator, _scriptTimeout);
var logger = _loggerFactory.Create(def.AlarmId);
var templateTokens = MessageTemplate.ExtractTokenPaths(def.MessageTemplate);
var allInputs = new HashSet<string>(extraction.Reads, StringComparer.Ordinal);
foreach (var t in templateTokens) allInputs.Add(t);
_alarms[def.AlarmId] = new AlarmState(def, timed, extraction.Reads, templateTokens, logger,
AlarmConditionState.Fresh(def.AlarmId, _clock()));
foreach (var path in allInputs)
{
if (!_alarmsReferencing.TryGetValue(path, out var set))
_alarmsReferencing[path] = set = new HashSet<string>(StringComparer.Ordinal);
set.Add(def.AlarmId);
}
}
catch (Exception ex)
{
compileFailures.Add($"{def.AlarmId}: {ex.Message}");
}
}
if (compileFailures.Count > 0)
{
throw new InvalidOperationException(
$"ScriptedAlarmEngine load failed. {compileFailures.Count} alarm(s) did not compile:\n "
+ string.Join("\n ", compileFailures));
}
// Seed the value cache with current upstream values + subscribe for changes.
foreach (var path in _alarmsReferencing.Keys)
{
_valueCache[path] = _upstream.ReadTag(path);
_upstreamSubscriptions.Add(_upstream.SubscribeTag(path, OnUpstreamChange));
}
// Restore persisted state, falling back to Fresh where nothing was saved,
// then re-derive ActiveState from the current predicate per decision #14.
foreach (var (alarmId, state) in _alarms)
{
var persisted = await _store.LoadAsync(alarmId, ct).ConfigureAwait(false);
var seed = persisted ?? state.Condition;
var afterPredicate = await EvaluatePredicateToStateAsync(state, seed, nowUtc: _clock(), ct)
.ConfigureAwait(false);
_alarms[alarmId] = state with { Condition = afterPredicate };
await _store.SaveAsync(afterPredicate, ct).ConfigureAwait(false);
}
_loaded = true;
_engineLogger.Information("ScriptedAlarmEngine loaded {Count} alarm(s)", _alarms.Count);
// Start the shelving-check timer — ticks every 5s, expires any timed shelves
// that have passed their UnshelveAtUtc.
_shelvingTimer = new Timer(_ => RunShelvingCheck(),
null, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5));
}
finally
{
_evalGate.Release();
}
}
/// <summary>
/// Current persisted state for <paramref name="alarmId"/>. Returns null for
/// unknown alarm. Mainly used for diagnostics + the Admin UI status page.
/// </summary>
public AlarmConditionState? GetState(string alarmId)
=> _alarms.TryGetValue(alarmId, out var s) ? s.Condition : null;
public IReadOnlyCollection<AlarmConditionState> GetAllStates()
=> _alarms.Values.Select(a => a.Condition).ToArray();
public Task AcknowledgeAsync(string alarmId, string user, string? comment, CancellationToken ct)
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyAcknowledge(cur, user, comment, _clock()));
public Task ConfirmAsync(string alarmId, string user, string? comment, CancellationToken ct)
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyConfirm(cur, user, comment, _clock()));
public Task OneShotShelveAsync(string alarmId, string user, CancellationToken ct)
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyOneShotShelve(cur, user, _clock()));
public Task TimedShelveAsync(string alarmId, string user, DateTime unshelveAtUtc, CancellationToken ct)
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyTimedShelve(cur, user, unshelveAtUtc, _clock()));
public Task UnshelveAsync(string alarmId, string user, CancellationToken ct)
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyUnshelve(cur, user, _clock()));
public Task EnableAsync(string alarmId, string user, CancellationToken ct)
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyEnable(cur, user, _clock()));
public Task DisableAsync(string alarmId, string user, CancellationToken ct)
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyDisable(cur, user, _clock()));
public Task AddCommentAsync(string alarmId, string user, string text, CancellationToken ct)
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyAddComment(cur, user, text, _clock()));
private async Task ApplyAsync(string alarmId, CancellationToken ct, Func<AlarmConditionState, TransitionResult> op)
{
EnsureLoaded();
if (!_alarms.TryGetValue(alarmId, out var state))
throw new ArgumentException($"Unknown alarm {alarmId}", nameof(alarmId));
await _evalGate.WaitAsync(ct).ConfigureAwait(false);
try
{
var result = op(state.Condition);
_alarms[alarmId] = state with { Condition = result.State };
await _store.SaveAsync(result.State, ct).ConfigureAwait(false);
if (result.Emission != EmissionKind.None) EmitEvent(state, result.State, result.Emission);
}
finally { _evalGate.Release(); }
}
/// <summary>
/// Upstream-change callback. Updates the value cache + enqueues predicate
/// re-evaluation for every alarm referencing the changed path. Fire-and-forget
/// so driver-side dispatch isn't blocked.
/// </summary>
internal void OnUpstreamChange(string path, DataValueSnapshot value)
{
_valueCache[path] = value;
if (_alarmsReferencing.TryGetValue(path, out var alarmIds))
{
_ = ReevaluateAsync(alarmIds.ToArray(), CancellationToken.None);
}
}
private async Task ReevaluateAsync(IReadOnlyList<string> alarmIds, CancellationToken ct)
{
try
{
await _evalGate.WaitAsync(ct).ConfigureAwait(false);
try
{
foreach (var id in alarmIds)
{
if (!_alarms.TryGetValue(id, out var state)) continue;
var newState = await EvaluatePredicateToStateAsync(
state, state.Condition, _clock(), ct).ConfigureAwait(false);
if (!ReferenceEquals(newState, state.Condition))
{
_alarms[id] = state with { Condition = newState };
await _store.SaveAsync(newState, ct).ConfigureAwait(false);
}
}
}
finally { _evalGate.Release(); }
}
catch (Exception ex)
{
_engineLogger.Error(ex, "ScriptedAlarmEngine reevaluate failed");
}
}
/// <summary>
/// Evaluate the predicate + apply the resulting state-machine transition.
/// Returns the new condition state. Emits the appropriate event if the
/// transition produces one.
/// </summary>
private async Task<AlarmConditionState> EvaluatePredicateToStateAsync(
AlarmState state, AlarmConditionState seed, DateTime nowUtc, CancellationToken ct)
{
var inputs = BuildReadCache(state.Inputs);
var context = new AlarmPredicateContext(inputs, state.Logger, _clock);
bool predicateTrue;
try
{
predicateTrue = await state.Evaluator.RunAsync(context, ct).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
throw;
}
catch (ScriptTimeoutException tex)
{
state.Logger.Warning("Alarm predicate timed out after {Timeout} — state unchanged", tex.Timeout);
return seed;
}
catch (Exception ex)
{
state.Logger.Error(ex, "Alarm predicate threw — state unchanged");
return seed;
}
var result = Part9StateMachine.ApplyPredicate(seed, predicateTrue, nowUtc);
if (result.Emission != EmissionKind.None)
EmitEvent(state, result.State, result.Emission);
return result.State;
}
private IReadOnlyDictionary<string, DataValueSnapshot> BuildReadCache(IReadOnlySet<string> inputs)
{
var d = new Dictionary<string, DataValueSnapshot>(StringComparer.Ordinal);
foreach (var p in inputs)
d[p] = _valueCache.TryGetValue(p, out var v) ? v : _upstream.ReadTag(p);
return d;
}
private void EmitEvent(AlarmState state, AlarmConditionState condition, EmissionKind kind)
{
// Suppressed kind means shelving ate the emission — we don't fire for subscribers
// but the state record still advanced so startup recovery reflects reality.
if (kind == EmissionKind.Suppressed || kind == EmissionKind.None) return;
var message = MessageTemplate.Resolve(state.Definition.MessageTemplate, TryLookup);
var evt = new ScriptedAlarmEvent(
AlarmId: state.Definition.AlarmId,
EquipmentPath: state.Definition.EquipmentPath,
AlarmName: state.Definition.AlarmName,
Kind: state.Definition.Kind,
Severity: state.Definition.Severity,
Message: message,
Condition: condition,
Emission: kind,
TimestampUtc: _clock());
try { OnEvent?.Invoke(this, evt); }
catch (Exception ex)
{
_engineLogger.Warning(ex, "ScriptedAlarmEngine OnEvent subscriber threw for {AlarmId}", state.Definition.AlarmId);
}
}
private DataValueSnapshot? TryLookup(string path)
=> _valueCache.TryGetValue(path, out var v) ? v : null;
private void RunShelvingCheck()
{
if (_disposed) return;
var ids = _alarms.Keys.ToArray();
_ = ShelvingCheckAsync(ids, CancellationToken.None);
}
private async Task ShelvingCheckAsync(IReadOnlyList<string> alarmIds, CancellationToken ct)
{
try
{
await _evalGate.WaitAsync(ct).ConfigureAwait(false);
try
{
var now = _clock();
foreach (var id in alarmIds)
{
if (!_alarms.TryGetValue(id, out var state)) continue;
var result = Part9StateMachine.ApplyShelvingCheck(state.Condition, now);
if (!ReferenceEquals(result.State, state.Condition))
{
_alarms[id] = state with { Condition = result.State };
await _store.SaveAsync(result.State, ct).ConfigureAwait(false);
if (result.Emission != EmissionKind.None)
EmitEvent(state, result.State, result.Emission);
}
}
}
finally { _evalGate.Release(); }
}
catch (Exception ex)
{
_engineLogger.Warning(ex, "ScriptedAlarmEngine shelving-check failed");
}
}
private void UnsubscribeFromUpstream()
{
foreach (var s in _upstreamSubscriptions)
{
try { s.Dispose(); } catch { }
}
_upstreamSubscriptions.Clear();
}
private void EnsureLoaded()
{
if (!_loaded) throw new InvalidOperationException(
"ScriptedAlarmEngine not loaded. Call LoadAsync first.");
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_shelvingTimer?.Dispose();
UnsubscribeFromUpstream();
_alarms.Clear();
_alarmsReferencing.Clear();
}
private sealed record AlarmState(
ScriptedAlarmDefinition Definition,
TimedScriptEvaluator<AlarmPredicateContext, bool> Evaluator,
IReadOnlySet<string> Inputs,
IReadOnlyList<string> TemplateTokens,
ILogger Logger,
AlarmConditionState Condition);
}
/// <summary>
/// One alarm emission the engine pushed to subscribers. Carries everything
/// downstream consumers (OPC UA alarm-source adapter + historian sink) need to
/// publish the event without re-querying the engine.
/// </summary>
public sealed record ScriptedAlarmEvent(
string AlarmId,
string EquipmentPath,
string AlarmName,
AlarmKind Kind,
AlarmSeverity Severity,
string Message,
AlarmConditionState Condition,
EmissionKind Emission,
DateTime TimestampUtc);
/// <summary>
/// Upstream source abstraction — intentionally identical shape to the virtual-tag
/// engine's so Stream G can compose them behind one driver bridge.
/// </summary>
public interface ITagUpstreamSource
{
DataValueSnapshot ReadTag(string path);
IDisposable SubscribeTag(string path, Action<string, DataValueSnapshot> observer);
}

View File

@@ -0,0 +1,122 @@
using System.Collections.Concurrent;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
/// <summary>
/// Adapter that exposes <see cref="ScriptedAlarmEngine"/> through the driver-agnostic
/// <see cref="IAlarmSource"/> surface. The existing Phase 6.1 <c>AlarmTracker</c>
/// composition fan-out consumes this alongside Galaxy / AB CIP / FOCAS alarm
/// sources — no per-source branching in the fan-out.
/// </summary>
/// <remarks>
/// <para>
/// Per Phase 7 plan Stream C.6, ack / confirm / shelve / unshelve are OPC UA
/// method calls per-condition. This adapter implements <see cref="AcknowledgeAsync"/>
/// from the base interface; the richer Part 9 methods (Confirm / Shelve /
/// Unshelve / AddComment) live directly on the engine, invoked from OPC UA
/// method handlers wired up in Stream G.
/// </para>
/// <para>
/// SubscribeAlarmsAsync takes a list of source-node-id filters (typically an
/// Equipment path prefix). When the list is empty every alarm matches. The
/// adapter doesn't maintain per-subscription state beyond the filter set — it
/// checks each emission against every live subscription.
/// </para>
/// </remarks>
public sealed class ScriptedAlarmSource : IAlarmSource, IDisposable
{
private readonly ScriptedAlarmEngine _engine;
private readonly ConcurrentDictionary<string, Subscription> _subscriptions
= new(StringComparer.Ordinal);
private bool _disposed;
public ScriptedAlarmSource(ScriptedAlarmEngine engine)
{
_engine = engine ?? throw new ArgumentNullException(nameof(engine));
_engine.OnEvent += OnEngineEvent;
}
public event EventHandler<AlarmEventArgs>? OnAlarmEvent;
public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
{
if (sourceNodeIds is null) throw new ArgumentNullException(nameof(sourceNodeIds));
var handle = new SubscriptionHandle(Guid.NewGuid().ToString("N"));
_subscriptions[handle.DiagnosticId] = new Subscription(handle,
new HashSet<string>(sourceNodeIds, StringComparer.Ordinal));
return Task.FromResult<IAlarmSubscriptionHandle>(handle);
}
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken)
{
if (handle is null) throw new ArgumentNullException(nameof(handle));
_subscriptions.TryRemove(handle.DiagnosticId, out _);
return Task.CompletedTask;
}
public async Task AcknowledgeAsync(
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken)
{
if (acknowledgements is null) throw new ArgumentNullException(nameof(acknowledgements));
foreach (var a in acknowledgements)
{
// The base interface doesn't carry a user identity — Stream G provides the
// authenticated principal at the OPC UA dispatch layer + proxies through
// the engine's richer AcknowledgeAsync. Here we default to "opcua-client"
// so callers using the raw IAlarmSource still produce an audit entry.
await _engine.AcknowledgeAsync(a.ConditionId, "opcua-client", a.Comment, cancellationToken)
.ConfigureAwait(false);
}
}
private void OnEngineEvent(object? sender, ScriptedAlarmEvent evt)
{
if (_disposed) return;
foreach (var sub in _subscriptions.Values)
{
if (!Matches(sub, evt)) continue;
var payload = new AlarmEventArgs(
SubscriptionHandle: sub.Handle,
SourceNodeId: evt.EquipmentPath,
ConditionId: evt.AlarmId,
AlarmType: evt.Kind.ToString(),
Message: evt.Message,
Severity: evt.Severity,
SourceTimestampUtc: evt.TimestampUtc);
try { OnAlarmEvent?.Invoke(this, payload); }
catch { /* subscriber exceptions don't crash the adapter */ }
}
}
private static bool Matches(Subscription sub, ScriptedAlarmEvent evt)
{
if (sub.Filter.Count == 0) return true;
// A subscription matches if any filter is a prefix of the alarm's equipment
// path — typical use is "Enterprise/Site/Area/Line" filtering a whole line.
foreach (var f in sub.Filter)
{
if (evt.EquipmentPath.Equals(f, StringComparison.Ordinal)) return true;
if (evt.EquipmentPath.StartsWith(f + "/", StringComparison.Ordinal)) return true;
}
return false;
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_engine.OnEvent -= OnEngineEvent;
_subscriptions.Clear();
}
private sealed class SubscriptionHandle : IAlarmSubscriptionHandle
{
public SubscriptionHandle(string id) { DiagnosticId = id; }
public string DiagnosticId { get; }
}
private sealed record Subscription(SubscriptionHandle Handle, IReadOnlySet<string> Filter);
}

View File

@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Serilog" Version="4.2.0"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Scripting\ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj"/>
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests"/>
</ItemGroup>
<ItemGroup>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,271 @@
namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
/// <summary>
/// Directed dependency graph over tag paths. Nodes are tag paths (either driver
/// tags — leaves — or virtual tags — internal nodes). Edges run from a virtual tag
/// to each tag it reads via <c>ctx.GetTag(...)</c>. Supports cycle detection at
/// publish time and topological sort for evaluation ordering.
/// </summary>
/// <remarks>
/// <para>
/// Cycle detection uses Tarjan's strongly-connected-components algorithm,
/// iterative implementation (no recursion) so deeply-nested graphs can't blow
/// the stack. A cycle of length > 1 (or a self-loop) is a publish-time error;
/// the engine refuses to load such a config.
/// </para>
/// <para>
/// Topological sort uses Kahn's algorithm. The output order guarantees that when
/// tag X depends on tag Y, Y appears before X — so a change cascade starting at
/// Y can evaluate the full downstream closure in one serial pass without needing
/// a second iteration.
/// </para>
/// <para>
/// Missing leaf dependencies (a virtual tag reads a driver tag that doesn't
/// exist in the live config) are NOT rejected here — the graph treats any
/// unregistered path as an implicit leaf. Leaf validity is a separate concern
/// handled at engine-load time against the authoritative tag catalog.
/// </para>
/// </remarks>
public sealed class DependencyGraph
{
private readonly Dictionary<string, HashSet<string>> _dependsOn = new(StringComparer.Ordinal);
private readonly Dictionary<string, HashSet<string>> _dependents = new(StringComparer.Ordinal);
/// <summary>
/// Register a node and the set of tags it depends on. Idempotent — re-adding
/// the same node overwrites the prior dependency set, so re-publishing an edited
/// script works without a separate "remove" call.
/// </summary>
public void Add(string nodeId, IReadOnlySet<string> dependsOn)
{
if (string.IsNullOrWhiteSpace(nodeId)) throw new ArgumentException("Node id required.", nameof(nodeId));
if (dependsOn is null) throw new ArgumentNullException(nameof(dependsOn));
// Remove any prior dependents pointing at the previous version of this node.
if (_dependsOn.TryGetValue(nodeId, out var previous))
{
foreach (var dep in previous)
{
if (_dependents.TryGetValue(dep, out var set))
set.Remove(nodeId);
}
}
_dependsOn[nodeId] = new HashSet<string>(dependsOn, StringComparer.Ordinal);
foreach (var dep in dependsOn)
{
if (!_dependents.TryGetValue(dep, out var set))
_dependents[dep] = set = new HashSet<string>(StringComparer.Ordinal);
set.Add(nodeId);
}
}
/// <summary>Tag paths <paramref name="nodeId"/> directly reads.</summary>
public IReadOnlySet<string> DirectDependencies(string nodeId) =>
_dependsOn.TryGetValue(nodeId, out var set) ? set : (IReadOnlySet<string>)new HashSet<string>();
/// <summary>
/// Tags whose evaluation depends on <paramref name="nodeId"/> — i.e. when
/// <paramref name="nodeId"/> changes, these need to re-evaluate. Direct only;
/// transitive propagation falls out of the topological sort.
/// </summary>
public IReadOnlySet<string> DirectDependents(string nodeId) =>
_dependents.TryGetValue(nodeId, out var set) ? set : (IReadOnlySet<string>)new HashSet<string>();
/// <summary>
/// Full transitive dependent closure of <paramref name="nodeId"/> in topological
/// order (direct dependents first, then their dependents, and so on). Used by the
/// change-trigger dispatcher to schedule the right sequence of re-evaluations
/// when a single upstream value changes.
/// </summary>
public IReadOnlyList<string> TransitiveDependentsInOrder(string nodeId)
{
if (string.IsNullOrWhiteSpace(nodeId)) return [];
var result = new List<string>();
var visited = new HashSet<string>(StringComparer.Ordinal);
var order = TopologicalSort();
var rank = new Dictionary<string, int>(StringComparer.Ordinal);
for (var i = 0; i < order.Count; i++) rank[order[i]] = i;
// DFS from the changed node collecting every reachable dependent.
var stack = new Stack<string>();
stack.Push(nodeId);
while (stack.Count > 0)
{
var cur = stack.Pop();
foreach (var dep in DirectDependents(cur))
{
if (visited.Add(dep))
{
result.Add(dep);
stack.Push(dep);
}
}
}
// Sort by topological rank so when re-evaluation runs serial, earlier entries
// are computed before later entries that might depend on them.
result.Sort((a, b) =>
{
var ra = rank.TryGetValue(a, out var va) ? va : int.MaxValue;
var rb = rank.TryGetValue(b, out var vb) ? vb : int.MaxValue;
return ra.CompareTo(rb);
});
return result;
}
/// <summary>Iterable of every registered node id (inputs-only tags excluded).</summary>
public IReadOnlyCollection<string> RegisteredNodes => _dependsOn.Keys;
/// <summary>
/// Produce an evaluation order where every node appears after all its
/// dependencies. Throws <see cref="DependencyCycleException"/> if any cycle
/// exists. Implemented via Kahn's algorithm.
/// </summary>
public IReadOnlyList<string> TopologicalSort()
{
// Kahn's framing: edge u -> v means "u must come before v". For dependencies,
// if X depends on Y, Y must come before X, so the edge runs Y -> X and X has
// an incoming edge from Y. inDegree[X] = count of X's registered (virtual) deps
// — leaf driver-tag deps don't contribute to ordering since they're never emitted.
var inDegree = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (var node in _dependsOn.Keys) inDegree[node] = 0;
foreach (var kv in _dependsOn)
{
var nodeId = kv.Key;
foreach (var dep in kv.Value)
{
if (_dependsOn.ContainsKey(dep))
inDegree[nodeId]++;
}
}
var ready = new Queue<string>(inDegree.Where(kv => kv.Value == 0).Select(kv => kv.Key));
var result = new List<string>();
while (ready.Count > 0)
{
var n = ready.Dequeue();
result.Add(n);
// In our edge direction (node -> deps), removing n means decrementing in-degree
// of every node that DEPENDS on n.
foreach (var dependent in DirectDependents(n))
{
if (inDegree.TryGetValue(dependent, out var d))
{
inDegree[dependent] = d - 1;
if (inDegree[dependent] == 0) ready.Enqueue(dependent);
}
}
}
if (result.Count != inDegree.Count)
{
var cycles = DetectCycles();
throw new DependencyCycleException(cycles);
}
return result;
}
/// <summary>
/// Returns every strongly-connected component of size &gt; 1 + every self-loop.
/// Empty list means the graph is a DAG. Useful for surfacing every cycle in one
/// rejection pass so operators see all of them, not just one at a time.
/// </summary>
public IReadOnlyList<IReadOnlyList<string>> DetectCycles()
{
// Iterative Tarjan's SCC. Avoids recursion so deep graphs don't StackOverflow.
var index = 0;
var indexOf = new Dictionary<string, int>(StringComparer.Ordinal);
var lowlinkOf = new Dictionary<string, int>(StringComparer.Ordinal);
var onStack = new HashSet<string>(StringComparer.Ordinal);
var sccStack = new Stack<string>();
var cycles = new List<IReadOnlyList<string>>();
foreach (var root in _dependsOn.Keys)
{
if (indexOf.ContainsKey(root)) continue;
var work = new Stack<(string node, IEnumerator<string> iter)>();
indexOf[root] = index;
lowlinkOf[root] = index;
index++;
onStack.Add(root);
sccStack.Push(root);
work.Push((root, _dependsOn[root].GetEnumerator()));
while (work.Count > 0)
{
var (v, iter) = work.Peek();
if (iter.MoveNext())
{
var w = iter.Current;
if (!_dependsOn.ContainsKey(w))
continue; // leaf — not part of any cycle with us
if (!indexOf.ContainsKey(w))
{
indexOf[w] = index;
lowlinkOf[w] = index;
index++;
onStack.Add(w);
sccStack.Push(w);
work.Push((w, _dependsOn[w].GetEnumerator()));
}
else if (onStack.Contains(w))
{
lowlinkOf[v] = Math.Min(lowlinkOf[v], indexOf[w]);
}
}
else
{
// v fully explored — unwind
work.Pop();
if (lowlinkOf[v] == indexOf[v])
{
var component = new List<string>();
string w;
do
{
w = sccStack.Pop();
onStack.Remove(w);
component.Add(w);
} while (w != v);
if (component.Count > 1 || _dependsOn[v].Contains(v))
cycles.Add(component);
}
else if (work.Count > 0)
{
var parent = work.Peek().node;
lowlinkOf[parent] = Math.Min(lowlinkOf[parent], lowlinkOf[v]);
}
}
}
}
return cycles;
}
public void Clear()
{
_dependsOn.Clear();
_dependents.Clear();
}
}
/// <summary>Thrown when <see cref="DependencyGraph.TopologicalSort"/> finds one or more cycles.</summary>
public sealed class DependencyCycleException : Exception
{
public IReadOnlyList<IReadOnlyList<string>> Cycles { get; }
public DependencyCycleException(IReadOnlyList<IReadOnlyList<string>> cycles)
: base(BuildMessage(cycles))
{
Cycles = cycles;
}
private static string BuildMessage(IReadOnlyList<IReadOnlyList<string>> cycles)
{
var lines = cycles.Select(c => " - " + string.Join(" -> ", c) + " -> " + c[0]);
return "Virtual-tag dependency graph contains cycle(s):\n" + string.Join("\n", lines);
}
}

View File

@@ -0,0 +1,25 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
/// <summary>
/// Sink for virtual-tag evaluation results that the operator marked
/// <c>Historize = true</c>. Stream G wires this to the existing history-write path
/// drivers use; tests inject a fake recorder.
/// </summary>
/// <remarks>
/// Emission is fire-and-forget from the evaluation pipeline — a slow historian must
/// not block script evaluations. Implementations queue internally and drain on their
/// own cadence.
/// </remarks>
public interface IHistoryWriter
{
void Record(string path, DataValueSnapshot value);
}
/// <summary>No-op default used when no historian is configured.</summary>
public sealed class NullHistoryWriter : IHistoryWriter
{
public static readonly NullHistoryWriter Instance = new();
public void Record(string path, DataValueSnapshot value) { }
}

View File

@@ -0,0 +1,40 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
/// <summary>
/// What the virtual-tag engine pulls driver-tag values from. Implementations
/// shipped in Stream G bridge this to <see cref="IReadable"/> + <see cref="ISubscribable"/>
/// on the live driver instances; tests use an in-memory fake.
/// </summary>
/// <remarks>
/// <para>
/// The read path is synchronous because user scripts call
/// <c>ctx.GetTag(path)</c> inline — blocking on a driver wire call per-script
/// evaluation would kill throughput. Implementations are expected to serve
/// from a last-known-value cache populated by the subscription callbacks.
/// </para>
/// <para>
/// The subscription path feeds the engine's <c>ChangeTriggerDispatcher</c> so
/// change-driven virtual tags re-evaluate on any upstream delta (value, status,
/// or timestamp). One subscription per distinct upstream tag path; the engine
/// tracks the mapping itself.
/// </para>
/// </remarks>
public interface ITagUpstreamSource
{
/// <summary>
/// Synchronous read returning the last-known value + quality for
/// <paramref name="path"/>. Returns a <c>BadNodeIdUnknown</c>-quality snapshot
/// when the path isn't configured.
/// </summary>
DataValueSnapshot ReadTag(string path);
/// <summary>
/// Register an observer that fires every time the upstream value at
/// <paramref name="path"/> changes. Returns an <see cref="IDisposable"/> the
/// engine disposes when the virtual-tag config is reloaded or the engine shuts
/// down, so source-side subscriptions don't leak.
/// </summary>
IDisposable SubscribeTag(string path, Action<string, DataValueSnapshot> observer);
}

View File

@@ -0,0 +1,83 @@
using Serilog;
namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
/// <summary>
/// Periodic re-evaluation scheduler for tags with a non-null
/// <see cref="VirtualTagDefinition.TimerInterval"/>. Independent of the
/// change-trigger path — a tag can be timer-only, change-only, or both. One
/// <see cref="System.Threading.Timer"/> per interval-group keeps the wire count
/// low regardless of tag count.
/// </summary>
public sealed class TimerTriggerScheduler : IDisposable
{
private readonly VirtualTagEngine _engine;
private readonly ILogger _logger;
private readonly List<Timer> _timers = [];
private readonly CancellationTokenSource _cts = new();
private bool _disposed;
public TimerTriggerScheduler(VirtualTagEngine engine, ILogger logger)
{
_engine = engine ?? throw new ArgumentNullException(nameof(engine));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Stand up one <see cref="Timer"/> per unique interval. All tags with
/// matching interval share a timer; each tick triggers re-evaluation of the
/// group in topological order so cascades are consistent with change-triggered
/// behavior.
/// </summary>
public void Start(IReadOnlyList<VirtualTagDefinition> definitions)
{
if (_disposed) throw new ObjectDisposedException(nameof(TimerTriggerScheduler));
var byInterval = definitions
.Where(d => d.TimerInterval.HasValue && d.TimerInterval.Value > TimeSpan.Zero)
.GroupBy(d => d.TimerInterval!.Value);
foreach (var group in byInterval)
{
var paths = group.Select(d => d.Path).ToArray();
var interval = group.Key;
var timer = new Timer(_ => Tick(paths), null, interval, interval);
_timers.Add(timer);
_logger.Information("TimerTriggerScheduler: {TagCount} tag(s) on {Interval} cadence",
paths.Length, interval);
}
}
private void Tick(IReadOnlyList<string> paths)
{
if (_cts.IsCancellationRequested) return;
foreach (var p in paths)
{
try
{
_engine.EvaluateOneAsync(p, _cts.Token).GetAwaiter().GetResult();
}
catch (OperationCanceledException)
{
return;
}
catch (Exception ex)
{
_logger.Error(ex, "TimerTriggerScheduler evaluate failed for {Path}", p);
}
}
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_cts.Cancel();
foreach (var t in _timers)
{
try { t.Dispose(); } catch { }
}
_timers.Clear();
_cts.Dispose();
}
}

View File

@@ -0,0 +1,64 @@
using Serilog;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
/// <summary>
/// Per-evaluation <see cref="ScriptContext"/> for a virtual-tag script. Reads come
/// out of the engine's last-known-value cache (driver tags updated via the
/// <see cref="ITagUpstreamSource"/> subscription, virtual tags updated by prior
/// evaluations). Writes route through the engine's <c>SetVirtualTag</c> callback so
/// cross-tag write side effects still participate in change-trigger cascades.
/// </summary>
/// <remarks>
/// <para>
/// Context instances are evaluation-scoped, not tag-scoped. The engine
/// constructs a fresh context for every run — cheap because the constructor
/// just captures references — so scripts can't cache mutable state across runs
/// via <c>ctx</c>. Mutable state across runs is a future decision (e.g. a
/// dedicated <c>ctx.Memory</c> dictionary); not in scope for Phase 7.
/// </para>
/// <para>
/// The <see cref="Now"/> clock is injectable so tests can pin time
/// deterministically. Production wires to <see cref="DateTime.UtcNow"/>.
/// </para>
/// </remarks>
public sealed class VirtualTagContext : ScriptContext
{
private readonly IReadOnlyDictionary<string, DataValueSnapshot> _readCache;
private readonly Action<string, object?> _setVirtualTag;
private readonly Func<DateTime> _clock;
public VirtualTagContext(
IReadOnlyDictionary<string, DataValueSnapshot> readCache,
Action<string, object?> setVirtualTag,
ILogger logger,
Func<DateTime>? clock = null)
{
_readCache = readCache ?? throw new ArgumentNullException(nameof(readCache));
_setVirtualTag = setVirtualTag ?? throw new ArgumentNullException(nameof(setVirtualTag));
Logger = logger ?? throw new ArgumentNullException(nameof(logger));
_clock = clock ?? (() => DateTime.UtcNow);
}
public override DataValueSnapshot GetTag(string path)
{
if (string.IsNullOrWhiteSpace(path))
return new DataValueSnapshot(null, 0x80340000u /* BadNodeIdUnknown */, null, _clock());
return _readCache.TryGetValue(path, out var v)
? v
: new DataValueSnapshot(null, 0x80340000u /* BadNodeIdUnknown */, null, _clock());
}
public override void SetVirtualTag(string path, object? value)
{
if (string.IsNullOrWhiteSpace(path))
throw new ArgumentException("Virtual tag path required.", nameof(path));
_setVirtualTag(path, value);
}
public override DateTime Now => _clock();
public override ILogger Logger { get; }
}

View File

@@ -0,0 +1,41 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
/// <summary>
/// Operator-authored virtual-tag configuration row. Phase 7 Stream E (config DB
/// schema) materializes these from the <c>VirtualTag</c> + <c>Script</c> tables on
/// publish; the engine ingests a list of them at load time.
/// </summary>
/// <param name="Path">
/// UNS tag path — <c>Enterprise/Site/Area/Line/Equipment/TagName</c>. Used both as
/// the engine's internal id and the OPC UA browse path.
/// </param>
/// <param name="DataType">
/// Expected return type. The evaluator coerces the script's return value to this
/// type before publishing; mismatch surfaces as <c>BadTypeMismatch</c> quality on
/// the tag.
/// </param>
/// <param name="ScriptSource">Roslyn C# script source. Must compile under <c>ScriptSandbox</c>.</param>
/// <param name="ChangeTriggered">
/// True if any input tag's change (value / status / timestamp delta) should trigger
/// re-evaluation. Operator picks per tag — usually true for inputs that change at
/// protocol rates.
/// </param>
/// <param name="TimerInterval">
/// Optional periodic re-evaluation cadence. Null = timer-driven disabled. Both can
/// be enabled simultaneously; independent scheduling paths both feed
/// <c>EvaluationPipeline</c>.
/// </param>
/// <param name="Historize">
/// When true, every evaluation result is forwarded to the configured
/// <see cref="IHistoryWriter"/>. Operator-set per tag; the Admin UI exposes as a
/// checkbox.
/// </param>
public sealed record VirtualTagDefinition(
string Path,
DriverDataType DataType,
string ScriptSource,
bool ChangeTriggered = true,
TimeSpan? TimerInterval = null,
bool Historize = false);

View File

@@ -0,0 +1,385 @@
using System.Collections.Concurrent;
using Serilog;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
/// <summary>
/// The Phase 7 virtual-tag evaluation engine. Ingests a set of
/// <see cref="VirtualTagDefinition"/>s at load time, compiles each script against
/// <see cref="ScriptSandbox"/>, builds the dependency graph, subscribes to every
/// referenced upstream tag, and schedules re-evaluations on change + on timer.
/// </summary>
/// <remarks>
/// <para>
/// Evaluation order is topological per ADR-001 / Phase 7 plan decision #19 —
/// serial for the v1 rollout, parallel promoted to a follow-up. When upstream
/// tag X changes, the engine computes the transitive dependent closure of X in
/// topological rank and evaluates each in turn, so a cascade through multiple
/// levels of virtual tags settles within one change-trigger pass.
/// </para>
/// <para>
/// Per-tag error isolation per Phase 7 plan decision #11 — a script exception
/// (or timeout) fails that tag's latest value with <c>BadInternalError</c> or
/// <c>BadTypeMismatch</c> quality and logs a structured error; every other tag
/// keeps evaluating. The engine itself never faults from a user script.
/// </para>
/// </remarks>
public sealed class VirtualTagEngine : IDisposable
{
private readonly ITagUpstreamSource _upstream;
private readonly IHistoryWriter _history;
private readonly ScriptLoggerFactory _loggerFactory;
private readonly ILogger _engineLogger;
private readonly Func<DateTime> _clock;
private readonly TimeSpan _scriptTimeout;
private readonly DependencyGraph _graph = new();
private readonly Dictionary<string, VirtualTagState> _tags = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, DataValueSnapshot> _valueCache = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, List<Action<string, DataValueSnapshot>>> _observers
= new(StringComparer.Ordinal);
private readonly List<IDisposable> _upstreamSubscriptions = [];
private readonly SemaphoreSlim _evalGate = new(1, 1);
private bool _loaded;
private bool _disposed;
public VirtualTagEngine(
ITagUpstreamSource upstream,
ScriptLoggerFactory loggerFactory,
ILogger engineLogger,
IHistoryWriter? historyWriter = null,
Func<DateTime>? clock = null,
TimeSpan? scriptTimeout = null)
{
_upstream = upstream ?? throw new ArgumentNullException(nameof(upstream));
_loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory));
_engineLogger = engineLogger ?? throw new ArgumentNullException(nameof(engineLogger));
_history = historyWriter ?? NullHistoryWriter.Instance;
_clock = clock ?? (() => DateTime.UtcNow);
_scriptTimeout = scriptTimeout ?? TimedScriptEvaluator<VirtualTagContext, object?>.DefaultTimeout;
}
/// <summary>Registered tag paths, in topological order. Empty before <see cref="Load"/>.</summary>
public IReadOnlyCollection<string> LoadedTagPaths => _tags.Keys;
/// <summary>Compile + register every tag in <paramref name="definitions"/>. Throws on cycle or any compile failure.</summary>
public void Load(IReadOnlyList<VirtualTagDefinition> definitions)
{
if (_disposed) throw new ObjectDisposedException(nameof(VirtualTagEngine));
if (definitions is null) throw new ArgumentNullException(nameof(definitions));
// Start from a clean slate — supports config-publish reloads.
UnsubscribeFromUpstream();
_tags.Clear();
_graph.Clear();
var compileFailures = new List<string>();
foreach (var def in definitions)
{
try
{
var extraction = DependencyExtractor.Extract(def.ScriptSource);
if (!extraction.IsValid)
{
var msgs = string.Join("; ", extraction.Rejections.Select(r => r.Message));
compileFailures.Add($"{def.Path}: dependency extraction rejected — {msgs}");
continue;
}
var evaluator = ScriptEvaluator<VirtualTagContext, object?>.Compile(def.ScriptSource);
var timed = new TimedScriptEvaluator<VirtualTagContext, object?>(evaluator, _scriptTimeout);
var scriptLogger = _loggerFactory.Create(def.Path);
_tags[def.Path] = new VirtualTagState(def, timed, extraction.Reads, extraction.Writes, scriptLogger);
_graph.Add(def.Path, extraction.Reads);
}
catch (Exception ex)
{
compileFailures.Add($"{def.Path}: {ex.Message}");
}
}
if (compileFailures.Count > 0)
{
var joined = string.Join("\n ", compileFailures);
throw new InvalidOperationException(
$"Virtual-tag engine load failed. {compileFailures.Count} script(s) did not compile:\n {joined}");
}
// Cycle check — throws DependencyCycleException on offense.
_ = _graph.TopologicalSort();
// Subscribe to every referenced upstream path (driver tags only — virtual tags
// cascade internally). Seed the cache with current upstream values so first
// evaluations see something real.
var upstreamPaths = definitions
.SelectMany(d => _tags[d.Path].Reads)
.Where(p => !_tags.ContainsKey(p))
.Distinct(StringComparer.Ordinal);
foreach (var path in upstreamPaths)
{
_valueCache[path] = _upstream.ReadTag(path);
_upstreamSubscriptions.Add(_upstream.SubscribeTag(path, OnUpstreamChange));
}
_loaded = true;
_engineLogger.Information(
"VirtualTagEngine loaded {TagCount} tag(s), {UpstreamCount} upstream subscription(s)",
_tags.Count, _upstreamSubscriptions.Count);
}
/// <summary>
/// Evaluate every registered tag once in topological order — used at startup so
/// virtual tags have a defined initial value rather than inheriting the cache
/// default. Also called after a config reload.
/// </summary>
public async Task EvaluateAllAsync(CancellationToken ct = default)
{
EnsureLoaded();
var order = _graph.TopologicalSort();
foreach (var path in order)
{
if (_tags.ContainsKey(path))
await EvaluateOneAsync(path, ct).ConfigureAwait(false);
}
}
/// <summary>Evaluate a single tag — used by the timer trigger + test hooks.</summary>
public Task EvaluateOneAsync(string path, CancellationToken ct = default)
{
EnsureLoaded();
if (!_tags.ContainsKey(path))
throw new ArgumentException($"Not a registered virtual tag: {path}", nameof(path));
return EvaluateInternalAsync(path, ct);
}
/// <summary>
/// Read the most recently evaluated value for <paramref name="path"/>. Driver
/// tags return the last-known upstream value; virtual tags return their last
/// evaluation result.
/// </summary>
public DataValueSnapshot Read(string path)
{
if (string.IsNullOrWhiteSpace(path))
return new DataValueSnapshot(null, 0x80340000u, null, _clock());
return _valueCache.TryGetValue(path, out var v)
? v
: new DataValueSnapshot(null, 0x80340000u /* BadNodeIdUnknown */, null, _clock());
}
/// <summary>
/// Register an observer that fires on every evaluation of the given tag.
/// Returns an <see cref="IDisposable"/> to unsubscribe. Does NOT fire a seed
/// value — subscribers call <see cref="Read"/> for the current value if needed.
/// </summary>
public IDisposable Subscribe(string path, Action<string, DataValueSnapshot> observer)
{
var list = _observers.GetOrAdd(path, _ => []);
lock (list) { list.Add(observer); }
return new Unsub(this, path, observer);
}
/// <summary>
/// Change-trigger entry point — called by the upstream subscription callback.
/// Updates the cache, fans out to observers (so OPC UA clients see the upstream
/// change too if they subscribed via the engine), and schedules every
/// change-triggered dependent for re-evaluation in topological order.
/// </summary>
internal void OnUpstreamChange(string path, DataValueSnapshot value)
{
_valueCache[path] = value;
NotifyObservers(path, value);
// Fire-and-forget — the upstream subscription callback must not block the
// driver's dispatcher. Exceptions during cascade are handled per-tag inside
// EvaluateInternalAsync.
_ = CascadeAsync(path, CancellationToken.None);
}
private async Task CascadeAsync(string upstreamPath, CancellationToken ct)
{
try
{
var dependents = _graph.TransitiveDependentsInOrder(upstreamPath);
foreach (var dep in dependents)
{
if (_tags.TryGetValue(dep, out var state) && state.Definition.ChangeTriggered)
await EvaluateInternalAsync(dep, ct).ConfigureAwait(false);
}
}
catch (Exception ex)
{
_engineLogger.Error(ex, "VirtualTagEngine cascade failed for upstream {Path}", upstreamPath);
}
}
private async Task EvaluateInternalAsync(string path, CancellationToken ct)
{
if (!_tags.TryGetValue(path, out var state)) return;
// Serial evaluation across all tags. Phase 7 plan decision #19 — parallel is a
// follow-up. The semaphore bounds the evaluation graph so two cascades don't
// interleave, which would break the "earlier nodes computed first" invariant.
// SemaphoreSlim.WaitAsync is async-safe where Monitor.Enter is not (Monitor
// ownership is thread-local and lost across await).
await _evalGate.WaitAsync(ct).ConfigureAwait(false);
try
{
var ctxCache = BuildReadCache(state.Reads);
var context = new VirtualTagContext(
ctxCache,
(p, v) => OnScriptSetVirtualTag(p, v),
state.Logger,
_clock);
DataValueSnapshot result;
try
{
var raw = await state.Evaluator.RunAsync(context, ct).ConfigureAwait(false);
var coerced = CoerceResult(raw, state.Definition.DataType);
result = new DataValueSnapshot(coerced, 0u, _clock(), _clock());
}
catch (ScriptTimeoutException tex)
{
state.Logger.Warning("Script timed out after {Timeout}", tex.Timeout);
result = new DataValueSnapshot(null, 0x80020000u /* BadInternalError */, null, _clock());
}
catch (OperationCanceledException)
{
throw; // shutdown path — don't misclassify
}
catch (Exception ex)
{
state.Logger.Error(ex, "Virtual-tag script threw");
result = new DataValueSnapshot(null, 0x80020000u /* BadInternalError */, null, _clock());
}
_valueCache[path] = result;
NotifyObservers(path, result);
if (state.Definition.Historize) _history.Record(path, result);
}
finally
{
_evalGate.Release();
}
}
private IReadOnlyDictionary<string, DataValueSnapshot> BuildReadCache(IReadOnlySet<string> reads)
{
var map = new Dictionary<string, DataValueSnapshot>(StringComparer.Ordinal);
foreach (var r in reads)
{
map[r] = _valueCache.TryGetValue(r, out var v)
? v
: _upstream.ReadTag(r);
}
return map;
}
private void OnScriptSetVirtualTag(string path, object? value)
{
if (!_tags.ContainsKey(path))
{
_engineLogger.Warning(
"Script attempted ctx.SetVirtualTag on non-virtual or non-registered path {Path}", path);
return;
}
var snap = new DataValueSnapshot(value, 0u, _clock(), _clock());
_valueCache[path] = snap;
NotifyObservers(path, snap);
if (_tags[path].Definition.Historize) _history.Record(path, snap);
}
private void NotifyObservers(string path, DataValueSnapshot value)
{
if (!_observers.TryGetValue(path, out var list)) return;
Action<string, DataValueSnapshot>[] snapshot;
lock (list) { snapshot = list.ToArray(); }
foreach (var obs in snapshot)
{
try { obs(path, value); }
catch (Exception ex)
{
_engineLogger.Warning(ex, "Virtual-tag observer for {Path} threw", path);
}
}
}
private static object? CoerceResult(object? raw, DriverDataType target)
{
if (raw is null) return null;
try
{
return target switch
{
DriverDataType.Boolean => Convert.ToBoolean(raw),
DriverDataType.Int32 => Convert.ToInt32(raw),
DriverDataType.Int64 => Convert.ToInt64(raw),
DriverDataType.Float32 => Convert.ToSingle(raw),
DriverDataType.Float64 => Convert.ToDouble(raw),
DriverDataType.String => Convert.ToString(raw) ?? string.Empty,
DriverDataType.DateTime => raw is DateTime dt ? dt : Convert.ToDateTime(raw),
_ => raw,
};
}
catch
{
// Caller logs + maps to BadTypeMismatch — we let null propagate so the
// outer evaluation path sets the Bad quality.
return null;
}
}
private void UnsubscribeFromUpstream()
{
foreach (var s in _upstreamSubscriptions)
{
try { s.Dispose(); } catch { /* best effort */ }
}
_upstreamSubscriptions.Clear();
}
private void EnsureLoaded()
{
if (!_loaded) throw new InvalidOperationException(
"VirtualTagEngine not loaded. Call Load(definitions) first.");
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
UnsubscribeFromUpstream();
_tags.Clear();
_graph.Clear();
}
internal DependencyGraph GraphForTesting => _graph;
private sealed class Unsub : IDisposable
{
private readonly VirtualTagEngine _engine;
private readonly string _path;
private readonly Action<string, DataValueSnapshot> _observer;
public Unsub(VirtualTagEngine e, string path, Action<string, DataValueSnapshot> observer)
{
_engine = e; _path = path; _observer = observer;
}
public void Dispose()
{
if (_engine._observers.TryGetValue(_path, out var list))
{
lock (list) { list.Remove(_observer); }
}
}
}
internal sealed record VirtualTagState(
VirtualTagDefinition Definition,
TimedScriptEvaluator<VirtualTagContext, object?> Evaluator,
IReadOnlySet<string> Reads,
IReadOnlySet<string> Writes,
ILogger Logger);
}

View File

@@ -0,0 +1,89 @@
using System.Collections.Concurrent;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
/// <summary>
/// Implements the driver-agnostic capability surface the
/// <c>DriverNodeManager</c> dispatches to when a node resolves to
/// <c>NodeSource.Virtual</c> per ADR-002. Reads return the engine's last-known
/// evaluation result; subscriptions forward engine-emitted change events as
/// <see cref="ISubscribable.OnDataChange"/> events.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="IWritable"/> is deliberately not implemented — OPC UA client
/// writes to virtual tags are rejected in <c>DriverNodeManager</c> before they
/// reach here per Phase 7 decision #6. Scripts are the only write path, routed
/// through <c>ctx.SetVirtualTag</c>.
/// </para>
/// </remarks>
public sealed class VirtualTagSource : IReadable, ISubscribable
{
private readonly VirtualTagEngine _engine;
private readonly ConcurrentDictionary<string, Subscription> _subs = new(StringComparer.Ordinal);
public VirtualTagSource(VirtualTagEngine engine)
{
_engine = engine ?? throw new ArgumentNullException(nameof(engine));
}
public event EventHandler<DataChangeEventArgs>? OnDataChange;
public Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
{
if (fullReferences is null) throw new ArgumentNullException(nameof(fullReferences));
var results = new DataValueSnapshot[fullReferences.Count];
for (var i = 0; i < fullReferences.Count; i++)
results[i] = _engine.Read(fullReferences[i]);
return Task.FromResult<IReadOnlyList<DataValueSnapshot>>(results);
}
public Task<ISubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> fullReferences,
TimeSpan publishingInterval,
CancellationToken cancellationToken)
{
if (fullReferences is null) throw new ArgumentNullException(nameof(fullReferences));
var handle = new SubscriptionHandle(Guid.NewGuid().ToString("N"));
var observers = new List<IDisposable>(fullReferences.Count);
foreach (var path in fullReferences)
{
observers.Add(_engine.Subscribe(path, (p, snap) =>
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, p, snap))));
}
_subs[handle.DiagnosticId] = new Subscription(handle, observers);
// OPC UA convention: emit initial-data callback for each path with the current value.
foreach (var path in fullReferences)
{
var snap = _engine.Read(path);
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, path, snap));
}
return Task.FromResult<ISubscriptionHandle>(handle);
}
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
{
if (handle is null) throw new ArgumentNullException(nameof(handle));
if (_subs.TryRemove(handle.DiagnosticId, out var sub))
{
foreach (var d in sub.Observers)
{
try { d.Dispose(); } catch { }
}
}
return Task.CompletedTask;
}
private sealed class SubscriptionHandle : ISubscriptionHandle
{
public SubscriptionHandle(string id) { DiagnosticId = id; }
public string DiagnosticId { get; }
}
private sealed record Subscription(SubscriptionHandle Handle, IReadOnlyList<IDisposable> Observers);
}

View File

@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Core.VirtualTags</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Serilog" Version="4.2.0"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Scripting\ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj"/>
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Core.VirtualTags.Tests"/>
</ItemGroup>
<ItemGroup>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
</ItemGroup>
</Project>

View File

@@ -87,6 +87,16 @@ public static class EquipmentNodeWalker
.GroupBy(t => t.EquipmentId!, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.OrderBy(t => t.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase);
var virtualTagsByEquipment = (content.VirtualTags ?? [])
.Where(v => v.Enabled)
.GroupBy(v => v.EquipmentId, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.OrderBy(v => v.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase);
var scriptedAlarmsByEquipment = (content.ScriptedAlarms ?? [])
.Where(a => a.Enabled)
.GroupBy(a => a.EquipmentId, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.OrderBy(a => a.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase);
foreach (var area in content.Areas.OrderBy(a => a.Name, StringComparer.Ordinal))
{
var areaBuilder = namespaceBuilder.Folder(area.Name, area.Name);
@@ -103,9 +113,17 @@ public static class EquipmentNodeWalker
AddIdentifierProperties(equipmentBuilder, equipment);
IdentificationFolderBuilder.Build(equipmentBuilder, equipment);
if (!tagsByEquipment.TryGetValue(equipment.EquipmentId, out var equipmentTags)) continue;
foreach (var tag in equipmentTags)
AddTagVariable(equipmentBuilder, tag);
if (tagsByEquipment.TryGetValue(equipment.EquipmentId, out var equipmentTags))
foreach (var tag in equipmentTags)
AddTagVariable(equipmentBuilder, tag);
if (virtualTagsByEquipment.TryGetValue(equipment.EquipmentId, out var vTags))
foreach (var vtag in vTags)
AddVirtualTagVariable(equipmentBuilder, vtag);
if (scriptedAlarmsByEquipment.TryGetValue(equipment.EquipmentId, out var alarms))
foreach (var alarm in alarms)
AddScriptedAlarmVariable(equipmentBuilder, alarm);
}
}
}
@@ -157,6 +175,55 @@ public static class EquipmentNodeWalker
/// </summary>
private static DriverDataType ParseDriverDataType(string raw) =>
Enum.TryParse<DriverDataType>(raw, ignoreCase: true, out var parsed) ? parsed : DriverDataType.String;
/// <summary>
/// Emit a <see cref="VirtualTag"/> row as a <see cref="NodeSourceKind.Virtual"/>
/// variable node. <c>FullName</c> doubles as the UNS path Phase 7's VirtualTagEngine
/// addresses its engine-side entries by. The <c>VirtualTagId</c> discriminator lets
/// the DriverNodeManager dispatch Reads/Subscribes to the engine rather than any
/// driver.
/// </summary>
private static void AddVirtualTagVariable(IAddressSpaceBuilder equipmentBuilder, VirtualTag vtag)
{
var attr = new DriverAttributeInfo(
FullName: vtag.VirtualTagId,
DriverDataType: ParseDriverDataType(vtag.DataType),
IsArray: false,
ArrayDim: null,
SecurityClass: SecurityClassification.FreeAccess,
IsHistorized: vtag.Historize,
IsAlarm: false,
WriteIdempotent: false,
Source: NodeSourceKind.Virtual,
VirtualTagId: vtag.VirtualTagId,
ScriptedAlarmId: null);
equipmentBuilder.Variable(vtag.Name, vtag.Name, attr);
}
/// <summary>
/// Emit a <see cref="ScriptedAlarm"/> row as a <see cref="NodeSourceKind.ScriptedAlarm"/>
/// variable node. The OPC UA Part 9 alarm-condition materialization happens at the
/// node-manager level (which wires the concrete <c>AlarmConditionState</c> subclass
/// per <see cref="ScriptedAlarm.AlarmType"/>); this walker provides the browse-level
/// anchor + the <see cref="DriverAttributeInfo.IsAlarm"/> flag that triggers that
/// materialization path.
/// </summary>
private static void AddScriptedAlarmVariable(IAddressSpaceBuilder equipmentBuilder, ScriptedAlarm alarm)
{
var attr = new DriverAttributeInfo(
FullName: alarm.ScriptedAlarmId,
DriverDataType: DriverDataType.Boolean,
IsArray: false,
ArrayDim: null,
SecurityClass: SecurityClassification.FreeAccess,
IsHistorized: false,
IsAlarm: true,
WriteIdempotent: false,
Source: NodeSourceKind.ScriptedAlarm,
VirtualTagId: null,
ScriptedAlarmId: alarm.ScriptedAlarmId);
equipmentBuilder.Variable(alarm.Name, alarm.Name, attr);
}
}
/// <summary>
@@ -170,4 +237,6 @@ public sealed record EquipmentNamespaceContent(
IReadOnlyList<UnsArea> Areas,
IReadOnlyList<UnsLine> Lines,
IReadOnlyList<Equipment> Equipment,
IReadOnlyList<Tag> Tags);
IReadOnlyList<Tag> Tags,
IReadOnlyList<VirtualTag>? VirtualTags = null,
IReadOnlyList<ScriptedAlarm>? ScriptedAlarms = null);

View File

@@ -60,6 +60,14 @@ public enum MessageKind : byte
HostConnectivityStatus = 0x70,
RuntimeStatusChange = 0x71,
// Phase 7 Stream D — historian alarm sink. Main server → Galaxy.Host batched
// writes into the Aveva Historian alarm schema via the already-loaded
// aahClientManaged DLLs. HistorianConnectivityStatus fires proactively from the
// Host when the SDK session transitions so diagnostics flip promptly.
HistorianAlarmEventRequest = 0x80,
HistorianAlarmEventResponse = 0x81,
HistorianConnectivityStatus = 0x82,
RecycleHostRequest = 0xF0,
RecycleStatusResponse = 0xF1,

View File

@@ -0,0 +1,92 @@
using System;
using MessagePack;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
/// <summary>
/// Phase 7 Stream D — IPC contracts for routing Part 9 alarm transitions from the
/// main .NET 10 server into Galaxy.Host's already-loaded <c>aahClientManaged</c>
/// DLLs. Reuses the Tier-C isolation + licensing pathway rather than loading 32-bit
/// native historian code into the main server.
/// </summary>
/// <remarks>
/// <para>
/// Batched on the wire to amortize IPC overhead — the main server's SqliteStoreAndForwardSink
/// ships up to 100 events per request per Phase 7 plan Stream D.5.
/// </para>
/// <para>
/// Per-event outcomes (Ack / RetryPlease / PermanentFail) let the drain worker
/// dead-letter malformed events without blocking neighbors in the batch.
/// <see cref="HistorianConnectivityStatusNotification"/> fires proactively from
/// the Host when the SDK session drops so the /hosts + /alarms/historian Admin
/// diagnostics pages flip to red promptly instead of waiting for the next
/// drain cycle.
/// </para>
/// </remarks>
[MessagePackObject]
public sealed class HistorianAlarmEventRequest
{
[Key(0)] public HistorianAlarmEventDto[] Events { get; set; } = Array.Empty<HistorianAlarmEventDto>();
}
[MessagePackObject]
public sealed class HistorianAlarmEventResponse
{
/// <summary>Per-event outcome, same order as the request.</summary>
[Key(0)] public HistorianAlarmEventOutcomeDto[] Outcomes { get; set; } = Array.Empty<HistorianAlarmEventOutcomeDto>();
}
/// <summary>Outcome enum — bytes on the wire so it stays compact.</summary>
public enum HistorianAlarmEventOutcomeDto : byte
{
/// <summary>Successfully persisted to the historian — remove from queue.</summary>
Ack = 0,
/// <summary>Transient failure (historian disconnected, timeout, busy) — retry after backoff.</summary>
RetryPlease = 1,
/// <summary>Permanent failure (malformed, unrecoverable SDK error) — move to dead-letter.</summary>
PermanentFail = 2,
}
/// <summary>One alarm-transition payload. Fields mirror <c>Core.AlarmHistorian.AlarmHistorianEvent</c>.</summary>
[MessagePackObject]
public sealed class HistorianAlarmEventDto
{
[Key(0)] public string AlarmId { get; set; } = string.Empty;
[Key(1)] public string EquipmentPath { get; set; } = string.Empty;
[Key(2)] public string AlarmName { get; set; } = string.Empty;
/// <summary>Concrete Part 9 subtype name — "LimitAlarm" / "OffNormalAlarm" / "AlarmCondition" / "DiscreteAlarm".</summary>
[Key(3)] public string AlarmTypeName { get; set; } = string.Empty;
/// <summary>Numeric severity the Host maps to the historian's priority scale.</summary>
[Key(4)] public int Severity { get; set; }
/// <summary>Which transition this event represents — "Activated" / "Cleared" / "Acknowledged" / etc.</summary>
[Key(5)] public string EventKind { get; set; } = string.Empty;
/// <summary>Pre-rendered message — template tokens resolved upstream.</summary>
[Key(6)] public string Message { get; set; } = string.Empty;
/// <summary>Operator who triggered the transition. "system" for engine-driven events.</summary>
[Key(7)] public string User { get; set; } = "system";
/// <summary>Operator-supplied free-form comment, if any.</summary>
[Key(8)] public string? Comment { get; set; }
/// <summary>Source timestamp (UTC Unix milliseconds).</summary>
[Key(9)] public long TimestampUtcUnixMs { get; set; }
}
/// <summary>
/// Proactive notification — Galaxy.Host pushes this when the historian SDK session
/// transitions (connected / disconnected / degraded). The main server reflects this
/// into the historian sink status so Admin UI surfaces the problem without the
/// operator having to scrutinize drain cadence.
/// </summary>
[MessagePackObject]
public sealed class HistorianConnectivityStatusNotification
{
[Key(0)] public string Status { get; set; } = "unknown"; // connected | disconnected | degraded
[Key(1)] public string? Detail { get; set; }
[Key(2)] public long ObservedAtUtcUnixMs { get; set; }
}

View File

@@ -68,9 +68,18 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
private readonly AuthorizationGate? _authzGate;
private readonly NodeScopeResolver? _scopeResolver;
// Phase 7 Stream G follow-up — per-variable NodeSourceKind so OnReadValue can dispatch
// to the VirtualTagEngine / ScriptedAlarmEngine instead of the driver's IReadable per
// ADR-002. Absent entries default to Driver so drivers registered before Phase 7
// keep working unchanged.
private readonly Dictionary<string, NodeSourceKind> _sourceByFullRef = new(StringComparer.OrdinalIgnoreCase);
private readonly IReadable? _virtualReadable;
private readonly IReadable? _scriptedAlarmReadable;
public DriverNodeManager(IServerInternal server, ApplicationConfiguration configuration,
IDriver driver, CapabilityInvoker invoker, ILogger<DriverNodeManager> logger,
AuthorizationGate? authzGate = null, NodeScopeResolver? scopeResolver = null)
AuthorizationGate? authzGate = null, NodeScopeResolver? scopeResolver = null,
IReadable? virtualReadable = null, IReadable? scriptedAlarmReadable = null)
: base(server, configuration, namespaceUris: $"urn:OtOpcUa:{driver.DriverInstanceId}")
{
_driver = driver;
@@ -80,6 +89,8 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
_invoker = invoker;
_authzGate = authzGate;
_scopeResolver = scopeResolver;
_virtualReadable = virtualReadable;
_scriptedAlarmReadable = scriptedAlarmReadable;
_logger = logger;
}
@@ -185,6 +196,7 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
_variablesByFullRef[attributeInfo.FullName] = v;
_securityByFullRef[attributeInfo.FullName] = attributeInfo.SecurityClass;
_writeIdempotentByFullRef[attributeInfo.FullName] = attributeInfo.WriteIdempotent;
_sourceByFullRef[attributeInfo.FullName] = attributeInfo.Source;
v.OnReadValue = OnReadValue;
v.OnWriteValue = OnWriteValue;
@@ -216,16 +228,18 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
private ServiceResult OnReadValue(ISystemContext context, NodeState node, NumericRange indexRange,
QualifiedName dataEncoding, ref object? value, ref StatusCode statusCode, ref DateTime timestamp)
{
if (_readable is null)
var fullRef = node.NodeId.Identifier as string ?? "";
var source = _sourceByFullRef.TryGetValue(fullRef, out var s) ? s : NodeSourceKind.Driver;
var readable = SelectReadable(source, _readable, _virtualReadable, _scriptedAlarmReadable);
if (readable is null)
{
statusCode = StatusCodes.BadNotReadable;
statusCode = source == NodeSourceKind.Driver ? StatusCodes.BadNotReadable : StatusCodes.BadNotFound;
return ServiceResult.Good;
}
try
{
var fullRef = node.NodeId.Identifier as string ?? "";
// Phase 6.2 Stream C — authorization gate. Runs ahead of the invoker so a denied
// read never hits the driver. Returns true in lax mode when identity lacks LDAP
// groups; strict mode denies those cases. See AuthorizationGate remarks.
@@ -242,7 +256,7 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
var result = _invoker.ExecuteAsync(
DriverCapability.Read,
ResolveHostFor(fullRef),
async ct => (IReadOnlyList<DataValueSnapshot>)await _readable.ReadAsync([fullRef], ct).ConfigureAwait(false),
async ct => (IReadOnlyList<DataValueSnapshot>)await readable.ReadAsync([fullRef], ct).ConfigureAwait(false),
CancellationToken.None).AsTask().GetAwaiter().GetResult();
if (result.Count == 0)
{
@@ -262,6 +276,32 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
return ServiceResult.Good;
}
/// <summary>
/// Picks the <see cref="IReadable"/> the dispatch layer routes through based on the
/// node's Phase 7 source kind (ADR-002). Extracted as a pure function for unit test
/// coverage — the full dispatch requires the OPC UA server stack, but this kernel is
/// deterministic and small.
/// </summary>
internal static IReadable? SelectReadable(
NodeSourceKind source,
IReadable? driverReadable,
IReadable? virtualReadable,
IReadable? scriptedAlarmReadable) => source switch
{
NodeSourceKind.Virtual => virtualReadable,
NodeSourceKind.ScriptedAlarm => scriptedAlarmReadable,
_ => driverReadable,
};
/// <summary>
/// Plan decision #6 gate — returns true only when the write is allowed. Virtual tags
/// and scripted alarms reject OPC UA writes because the write path for virtual tags
/// is <c>ctx.SetVirtualTag</c> from within a script, and the write path for alarm
/// state is the Part 9 method nodes (Acknowledge / Confirm / Shelve).
/// </summary>
internal static bool IsWriteAllowedBySource(NodeSourceKind source) =>
source == NodeSourceKind.Driver;
private static NodeId MapDataType(DriverDataType t) => t switch
{
DriverDataType.Boolean => DataTypeIds.Boolean,
@@ -414,10 +454,19 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
private ServiceResult OnWriteValue(ISystemContext context, NodeState node, NumericRange indexRange,
QualifiedName dataEncoding, ref object? value, ref StatusCode statusCode, ref DateTime timestamp)
{
if (_writable is null) return StatusCodes.BadNotWritable;
var fullRef = node.NodeId.Identifier as string;
if (string.IsNullOrEmpty(fullRef)) return StatusCodes.BadNodeIdUnknown;
// Per Phase 7 plan decision #6 — virtual tags + scripted alarms reject direct
// OPC UA writes with BadUserAccessDenied. Scripts can write to virtual tags
// via ctx.SetVirtualTag; operators cannot. Operator alarm actions go through
// the Part 9 method nodes (Acknowledge / Confirm / Shelve), not through the
// variable-value write path.
if (_sourceByFullRef.TryGetValue(fullRef!, out var source) && !IsWriteAllowedBySource(source))
return new ServiceResult(StatusCodes.BadUserAccessDenied);
if (_writable is null) return StatusCodes.BadNotWritable;
// PR 26: server-layer write authorization. Look up the attribute's classification
// (populated during Variable() in Discover) and check the session's roles against the
// policy table. Drivers don't participate in this decision — IWritable.WriteAsync

View File

@@ -30,6 +30,14 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
private readonly Func<string, ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverTier>? _tierLookup;
private readonly Func<string, string?>? _resilienceConfigLookup;
private readonly Func<string, ZB.MOM.WW.OtOpcUa.Core.OpcUa.EquipmentNamespaceContent?>? _equipmentContentLookup;
// Phase 7 Stream G follow-up (task #239). When composed with the VirtualTagEngine +
// ScriptedAlarmEngine sources these route node reads to the engines instead of the
// driver. Null = Phase 7 engines not enabled for this deployment (identical to pre-
// Phase-7 behaviour).
private readonly ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable? _virtualReadable;
private readonly ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable? _scriptedAlarmReadable;
private readonly ILoggerFactory _loggerFactory;
private readonly ILogger<OpcUaApplicationHost> _logger;
private ApplicationInstance? _application;
@@ -45,7 +53,9 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
StaleConfigFlag? staleConfigFlag = null,
Func<string, ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverTier>? tierLookup = null,
Func<string, string?>? resilienceConfigLookup = null,
Func<string, ZB.MOM.WW.OtOpcUa.Core.OpcUa.EquipmentNamespaceContent?>? equipmentContentLookup = null)
Func<string, ZB.MOM.WW.OtOpcUa.Core.OpcUa.EquipmentNamespaceContent?>? equipmentContentLookup = null,
ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable? virtualReadable = null,
ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable? scriptedAlarmReadable = null)
{
_options = options;
_driverHost = driverHost;
@@ -57,6 +67,8 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
_tierLookup = tierLookup;
_resilienceConfigLookup = resilienceConfigLookup;
_equipmentContentLookup = equipmentContentLookup;
_virtualReadable = virtualReadable;
_scriptedAlarmReadable = scriptedAlarmReadable;
_loggerFactory = loggerFactory;
_logger = logger;
}
@@ -85,7 +97,8 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
_server = new OtOpcUaServer(_driverHost, _authenticator, _pipelineBuilder, _loggerFactory,
authzGate: _authzGate, scopeResolver: _scopeResolver,
tierLookup: _tierLookup, resilienceConfigLookup: _resilienceConfigLookup);
tierLookup: _tierLookup, resilienceConfigLookup: _resilienceConfigLookup,
virtualReadable: _virtualReadable, scriptedAlarmReadable: _scriptedAlarmReadable);
await _application.Start(_server).ConfigureAwait(false);
_logger.LogInformation("OPC UA server started — endpoint={Endpoint} driverCount={Count}",

View File

@@ -25,6 +25,15 @@ public sealed class OtOpcUaServer : StandardServer
private readonly NodeScopeResolver? _scopeResolver;
private readonly Func<string, DriverTier>? _tierLookup;
private readonly Func<string, string?>? _resilienceConfigLookup;
// Phase 7 Stream G follow-up wiring (task #239). Shared across every DriverNodeManager
// instantiated by this server so virtual-tag reads and scripted-alarm reads from any
// driver's address-space subtree route to the same engine. When null (no Phase 7
// engines composed for this deployment) DriverNodeManager falls back to driver-only
// dispatch — identical to pre-Phase-7 behaviour.
private readonly IReadable? _virtualReadable;
private readonly IReadable? _scriptedAlarmReadable;
private readonly ILoggerFactory _loggerFactory;
private readonly List<DriverNodeManager> _driverNodeManagers = new();
@@ -36,7 +45,9 @@ public sealed class OtOpcUaServer : StandardServer
AuthorizationGate? authzGate = null,
NodeScopeResolver? scopeResolver = null,
Func<string, DriverTier>? tierLookup = null,
Func<string, string?>? resilienceConfigLookup = null)
Func<string, string?>? resilienceConfigLookup = null,
IReadable? virtualReadable = null,
IReadable? scriptedAlarmReadable = null)
{
_driverHost = driverHost;
_authenticator = authenticator;
@@ -45,6 +56,8 @@ public sealed class OtOpcUaServer : StandardServer
_scopeResolver = scopeResolver;
_tierLookup = tierLookup;
_resilienceConfigLookup = resilienceConfigLookup;
_virtualReadable = virtualReadable;
_scriptedAlarmReadable = scriptedAlarmReadable;
_loggerFactory = loggerFactory;
}
@@ -77,7 +90,8 @@ public sealed class OtOpcUaServer : StandardServer
var invoker = new CapabilityInvoker(_pipelineBuilder, driver.DriverInstanceId, () => options, driver.DriverType);
var manager = new DriverNodeManager(server, configuration, driver, invoker, logger,
authzGate: _authzGate, scopeResolver: _scopeResolver);
authzGate: _authzGate, scopeResolver: _scopeResolver,
virtualReadable: _virtualReadable, scriptedAlarmReadable: _scriptedAlarmReadable);
_driverNodeManagers.Add(manager);
}

View File

@@ -0,0 +1,130 @@
using System.Net;
using System.Net.Sockets;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Admin.E2ETests;
/// <summary>
/// Stands up the Admin Blazor Server host on a free TCP port with the live SQL Server
/// context swapped for an EF Core InMemory DbContext + the LDAP cookie auth swapped for
/// <see cref="TestAuthHandler"/>. Playwright connects to <see cref="BaseUrl"/>.
/// InMemory is sufficient because UnsService's drag-drop path exercises EF operations,
/// not raw SQL.
/// </summary>
/// <remarks>
/// We deliberately build a <see cref="WebApplication"/> directly rather than going through
/// <c>WebApplicationFactory&lt;Program&gt;</c> — the factory's TestServer transport doesn't
/// coexist cleanly with Kestrel-on-a-real-port, and Playwright needs a real loopback HTTP
/// endpoint to hit. This mirrors the Program.cs entry-points for everything else.
/// </remarks>
public sealed class AdminWebAppFactory : IAsyncDisposable
{
private WebApplication? _app;
public string BaseUrl { get; private set; } = "";
public long SeededGenerationId { get; private set; }
public string SeededClusterId { get; } = "e2e-cluster";
public async Task StartAsync()
{
var port = GetFreeTcpPort();
BaseUrl = $"http://127.0.0.1:{port}";
var builder = WebApplication.CreateBuilder(Array.Empty<string>());
builder.WebHost.UseUrls(BaseUrl);
// --- Mirror the Admin composition in Program.cs, but with the InMemory DB + test
// auth swaps instead of SQL Server + LDAP cookie auth.
builder.Services.AddRazorComponents().AddInteractiveServerComponents();
builder.Services.AddHttpContextAccessor();
builder.Services.AddSignalR();
builder.Services.AddAntiforgery();
builder.Services.AddAuthentication(TestAuthHandler.SchemeName)
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(TestAuthHandler.SchemeName, _ => { });
builder.Services.AddAuthorizationBuilder()
.AddPolicy("CanEdit", p => p.RequireRole(Admin.Services.AdminRoles.ConfigEditor, Admin.Services.AdminRoles.FleetAdmin))
.AddPolicy("CanPublish", p => p.RequireRole(Admin.Services.AdminRoles.FleetAdmin));
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddDbContext<OtOpcUaConfigDbContext>(opt =>
opt.UseInMemoryDatabase($"e2e-{Guid.NewGuid():N}"));
builder.Services.AddScoped<Admin.Services.ClusterService>();
builder.Services.AddScoped<Admin.Services.GenerationService>();
builder.Services.AddScoped<Admin.Services.UnsService>();
builder.Services.AddScoped<Admin.Services.EquipmentService>();
builder.Services.AddScoped<Admin.Services.NamespaceService>();
builder.Services.AddScoped<Admin.Services.DriverInstanceService>();
builder.Services.AddScoped<Admin.Services.DraftValidationService>();
_app = builder.Build();
_app.UseStaticFiles();
_app.UseRouting();
_app.UseAuthentication();
_app.UseAuthorization();
_app.UseAntiforgery();
_app.MapRazorComponents<Admin.Components.App>().AddInteractiveServerRenderMode();
// Seed the draft BEFORE starting the host so Playwright sees a ready page on first nav.
using (var scope = _app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
SeededGenerationId = Seed(db, SeededClusterId);
}
await _app.StartAsync();
}
public async ValueTask DisposeAsync()
{
if (_app is not null)
{
await _app.StopAsync();
await _app.DisposeAsync();
}
}
private static long Seed(OtOpcUaConfigDbContext db, string clusterId)
{
var cluster = new ServerCluster
{
ClusterId = clusterId, Name = "e2e", Enterprise = "zb", Site = "lab",
RedundancyMode = RedundancyMode.None, NodeCount = 1, CreatedBy = "e2e",
};
var gen = new ConfigGeneration
{
ClusterId = clusterId, Status = GenerationStatus.Draft, CreatedBy = "e2e",
};
db.ServerClusters.Add(cluster);
db.ConfigGenerations.Add(gen);
db.SaveChanges();
db.UnsAreas.AddRange(
new UnsArea { UnsAreaId = "area-a", ClusterId = clusterId, Name = "warsaw", GenerationId = gen.GenerationId },
new UnsArea { UnsAreaId = "area-b", ClusterId = clusterId, Name = "berlin", GenerationId = gen.GenerationId });
db.UnsLines.Add(new UnsLine
{
UnsLineId = "line-a1", UnsAreaId = "area-a", Name = "oven-line", GenerationId = gen.GenerationId,
});
db.SaveChanges();
return gen.GenerationId;
}
private static int GetFreeTcpPort()
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
return port;
}
}

View File

@@ -0,0 +1,44 @@
using Microsoft.Playwright;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Admin.E2ETests;
/// <summary>
/// One Playwright runtime + Chromium browser for the whole E2E suite. Tests
/// open a fresh <see cref="IBrowserContext"/> per fixture so cookies + localStorage
/// stay isolated. Browser install is a one-time step:
/// <c>pwsh tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/bin/Debug/net10.0/playwright.ps1 install chromium</c>.
/// When the browser binary isn't present the suite reports a <see cref="PlaywrightBrowserMissingException"/>
/// so CI can distinguish missing-browser from real test failure.
/// </summary>
public sealed class PlaywrightFixture : IAsyncLifetime
{
public IPlaywright Playwright { get; private set; } = null!;
public IBrowser Browser { get; private set; } = null!;
public async ValueTask InitializeAsync()
{
Playwright = await Microsoft.Playwright.Playwright.CreateAsync();
try
{
Browser = await Playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions { Headless = true });
}
catch (PlaywrightException ex) when (ex.Message.Contains("Executable doesn't exist"))
{
throw new PlaywrightBrowserMissingException(ex.Message);
}
}
public async ValueTask DisposeAsync()
{
if (Browser is not null) await Browser.CloseAsync();
Playwright?.Dispose();
}
}
/// <summary>
/// Thrown by <see cref="PlaywrightFixture"/> when Chromium isn't installed. Tests
/// catching this mark themselves as "skipped" rather than "failed", so CI without
/// the install step stays green.
/// </summary>
public sealed class PlaywrightBrowserMissingException(string message) : Exception(message);

View File

@@ -0,0 +1,34 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.OtOpcUa.Admin.Services;
namespace ZB.MOM.WW.OtOpcUa.Admin.E2ETests;
/// <summary>
/// Stamps every request with a FleetAdmin principal so E2E tests can hit
/// authenticated Razor pages without the LDAP login flow. Registered as the
/// default authentication scheme by <see cref="AdminWebAppFactory"/>.
/// </summary>
public sealed class TestAuthHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
public const string SchemeName = "Test";
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var claims = new[]
{
new Claim(ClaimTypes.Name, "e2e-test-user"),
new Claim(ClaimTypes.Role, AdminRoles.FleetAdmin),
};
var identity = new ClaimsIdentity(claims, SchemeName);
var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), SchemeName);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}

View File

@@ -0,0 +1,74 @@
using Microsoft.Playwright;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Admin.E2ETests;
/// <summary>
/// Phase 6.4 UnsTab drag-drop E2E smoke (task #199). This PR lands the Playwright +
/// WebApplicationFactory-equivalent scaffolding so future E2E coverage builds on it
/// rather than setting it up from scratch.
/// </summary>
/// <remarks>
/// <para>
/// <b>Prerequisite.</b> Chromium must be installed locally:
/// <c>pwsh tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/bin/Debug/net10.0/playwright.ps1 install chromium</c>.
/// When the binary is missing the tests <see cref="Assert.Skip"/> rather than fail hard,
/// so CI pipelines that don't run the install step still report green.
/// </para>
/// <para>
/// <b>Current scope.</b> The host-reachability smoke below proves the infra works:
/// Kestrel-on-a-free-port, InMemory DbContext swap, <see cref="TestAuthHandler"/>
/// bypass, and Playwright-to-real-browser are all exercised. The actual drag-drop
/// interactive assertion is filed as a follow-up (task #242) because
/// Blazor Server interactive render through a test-owned pipeline needs a dedicated
/// diagnosis pass — the scaffolding lands here first so that follow-up can focus on
/// the Blazor-specific wiring instead of rebuilding the harness.
/// </para>
/// </remarks>
[Trait("Category", "E2E")]
public sealed class UnsTabDragDropE2ETests
{
[Fact]
public async Task Admin_host_serves_HTTP_via_Playwright_scaffolding()
{
await using var app = new AdminWebAppFactory();
await app.StartAsync();
PlaywrightFixture fixture;
try
{
fixture = new PlaywrightFixture();
await fixture.InitializeAsync();
}
catch (PlaywrightBrowserMissingException)
{
Assert.Skip("Chromium not installed. Run playwright.ps1 install chromium.");
return;
}
try
{
var ctx = await fixture.Browser.NewContextAsync();
var page = await ctx.NewPageAsync();
// Navigate to the root. We only assert the host is live + returns HTML — not
// that the Blazor Server interactive render has booted. Booting the interactive
// circuit in a test-owned pipeline is task #242.
var response = await page.GotoAsync(app.BaseUrl);
response.ShouldNotBeNull();
response!.Status.ShouldBeLessThan(500,
$"Admin host returned HTTP {response.Status} at root — scaffolding broken");
// Static HTML shell should at least include the <body> and some content. This
// rules out 404s + verifies the MapRazorComponents route pipeline is wired.
var body = await page.Locator("body").InnerHTMLAsync();
body.Length.ShouldBeGreaterThan(0, "empty body = routing pipeline didn't hit Razor");
}
finally
{
await fixture.DisposeAsync();
}
}
}

View File

@@ -0,0 +1,34 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Admin.E2ETests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3" Version="1.1.0"/>
<PackageReference Include="Shouldly" Version="4.3.0"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0"/>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.0"/>
<PackageReference Include="Microsoft.Playwright" Version="1.51.0"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Admin\ZB.MOM.WW.OtOpcUa.Admin.csproj"/>
</ItemGroup>
<ItemGroup>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,196 @@
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Admin.Services;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
/// <summary>
/// Admin-side services shipped in Phase 7 Stream F — draft CRUD for scripts + virtual
/// tags + scripted alarms, the pre-publish test harness, and the historian
/// diagnostics façade.
/// </summary>
[Trait("Category", "Unit")]
public sealed class Phase7ServicesTests
{
private static OtOpcUaConfigDbContext NewDb([System.Runtime.CompilerServices.CallerMemberName] string test = "")
{
var options = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
.UseInMemoryDatabase($"phase7-{test}-{Guid.NewGuid():N}")
.Options;
return new OtOpcUaConfigDbContext(options);
}
[Fact]
public async Task ScriptService_AddAsync_generates_logical_id_and_hash()
{
using var db = NewDb();
var svc = new ScriptService(db);
var s = await svc.AddAsync(5, "line-rate", "return ctx.GetTag(\"a\").Value;", default);
s.ScriptId.ShouldStartWith("scr-");
s.GenerationId.ShouldBe(5);
s.SourceHash.Length.ShouldBe(64);
(await svc.ListAsync(5, default)).Count.ShouldBe(1);
}
[Fact]
public async Task ScriptService_UpdateAsync_recomputes_hash_on_source_change()
{
using var db = NewDb();
var svc = new ScriptService(db);
var s = await svc.AddAsync(5, "s", "return 1;", default);
var hashBefore = s.SourceHash;
var updated = await svc.UpdateAsync(5, s.ScriptId, "s", "return 2;", default);
updated.SourceHash.ShouldNotBe(hashBefore);
}
[Fact]
public async Task ScriptService_UpdateAsync_same_source_same_hash()
{
using var db = NewDb();
var svc = new ScriptService(db);
var s = await svc.AddAsync(5, "s", "return 1;", default);
var updated = await svc.UpdateAsync(5, s.ScriptId, "renamed", "return 1;", default);
updated.SourceHash.ShouldBe(s.SourceHash, "source unchanged → hash unchanged → compile cache hit preserved");
}
[Fact]
public async Task ScriptService_DeleteAsync_is_idempotent()
{
using var db = NewDb();
var svc = new ScriptService(db);
await Should.NotThrowAsync(() => svc.DeleteAsync(5, "nonexistent", default));
}
[Fact]
public async Task VirtualTagService_round_trips_trigger_flags()
{
using var db = NewDb();
var svc = new VirtualTagService(db);
var v = await svc.AddAsync(7, "eq-1", "LineRate", "Float32", "scr-1",
changeTriggered: true, timerIntervalMs: 1000, historize: true, default);
v.ChangeTriggered.ShouldBeTrue();
v.TimerIntervalMs.ShouldBe(1000);
v.Historize.ShouldBeTrue();
v.Enabled.ShouldBeTrue();
(await svc.ListAsync(7, default)).Single().VirtualTagId.ShouldBe(v.VirtualTagId);
}
[Fact]
public async Task VirtualTagService_update_enabled_toggles_flag()
{
using var db = NewDb();
var svc = new VirtualTagService(db);
var v = await svc.AddAsync(7, "eq-1", "N", "Int32", "scr-1", true, null, false, default);
var disabled = await svc.UpdateEnabledAsync(7, v.VirtualTagId, false, default);
disabled.Enabled.ShouldBeFalse();
}
[Fact]
public async Task ScriptedAlarmService_defaults_HistorizeToAveva_true_per_plan_decision_15()
{
using var db = NewDb();
var svc = new ScriptedAlarmService(db);
var a = await svc.AddAsync(9, "eq-1", "HighTemp", "LimitAlarm", severity: 800,
messageTemplate: "{Temp} too high", predicateScriptId: "scr-9",
historizeToAveva: true, retain: true, default);
a.HistorizeToAveva.ShouldBeTrue();
a.Severity.ShouldBe(800);
a.ScriptedAlarmId.ShouldStartWith("sal-");
}
[Fact]
public async Task ScriptTestHarness_runs_successful_script_and_captures_writes()
{
var harness = new ScriptTestHarnessService();
var source = """
ctx.SetVirtualTag("Out", 42);
return ctx.GetTag("In").Value;
""";
var inputs = new Dictionary<string, DataValueSnapshot>
{
["In"] = new(123, 0u, DateTime.UtcNow, DateTime.UtcNow),
};
var result = await harness.RunVirtualTagAsync(source, inputs, default);
result.Outcome.ShouldBe(ScriptTestOutcome.Success);
result.Output.ShouldBe(123);
result.Writes["Out"].ShouldBe(42);
}
[Fact]
public async Task ScriptTestHarness_rejects_missing_synthetic_input()
{
var harness = new ScriptTestHarnessService();
var source = """return ctx.GetTag("A").Value;""";
var result = await harness.RunVirtualTagAsync(source, new Dictionary<string, DataValueSnapshot>(), default);
result.Outcome.ShouldBe(ScriptTestOutcome.MissingInputs);
result.Errors[0].ShouldContain("A");
}
[Fact]
public async Task ScriptTestHarness_rejects_extra_synthetic_input_not_referenced_by_script()
{
var harness = new ScriptTestHarnessService();
var source = """return 1;"""; // no GetTag calls
var inputs = new Dictionary<string, DataValueSnapshot>
{
["Unexpected"] = new(0, 0u, DateTime.UtcNow, DateTime.UtcNow),
};
var result = await harness.RunVirtualTagAsync(source, inputs, default);
result.Outcome.ShouldBe(ScriptTestOutcome.UnknownInputs);
result.Errors[0].ShouldContain("Unexpected");
}
[Fact]
public async Task ScriptTestHarness_rejects_non_literal_path()
{
var harness = new ScriptTestHarnessService();
var source = """
var p = "A";
return ctx.GetTag(p).Value;
""";
var result = await harness.RunVirtualTagAsync(source, new Dictionary<string, DataValueSnapshot>(), default);
result.Outcome.ShouldBe(ScriptTestOutcome.DependencyRejected);
result.Errors.ShouldNotBeEmpty();
}
[Fact]
public async Task ScriptTestHarness_surfaces_compile_error_as_Threw()
{
var harness = new ScriptTestHarnessService();
var source = "this is not valid C#;";
var result = await harness.RunVirtualTagAsync(source, new Dictionary<string, DataValueSnapshot>(), default);
result.Outcome.ShouldBe(ScriptTestOutcome.Threw);
}
[Fact]
public void HistorianDiagnosticsService_reports_Disabled_for_null_sink()
{
var diag = new HistorianDiagnosticsService(NullAlarmHistorianSink.Instance);
diag.GetStatus().DrainState.ShouldBe(HistorianDrainState.Disabled);
diag.TryRetryDeadLettered().ShouldBe(0);
}
}

View File

@@ -0,0 +1,184 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.Extensions.DependencyInjection;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
/// <summary>
/// Verifies the Phase 7 Stream E entities (<see cref="Script"/>, <see cref="VirtualTag"/>,
/// <see cref="ScriptedAlarm"/>, <see cref="ScriptedAlarmState"/>) register correctly in
/// the EF model, map to the expected tables/columns/indexes, and carry the check constraints
/// the plan decisions call for. Introspection only — no SQL Server required.
/// </summary>
[Trait("Category", "Unit")]
public sealed class Phase7ScriptingEntitiesTests
{
private static OtOpcUaConfigDbContext BuildCtx()
{
var options = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
.UseSqlServer("Server=(local);Database=dummy;Integrated Security=true") // not connected
.Options;
return new OtOpcUaConfigDbContext(options);
}
private static Microsoft.EntityFrameworkCore.Metadata.IModel DesignModel(OtOpcUaConfigDbContext ctx)
=> ctx.GetService<IDesignTimeModel>().Model;
[Fact]
public void Script_entity_registered_with_expected_table_and_columns()
{
using var ctx = BuildCtx();
var entity = ctx.Model.FindEntityType(typeof(Script)).ShouldNotBeNull();
entity.GetTableName().ShouldBe("Script");
entity.FindProperty(nameof(Script.ScriptRowId)).ShouldNotBeNull();
entity.FindProperty(nameof(Script.ScriptId)).ShouldNotBeNull()
.GetMaxLength().ShouldBe(64);
entity.FindProperty(nameof(Script.SourceCode)).ShouldNotBeNull()
.GetColumnType().ShouldBe("nvarchar(max)");
entity.FindProperty(nameof(Script.SourceHash)).ShouldNotBeNull()
.GetMaxLength().ShouldBe(64);
entity.FindProperty(nameof(Script.Language)).ShouldNotBeNull()
.GetMaxLength().ShouldBe(16);
}
[Fact]
public void Script_has_unique_logical_id_per_generation()
{
using var ctx = BuildCtx();
var entity = ctx.Model.FindEntityType(typeof(Script)).ShouldNotBeNull();
entity.GetIndexes().ShouldContain(
i => i.IsUnique && i.GetDatabaseName() == "UX_Script_Generation_LogicalId");
entity.GetIndexes().ShouldContain(
i => i.GetDatabaseName() == "IX_Script_Generation_SourceHash");
}
[Fact]
public void VirtualTag_entity_registered_with_trigger_check_constraint()
{
using var ctx = BuildCtx();
var entity = DesignModel(ctx).FindEntityType(typeof(VirtualTag)).ShouldNotBeNull();
entity.GetTableName().ShouldBe("VirtualTag");
var checks = entity.GetCheckConstraints().Select(c => c.Name).ToArray();
checks.ShouldContain("CK_VirtualTag_Trigger_AtLeastOne");
checks.ShouldContain("CK_VirtualTag_TimerInterval_Min");
}
[Fact]
public void VirtualTag_enforces_unique_name_per_Equipment()
{
using var ctx = BuildCtx();
var entity = ctx.Model.FindEntityType(typeof(VirtualTag)).ShouldNotBeNull();
entity.GetIndexes().ShouldContain(
i => i.IsUnique && i.GetDatabaseName() == "UX_VirtualTag_Generation_EquipmentPath");
}
[Fact]
public void VirtualTag_has_ChangeTriggered_and_Historize_flags()
{
using var ctx = BuildCtx();
var entity = ctx.Model.FindEntityType(typeof(VirtualTag)).ShouldNotBeNull();
entity.FindProperty(nameof(VirtualTag.ChangeTriggered)).ShouldNotBeNull()
.ClrType.ShouldBe(typeof(bool));
entity.FindProperty(nameof(VirtualTag.Historize)).ShouldNotBeNull()
.ClrType.ShouldBe(typeof(bool));
entity.FindProperty(nameof(VirtualTag.TimerIntervalMs)).ShouldNotBeNull()
.ClrType.ShouldBe(typeof(int?));
}
[Fact]
public void ScriptedAlarm_entity_registered_with_severity_and_type_checks()
{
using var ctx = BuildCtx();
var entity = DesignModel(ctx).FindEntityType(typeof(ScriptedAlarm)).ShouldNotBeNull();
entity.GetTableName().ShouldBe("ScriptedAlarm");
var checks = entity.GetCheckConstraints().Select(c => c.Name).ToArray();
checks.ShouldContain("CK_ScriptedAlarm_Severity_Range");
checks.ShouldContain("CK_ScriptedAlarm_AlarmType");
}
[Fact]
public void ScriptedAlarm_has_HistorizeToAveva_default_true_per_plan_decision_15()
{
// Defaults live on the CLR default assignment — verify the initializer.
var alarm = new ScriptedAlarm
{
ScriptedAlarmId = "a1",
EquipmentId = "eq1",
Name = "n",
AlarmType = "LimitAlarm",
MessageTemplate = "m",
PredicateScriptId = "s1",
};
alarm.HistorizeToAveva.ShouldBeTrue();
alarm.Retain.ShouldBeTrue();
alarm.Severity.ShouldBe(500);
alarm.Enabled.ShouldBeTrue();
}
[Fact]
public void ScriptedAlarmState_keyed_on_ScriptedAlarmId_not_generation_scoped()
{
using var ctx = BuildCtx();
var entity = ctx.Model.FindEntityType(typeof(ScriptedAlarmState)).ShouldNotBeNull();
entity.GetTableName().ShouldBe("ScriptedAlarmState");
var pk = entity.FindPrimaryKey().ShouldNotBeNull();
pk.Properties.Count.ShouldBe(1);
pk.Properties[0].Name.ShouldBe(nameof(ScriptedAlarmState.ScriptedAlarmId));
// State is NOT generation-scoped — GenerationId column should not exist per plan decision #14.
entity.FindProperty("GenerationId").ShouldBeNull(
"ack state follows alarm identity across generations");
}
[Fact]
public void ScriptedAlarmState_default_state_values_match_Part9_initial_states()
{
var state = new ScriptedAlarmState
{
ScriptedAlarmId = "a1",
EnabledState = "Enabled",
AckedState = "Unacknowledged",
ConfirmedState = "Unconfirmed",
ShelvingState = "Unshelved",
};
state.CommentsJson.ShouldBe("[]");
state.LastAckUser.ShouldBeNull();
state.LastAckUtc.ShouldBeNull();
}
[Fact]
public void ScriptedAlarmState_has_JSON_check_constraint_on_CommentsJson()
{
using var ctx = BuildCtx();
var entity = DesignModel(ctx).FindEntityType(typeof(ScriptedAlarmState)).ShouldNotBeNull();
var checks = entity.GetCheckConstraints().Select(c => c.Name).ToArray();
checks.ShouldContain("CK_ScriptedAlarmState_CommentsJson_IsJson");
}
[Fact]
public void All_new_entities_exposed_via_DbSet()
{
using var ctx = BuildCtx();
ctx.Scripts.ShouldNotBeNull();
ctx.VirtualTags.ShouldNotBeNull();
ctx.ScriptedAlarms.ShouldNotBeNull();
ctx.ScriptedAlarmStates.ShouldNotBeNull();
}
[Fact]
public void AddPhase7ScriptingTables_migration_exists_in_assembly()
{
// The migration type carries the design-time snapshot + Up/Down methods EF uses to
// apply the schema. Missing = schema won't roll forward in deployments.
var t = typeof(Migrations.AddPhase7ScriptingTables);
t.ShouldNotBeNull();
}
}

View File

@@ -0,0 +1,286 @@
using Serilog;
using Serilog.Core;
using Serilog.Events;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests;
/// <summary>
/// Verifies the durable SQLite store-and-forward queue behind the historian sink:
/// round-trip Ack, backoff ladder on RetryPlease, dead-lettering on PermanentFail,
/// capacity eviction, and retention-based dead-letter purge.
/// </summary>
[Trait("Category", "Unit")]
public sealed class SqliteStoreAndForwardSinkTests : IDisposable
{
private readonly string _dbPath;
private readonly ILogger _log;
public SqliteStoreAndForwardSinkTests()
{
_dbPath = Path.Combine(Path.GetTempPath(), $"otopcua-historian-{Guid.NewGuid():N}.sqlite");
_log = new LoggerConfiguration().MinimumLevel.Verbose().CreateLogger();
}
public void Dispose()
{
try { if (File.Exists(_dbPath)) File.Delete(_dbPath); } catch { }
}
private sealed class FakeWriter : IAlarmHistorianWriter
{
public Queue<HistorianWriteOutcome> NextOutcomePerEvent { get; } = new();
public HistorianWriteOutcome DefaultOutcome { get; set; } = HistorianWriteOutcome.Ack;
public List<IReadOnlyList<AlarmHistorianEvent>> Batches { get; } = [];
public Exception? ThrowOnce { get; set; }
public Task<IReadOnlyList<HistorianWriteOutcome>> WriteBatchAsync(
IReadOnlyList<AlarmHistorianEvent> batch, CancellationToken ct)
{
if (ThrowOnce is not null)
{
var e = ThrowOnce;
ThrowOnce = null;
throw e;
}
Batches.Add(batch);
var outcomes = new List<HistorianWriteOutcome>();
for (var i = 0; i < batch.Count; i++)
outcomes.Add(NextOutcomePerEvent.Count > 0 ? NextOutcomePerEvent.Dequeue() : DefaultOutcome);
return Task.FromResult<IReadOnlyList<HistorianWriteOutcome>>(outcomes);
}
}
private static AlarmHistorianEvent Event(string alarmId, DateTime? ts = null) => new(
AlarmId: alarmId,
EquipmentPath: "/Site/Line1/Cell",
AlarmName: "HighTemp",
AlarmTypeName: "LimitAlarm",
Severity: AlarmSeverity.High,
EventKind: "Activated",
Message: "temp exceeded",
User: "system",
Comment: null,
TimestampUtc: ts ?? DateTime.UtcNow);
[Fact]
public async Task EnqueueThenDrain_Ack_removes_row()
{
var writer = new FakeWriter();
using var sink = new SqliteStoreAndForwardSink(_dbPath, writer, _log);
await sink.EnqueueAsync(Event("A1"), CancellationToken.None);
sink.GetStatus().QueueDepth.ShouldBe(1);
await sink.DrainOnceAsync(CancellationToken.None);
writer.Batches.Count.ShouldBe(1);
writer.Batches[0].Count.ShouldBe(1);
writer.Batches[0][0].AlarmId.ShouldBe("A1");
var status = sink.GetStatus();
status.QueueDepth.ShouldBe(0);
status.DeadLetterDepth.ShouldBe(0);
status.LastSuccessUtc.ShouldNotBeNull();
}
[Fact]
public async Task Drain_with_empty_queue_is_noop()
{
var writer = new FakeWriter();
using var sink = new SqliteStoreAndForwardSink(_dbPath, writer, _log);
await sink.DrainOnceAsync(CancellationToken.None);
writer.Batches.ShouldBeEmpty();
sink.GetStatus().DrainState.ShouldBe(HistorianDrainState.Idle);
}
[Fact]
public async Task RetryPlease_bumps_backoff_and_keeps_row()
{
var writer = new FakeWriter();
writer.NextOutcomePerEvent.Enqueue(HistorianWriteOutcome.RetryPlease);
using var sink = new SqliteStoreAndForwardSink(_dbPath, writer, _log);
await sink.EnqueueAsync(Event("A1"), CancellationToken.None);
var before = sink.CurrentBackoff;
await sink.DrainOnceAsync(CancellationToken.None);
sink.CurrentBackoff.ShouldBeGreaterThan(before);
sink.GetStatus().QueueDepth.ShouldBe(1, "row stays in queue for retry");
sink.GetStatus().DrainState.ShouldBe(HistorianDrainState.BackingOff);
}
[Fact]
public async Task Ack_after_Retry_resets_backoff()
{
var writer = new FakeWriter();
writer.NextOutcomePerEvent.Enqueue(HistorianWriteOutcome.RetryPlease);
using var sink = new SqliteStoreAndForwardSink(_dbPath, writer, _log);
await sink.EnqueueAsync(Event("A1"), CancellationToken.None);
await sink.DrainOnceAsync(CancellationToken.None);
sink.CurrentBackoff.ShouldBeGreaterThan(TimeSpan.FromSeconds(1) - TimeSpan.FromMilliseconds(1));
writer.NextOutcomePerEvent.Enqueue(HistorianWriteOutcome.Ack);
await sink.DrainOnceAsync(CancellationToken.None);
sink.CurrentBackoff.ShouldBe(TimeSpan.FromSeconds(1));
sink.GetStatus().QueueDepth.ShouldBe(0);
}
[Fact]
public async Task PermanentFail_dead_letters_one_row_only()
{
var writer = new FakeWriter();
writer.NextOutcomePerEvent.Enqueue(HistorianWriteOutcome.PermanentFail);
writer.NextOutcomePerEvent.Enqueue(HistorianWriteOutcome.Ack);
using var sink = new SqliteStoreAndForwardSink(_dbPath, writer, _log);
await sink.EnqueueAsync(Event("bad"), CancellationToken.None);
await sink.EnqueueAsync(Event("good"), CancellationToken.None);
await sink.DrainOnceAsync(CancellationToken.None);
var status = sink.GetStatus();
status.QueueDepth.ShouldBe(0, "good row acked");
status.DeadLetterDepth.ShouldBe(1, "bad row dead-lettered");
}
[Fact]
public async Task Writer_exception_treated_as_retry_for_whole_batch()
{
var writer = new FakeWriter { ThrowOnce = new InvalidOperationException("pipe broken") };
using var sink = new SqliteStoreAndForwardSink(_dbPath, writer, _log);
await sink.EnqueueAsync(Event("A1"), CancellationToken.None);
await sink.DrainOnceAsync(CancellationToken.None);
var status = sink.GetStatus();
status.QueueDepth.ShouldBe(1);
status.LastError.ShouldBe("pipe broken");
status.DrainState.ShouldBe(HistorianDrainState.BackingOff);
// Next drain after the writer recovers should Ack.
await sink.DrainOnceAsync(CancellationToken.None);
sink.GetStatus().QueueDepth.ShouldBe(0);
}
[Fact]
public async Task Capacity_eviction_drops_oldest_nondeadlettered_row()
{
var writer = new FakeWriter();
using var sink = new SqliteStoreAndForwardSink(
_dbPath, writer, _log, batchSize: 100, capacity: 3);
await sink.EnqueueAsync(Event("A1"), CancellationToken.None);
await sink.EnqueueAsync(Event("A2"), CancellationToken.None);
await sink.EnqueueAsync(Event("A3"), CancellationToken.None);
// A4 enqueue must evict the oldest (A1).
await sink.EnqueueAsync(Event("A4"), CancellationToken.None);
sink.GetStatus().QueueDepth.ShouldBe(3);
await sink.DrainOnceAsync(CancellationToken.None);
var drained = writer.Batches[0].Select(e => e.AlarmId).ToArray();
drained.ShouldNotContain("A1");
drained.ShouldContain("A2");
drained.ShouldContain("A3");
drained.ShouldContain("A4");
}
[Fact]
public async Task Deadlettered_rows_are_purged_past_retention()
{
var now = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
DateTime clock = now;
var writer = new FakeWriter();
writer.NextOutcomePerEvent.Enqueue(HistorianWriteOutcome.PermanentFail);
using var sink = new SqliteStoreAndForwardSink(
_dbPath, writer, _log, deadLetterRetention: TimeSpan.FromDays(30),
clock: () => clock);
await sink.EnqueueAsync(Event("bad"), CancellationToken.None);
await sink.DrainOnceAsync(CancellationToken.None);
sink.GetStatus().DeadLetterDepth.ShouldBe(1);
// Advance past retention + tick drain (which runs PurgeAgedDeadLetters).
clock = now.AddDays(31);
await sink.DrainOnceAsync(CancellationToken.None);
sink.GetStatus().DeadLetterDepth.ShouldBe(0, "purged past retention");
}
[Fact]
public async Task RetryDeadLettered_requeues_for_retry()
{
var writer = new FakeWriter();
writer.NextOutcomePerEvent.Enqueue(HistorianWriteOutcome.PermanentFail);
using var sink = new SqliteStoreAndForwardSink(_dbPath, writer, _log);
await sink.EnqueueAsync(Event("bad"), CancellationToken.None);
await sink.DrainOnceAsync(CancellationToken.None);
sink.GetStatus().DeadLetterDepth.ShouldBe(1);
var revived = sink.RetryDeadLettered();
revived.ShouldBe(1);
var status = sink.GetStatus();
status.QueueDepth.ShouldBe(1);
status.DeadLetterDepth.ShouldBe(0);
}
[Fact]
public async Task Backoff_ladder_caps_at_60s()
{
var writer = new FakeWriter { DefaultOutcome = HistorianWriteOutcome.RetryPlease };
using var sink = new SqliteStoreAndForwardSink(_dbPath, writer, _log);
await sink.EnqueueAsync(Event("A1"), CancellationToken.None);
// 10 retry rounds — ladder should cap at 60s.
for (var i = 0; i < 10; i++)
await sink.DrainOnceAsync(CancellationToken.None);
sink.CurrentBackoff.ShouldBe(TimeSpan.FromSeconds(60));
}
[Fact]
public void NullAlarmHistorianSink_reports_disabled_status()
{
var s = NullAlarmHistorianSink.Instance.GetStatus();
s.DrainState.ShouldBe(HistorianDrainState.Disabled);
s.QueueDepth.ShouldBe(0);
}
[Fact]
public async Task NullAlarmHistorianSink_swallows_enqueue()
{
// Should not throw or persist anything.
await NullAlarmHistorianSink.Instance.EnqueueAsync(Event("A1"), CancellationToken.None);
}
[Fact]
public void Ctor_rejects_bad_args()
{
var w = new FakeWriter();
Should.Throw<ArgumentException>(() => new SqliteStoreAndForwardSink("", w, _log));
Should.Throw<ArgumentNullException>(() => new SqliteStoreAndForwardSink(_dbPath, null!, _log));
Should.Throw<ArgumentNullException>(() => new SqliteStoreAndForwardSink(_dbPath, w, null!));
Should.Throw<ArgumentOutOfRangeException>(() => new SqliteStoreAndForwardSink(_dbPath, w, _log, batchSize: 0));
Should.Throw<ArgumentOutOfRangeException>(() => new SqliteStoreAndForwardSink(_dbPath, w, _log, capacity: 0));
}
[Fact]
public async Task Disposed_sink_rejects_enqueue()
{
var writer = new FakeWriter();
var sink = new SqliteStoreAndForwardSink(_dbPath, writer, _log);
sink.Dispose();
await Should.ThrowAsync<ObjectDisposedException>(
() => sink.EnqueueAsync(Event("A1"), CancellationToken.None));
}
}

View File

@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3" Version="1.1.0"/>
<PackageReference Include="Shouldly" Version="4.3.0"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.csproj"/>
</ItemGroup>
<ItemGroup>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,61 @@
using System.Collections.Concurrent;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests;
public sealed class FakeUpstream : ITagUpstreamSource
{
private readonly ConcurrentDictionary<string, DataValueSnapshot> _values = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, List<Action<string, DataValueSnapshot>>> _subs
= new(StringComparer.Ordinal);
public int ActiveSubscriptionCount { get; private set; }
public void Set(string path, object? value, uint statusCode = 0u)
{
var now = DateTime.UtcNow;
_values[path] = new DataValueSnapshot(value, statusCode, now, now);
}
public void Push(string path, object? value, uint statusCode = 0u)
{
Set(path, value, statusCode);
if (_subs.TryGetValue(path, out var list))
{
Action<string, DataValueSnapshot>[] snap;
lock (list) { snap = list.ToArray(); }
foreach (var obs in snap) obs(path, _values[path]);
}
}
public DataValueSnapshot ReadTag(string path)
=> _values.TryGetValue(path, out var v) ? v
: new DataValueSnapshot(null, 0x80340000u, null, DateTime.UtcNow);
public IDisposable SubscribeTag(string path, Action<string, DataValueSnapshot> observer)
{
var list = _subs.GetOrAdd(path, _ => []);
lock (list) { list.Add(observer); }
ActiveSubscriptionCount++;
return new Unsub(this, path, observer);
}
private sealed class Unsub : IDisposable
{
private readonly FakeUpstream _up;
private readonly string _path;
private readonly Action<string, DataValueSnapshot> _observer;
public Unsub(FakeUpstream up, string path, Action<string, DataValueSnapshot> observer)
{ _up = up; _path = path; _observer = observer; }
public void Dispose()
{
if (_up._subs.TryGetValue(_path, out var list))
{
lock (list)
{
if (list.Remove(_observer)) _up.ActiveSubscriptionCount--;
}
}
}
}
}

View File

@@ -0,0 +1,107 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests;
[Trait("Category", "Unit")]
public sealed class MessageTemplateTests
{
private static DataValueSnapshot Good(object? v) =>
new(v, 0u, DateTime.UtcNow, DateTime.UtcNow);
private static DataValueSnapshot Bad() =>
new(null, 0x80050000u, null, DateTime.UtcNow);
private static DataValueSnapshot? Resolver(Dictionary<string, DataValueSnapshot> map, string path)
=> map.TryGetValue(path, out var v) ? v : null;
[Fact]
public void No_tokens_returns_template_unchanged()
{
MessageTemplate.Resolve("No tokens here", _ => null).ShouldBe("No tokens here");
}
[Fact]
public void Single_token_substituted()
{
var map = new Dictionary<string, DataValueSnapshot> { ["Tank/Temp"] = Good(75.5) };
MessageTemplate.Resolve("Temp={Tank/Temp}C", p => Resolver(map, p)).ShouldBe("Temp=75.5C");
}
[Fact]
public void Multiple_tokens_substituted()
{
var map = new Dictionary<string, DataValueSnapshot>
{
["A"] = Good(10),
["B"] = Good("on"),
};
MessageTemplate.Resolve("{A}/{B}", p => Resolver(map, p)).ShouldBe("10/on");
}
[Fact]
public void Bad_quality_token_becomes_question_mark()
{
var map = new Dictionary<string, DataValueSnapshot> { ["Bad"] = Bad() };
MessageTemplate.Resolve("value={Bad}", p => Resolver(map, p)).ShouldBe("value={?}");
}
[Fact]
public void Unknown_path_becomes_question_mark()
{
MessageTemplate.Resolve("value={DoesNotExist}", _ => null).ShouldBe("value={?}");
}
[Fact]
public void Null_value_with_good_quality_becomes_question_mark()
{
var map = new Dictionary<string, DataValueSnapshot> { ["X"] = Good(null) };
MessageTemplate.Resolve("{X}", p => Resolver(map, p)).ShouldBe("{?}");
}
[Fact]
public void Tokens_with_slashes_and_dots_resolved()
{
var map = new Dictionary<string, DataValueSnapshot>
{
["Line1/Pump.Speed"] = Good(1200),
};
MessageTemplate.Resolve("rpm={Line1/Pump.Speed}", p => Resolver(map, p))
.ShouldBe("rpm=1200");
}
[Fact]
public void Empty_template_returns_empty()
{
MessageTemplate.Resolve("", _ => null).ShouldBe("");
}
[Fact]
public void Null_template_returns_empty_without_throwing()
{
MessageTemplate.Resolve(null!, _ => null).ShouldBe("");
}
[Fact]
public void ExtractTokenPaths_returns_every_distinct_token()
{
var tokens = MessageTemplate.ExtractTokenPaths("{A}/{B}/{A}/{C}");
tokens.ShouldBe(new[] { "A", "B", "A", "C" });
}
[Fact]
public void ExtractTokenPaths_empty_for_tokenless_template()
{
MessageTemplate.ExtractTokenPaths("No tokens").ShouldBeEmpty();
MessageTemplate.ExtractTokenPaths("").ShouldBeEmpty();
MessageTemplate.ExtractTokenPaths(null).ShouldBeEmpty();
}
[Fact]
public void Whitespace_inside_token_is_trimmed()
{
var map = new Dictionary<string, DataValueSnapshot> { ["A"] = Good(42) };
MessageTemplate.Resolve("{ A }", p => Resolver(map, p)).ShouldBe("42");
}
}

View File

@@ -0,0 +1,205 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests;
/// <summary>
/// Pure state-machine tests — no engine, no I/O, no async. Every transition rule
/// from Phase 7 plan Stream C.2 / C.3 has at least one locking test so regressions
/// surface as clear failures rather than subtle alarm-behavior drift.
/// </summary>
[Trait("Category", "Unit")]
public sealed class Part9StateMachineTests
{
private static readonly DateTime T0 = new(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc);
private static AlarmConditionState Fresh() => AlarmConditionState.Fresh("alarm-1", T0);
[Fact]
public void Predicate_true_on_inactive_becomes_active_and_emits_Activated()
{
var r = Part9StateMachine.ApplyPredicate(Fresh(), predicateTrue: true, T0.AddSeconds(1));
r.State.Active.ShouldBe(AlarmActiveState.Active);
r.State.Acked.ShouldBe(AlarmAckedState.Unacknowledged);
r.State.Confirmed.ShouldBe(AlarmConfirmedState.Unconfirmed);
r.Emission.ShouldBe(EmissionKind.Activated);
r.State.LastActiveUtc.ShouldNotBeNull();
}
[Fact]
public void Predicate_false_on_active_becomes_inactive_and_emits_Cleared()
{
var active = Part9StateMachine.ApplyPredicate(Fresh(), true, T0.AddSeconds(1)).State;
var r = Part9StateMachine.ApplyPredicate(active, false, T0.AddSeconds(2));
r.State.Active.ShouldBe(AlarmActiveState.Inactive);
r.Emission.ShouldBe(EmissionKind.Cleared);
r.State.LastClearedUtc.ShouldNotBeNull();
}
[Fact]
public void Predicate_unchanged_state_emits_None()
{
var r = Part9StateMachine.ApplyPredicate(Fresh(), false, T0);
r.Emission.ShouldBe(EmissionKind.None);
}
[Fact]
public void Disabled_alarm_ignores_predicate()
{
var disabled = Part9StateMachine.ApplyDisable(Fresh(), "op1", T0.AddSeconds(1)).State;
var r = Part9StateMachine.ApplyPredicate(disabled, true, T0.AddSeconds(2));
r.State.Active.ShouldBe(AlarmActiveState.Inactive);
r.Emission.ShouldBe(EmissionKind.None);
}
[Fact]
public void Acknowledge_from_unacked_records_user_and_emits()
{
var active = Part9StateMachine.ApplyPredicate(Fresh(), true, T0.AddSeconds(1)).State;
var r = Part9StateMachine.ApplyAcknowledge(active, "alice", "looking into it", T0.AddSeconds(2));
r.State.Acked.ShouldBe(AlarmAckedState.Acknowledged);
r.State.LastAckUser.ShouldBe("alice");
r.State.LastAckComment.ShouldBe("looking into it");
r.State.Comments.Count.ShouldBe(1);
r.Emission.ShouldBe(EmissionKind.Acknowledged);
}
[Fact]
public void Acknowledge_when_already_acked_is_noop()
{
var active = Part9StateMachine.ApplyPredicate(Fresh(), true, T0.AddSeconds(1)).State;
var acked = Part9StateMachine.ApplyAcknowledge(active, "alice", null, T0.AddSeconds(2)).State;
var r = Part9StateMachine.ApplyAcknowledge(acked, "alice", null, T0.AddSeconds(3));
r.Emission.ShouldBe(EmissionKind.None);
}
[Fact]
public void Acknowledge_without_user_throws()
{
Should.Throw<ArgumentException>(() =>
Part9StateMachine.ApplyAcknowledge(Fresh(), "", null, T0));
}
[Fact]
public void Confirm_after_clear_records_user_and_emits()
{
// Walk: activate -> ack -> clear -> confirm
var s = Fresh();
s = Part9StateMachine.ApplyPredicate(s, true, T0.AddSeconds(1)).State;
s = Part9StateMachine.ApplyAcknowledge(s, "alice", null, T0.AddSeconds(2)).State;
s = Part9StateMachine.ApplyPredicate(s, false, T0.AddSeconds(3)).State;
var r = Part9StateMachine.ApplyConfirm(s, "bob", "resolved", T0.AddSeconds(4));
r.State.Confirmed.ShouldBe(AlarmConfirmedState.Confirmed);
r.State.LastConfirmUser.ShouldBe("bob");
r.Emission.ShouldBe(EmissionKind.Confirmed);
}
[Fact]
public void OneShotShelve_suppresses_next_activation_emission()
{
var s = Part9StateMachine.ApplyOneShotShelve(Fresh(), "alice", T0.AddSeconds(1)).State;
var r = Part9StateMachine.ApplyPredicate(s, true, T0.AddSeconds(2));
r.State.Active.ShouldBe(AlarmActiveState.Active, "state still advances");
r.Emission.ShouldBe(EmissionKind.Suppressed, "but subscribers don't see it");
}
[Fact]
public void OneShotShelve_expires_on_clear()
{
var s = Fresh();
s = Part9StateMachine.ApplyOneShotShelve(s, "alice", T0.AddSeconds(1)).State;
s = Part9StateMachine.ApplyPredicate(s, true, T0.AddSeconds(2)).State;
var r = Part9StateMachine.ApplyPredicate(s, false, T0.AddSeconds(3));
r.State.Shelving.Kind.ShouldBe(ShelvingKind.Unshelved, "OneShot expires on clear");
}
[Fact]
public void TimedShelve_requires_future_unshelve_time()
{
Should.Throw<ArgumentOutOfRangeException>(() =>
Part9StateMachine.ApplyTimedShelve(Fresh(), "alice", T0, T0.AddSeconds(5)));
}
[Fact]
public void TimedShelve_expires_via_shelving_check()
{
var until = T0.AddMinutes(5);
var shelved = Part9StateMachine.ApplyTimedShelve(Fresh(), "alice", until, T0).State;
shelved.Shelving.Kind.ShouldBe(ShelvingKind.Timed);
// Before expiry — still shelved.
var earlier = Part9StateMachine.ApplyShelvingCheck(shelved, T0.AddMinutes(3));
earlier.State.Shelving.Kind.ShouldBe(ShelvingKind.Timed);
earlier.Emission.ShouldBe(EmissionKind.None);
// After expiry — auto-unshelved + emission.
var after = Part9StateMachine.ApplyShelvingCheck(shelved, T0.AddMinutes(6));
after.State.Shelving.Kind.ShouldBe(ShelvingKind.Unshelved);
after.Emission.ShouldBe(EmissionKind.Unshelved);
after.State.Comments.Any(c => c.Kind == "AutoUnshelve").ShouldBeTrue();
}
[Fact]
public void Unshelve_from_unshelved_is_noop()
{
var r = Part9StateMachine.ApplyUnshelve(Fresh(), "alice", T0);
r.Emission.ShouldBe(EmissionKind.None);
}
[Fact]
public void Explicit_Unshelve_emits_event()
{
var s = Part9StateMachine.ApplyOneShotShelve(Fresh(), "alice", T0).State;
var r = Part9StateMachine.ApplyUnshelve(s, "bob", T0.AddSeconds(30));
r.State.Shelving.Kind.ShouldBe(ShelvingKind.Unshelved);
r.Emission.ShouldBe(EmissionKind.Unshelved);
}
[Fact]
public void AddComment_appends_to_audit_trail_with_event()
{
var r = Part9StateMachine.ApplyAddComment(Fresh(), "alice", "investigating", T0.AddSeconds(5));
r.State.Comments.Count.ShouldBe(1);
r.State.Comments[0].Kind.ShouldBe("AddComment");
r.State.Comments[0].User.ShouldBe("alice");
r.State.Comments[0].Text.ShouldBe("investigating");
r.Emission.ShouldBe(EmissionKind.CommentAdded);
}
[Fact]
public void Comments_are_append_only_never_rewritten()
{
var s = Part9StateMachine.ApplyAddComment(Fresh(), "alice", "first", T0.AddSeconds(1)).State;
s = Part9StateMachine.ApplyAddComment(s, "bob", "second", T0.AddSeconds(2)).State;
s = Part9StateMachine.ApplyAddComment(s, "carol", "third", T0.AddSeconds(3)).State;
s.Comments.Count.ShouldBe(3);
s.Comments[0].User.ShouldBe("alice");
s.Comments[1].User.ShouldBe("bob");
s.Comments[2].User.ShouldBe("carol");
}
[Fact]
public void Full_lifecycle_walk_produces_every_expected_emission()
{
// Walk a condition through its whole lifecycle and make sure emissions line up.
var emissions = new List<EmissionKind>();
var s = Fresh();
s = Capture(Part9StateMachine.ApplyPredicate(s, true, T0.AddSeconds(1)));
s = Capture(Part9StateMachine.ApplyAcknowledge(s, "alice", null, T0.AddSeconds(2)));
s = Capture(Part9StateMachine.ApplyAddComment(s, "alice", "need to investigate", T0.AddSeconds(3)));
s = Capture(Part9StateMachine.ApplyPredicate(s, false, T0.AddSeconds(4)));
s = Capture(Part9StateMachine.ApplyConfirm(s, "bob", null, T0.AddSeconds(5)));
emissions.ShouldBe(new[] {
EmissionKind.Activated,
EmissionKind.Acknowledged,
EmissionKind.CommentAdded,
EmissionKind.Cleared,
EmissionKind.Confirmed,
});
AlarmConditionState Capture(TransitionResult r) { emissions.Add(r.Emission); return r.State; }
}
}

View File

@@ -0,0 +1,316 @@
using Serilog;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests;
/// <summary>
/// End-to-end engine tests: load, predicate evaluation, change-triggered
/// re-evaluation, state persistence, startup recovery, error isolation.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ScriptedAlarmEngineTests
{
private static ScriptedAlarmEngine Build(FakeUpstream up, out IAlarmStateStore store)
{
store = new InMemoryAlarmStateStore();
var logger = new LoggerConfiguration().CreateLogger();
return new ScriptedAlarmEngine(up, store, new ScriptLoggerFactory(logger), logger);
}
private static ScriptedAlarmDefinition Alarm(string id, string predicate,
string msg = "condition", AlarmSeverity sev = AlarmSeverity.High) =>
new(AlarmId: id,
EquipmentPath: "Plant/Line1/Reactor",
AlarmName: id,
Kind: AlarmKind.AlarmCondition,
Severity: sev,
MessageTemplate: msg,
PredicateScriptSource: predicate);
[Fact]
public async Task Load_compiles_and_subscribes_to_referenced_upstreams()
{
var up = new FakeUpstream();
up.Set("Temp", 50);
using var eng = Build(up, out _);
await eng.LoadAsync([Alarm("a1", """return (int)ctx.GetTag("Temp").Value > 100;""")],
TestContext.Current.CancellationToken);
eng.LoadedAlarmIds.ShouldContain("a1");
up.ActiveSubscriptionCount.ShouldBe(1);
}
[Fact]
public async Task Compile_failures_aggregated_into_one_error()
{
var up = new FakeUpstream();
using var eng = Build(up, out _);
var ex = await Should.ThrowAsync<InvalidOperationException>(async () =>
await eng.LoadAsync([
Alarm("bad1", "return unknownIdentifier;"),
Alarm("good", "return true;"),
Alarm("bad2", "var x = alsoUnknown; return x;"),
], TestContext.Current.CancellationToken));
ex.Message.ShouldContain("2 alarm(s) did not compile");
}
[Fact]
public async Task Upstream_change_re_evaluates_predicate_and_emits_Activated()
{
var up = new FakeUpstream();
up.Set("Temp", 50);
using var eng = Build(up, out _);
await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
TestContext.Current.CancellationToken);
var events = new List<ScriptedAlarmEvent>();
eng.OnEvent += (_, e) => events.Add(e);
up.Push("Temp", 150);
await WaitForAsync(() => events.Count > 0);
events[0].AlarmId.ShouldBe("HighTemp");
events[0].Emission.ShouldBe(EmissionKind.Activated);
eng.GetState("HighTemp")!.Active.ShouldBe(AlarmActiveState.Active);
}
[Fact]
public async Task Clearing_upstream_emits_Cleared_event()
{
var up = new FakeUpstream();
up.Set("Temp", 150);
using var eng = Build(up, out _);
await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
TestContext.Current.CancellationToken);
// Startup sees 150 → active.
eng.GetState("HighTemp")!.Active.ShouldBe(AlarmActiveState.Active);
var events = new List<ScriptedAlarmEvent>();
eng.OnEvent += (_, e) => events.Add(e);
up.Push("Temp", 50);
await WaitForAsync(() => events.Any(e => e.Emission == EmissionKind.Cleared));
eng.GetState("HighTemp")!.Active.ShouldBe(AlarmActiveState.Inactive);
}
[Fact]
public async Task Message_template_resolves_tag_values_at_emission()
{
var up = new FakeUpstream();
up.Set("Temp", 50);
up.Set("Limit", 100);
using var eng = Build(up, out _);
await eng.LoadAsync([
new ScriptedAlarmDefinition(
"HighTemp", "Plant/Line1", "HighTemp",
AlarmKind.LimitAlarm, AlarmSeverity.High,
"Temp {Temp}C exceeded limit {Limit}C",
"""return (int)ctx.GetTag("Temp").Value > (int)ctx.GetTag("Limit").Value;"""),
], TestContext.Current.CancellationToken);
var events = new List<ScriptedAlarmEvent>();
eng.OnEvent += (_, e) => events.Add(e);
up.Push("Temp", 150);
await WaitForAsync(() => events.Any());
events[0].Message.ShouldBe("Temp 150C exceeded limit 100C");
}
[Fact]
public async Task Ack_records_user_and_persists_to_store()
{
var up = new FakeUpstream();
up.Set("Temp", 150);
using var eng = Build(up, out var store);
await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
TestContext.Current.CancellationToken);
await eng.AcknowledgeAsync("HighTemp", "alice", "checking", TestContext.Current.CancellationToken);
var persisted = await store.LoadAsync("HighTemp", TestContext.Current.CancellationToken);
persisted.ShouldNotBeNull();
persisted!.Acked.ShouldBe(AlarmAckedState.Acknowledged);
persisted.LastAckUser.ShouldBe("alice");
persisted.LastAckComment.ShouldBe("checking");
persisted.Comments.Any(c => c.Kind == "Acknowledge" && c.User == "alice").ShouldBeTrue();
}
[Fact]
public async Task Startup_recovery_preserves_ack_but_rederives_active_from_predicate()
{
var up = new FakeUpstream();
up.Set("Temp", 50); // predicate will go false on second load
// First run — alarm goes active + operator acks.
using (var eng1 = Build(up, out var sharedStore))
{
up.Set("Temp", 150);
await eng1.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
TestContext.Current.CancellationToken);
eng1.GetState("HighTemp")!.Active.ShouldBe(AlarmActiveState.Active);
await eng1.AcknowledgeAsync("HighTemp", "alice", null, TestContext.Current.CancellationToken);
eng1.GetState("HighTemp")!.Acked.ShouldBe(AlarmAckedState.Acknowledged);
}
// Simulate restart — temp is back to 50 (below threshold).
up.Set("Temp", 50);
var logger = new LoggerConfiguration().CreateLogger();
var store2 = new InMemoryAlarmStateStore();
// seed store2 with the acked state from before restart
await store2.SaveAsync(new AlarmConditionState(
"HighTemp",
AlarmEnabledState.Enabled,
AlarmActiveState.Active, // was active pre-restart
AlarmAckedState.Acknowledged, // ack persisted
AlarmConfirmedState.Unconfirmed,
ShelvingState.Unshelved,
DateTime.UtcNow,
DateTime.UtcNow, null,
DateTime.UtcNow, "alice", null,
null, null, null,
[new AlarmComment(DateTime.UtcNow, "alice", "Acknowledge", "")]),
TestContext.Current.CancellationToken);
using var eng2 = new ScriptedAlarmEngine(up, store2, new ScriptLoggerFactory(logger), logger);
await eng2.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
TestContext.Current.CancellationToken);
var s = eng2.GetState("HighTemp")!;
s.Active.ShouldBe(AlarmActiveState.Inactive, "Active recomputed from current tag value");
s.Acked.ShouldBe(AlarmAckedState.Acknowledged, "Ack persisted across restart");
s.LastAckUser.ShouldBe("alice");
}
[Fact]
public async Task Shelved_active_transitions_state_but_suppresses_emission()
{
var up = new FakeUpstream();
up.Set("Temp", 50);
using var eng = Build(up, out _);
await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
TestContext.Current.CancellationToken);
await eng.OneShotShelveAsync("HighTemp", "alice", TestContext.Current.CancellationToken);
var events = new List<ScriptedAlarmEvent>();
eng.OnEvent += (_, e) => events.Add(e);
up.Push("Temp", 150);
await Task.Delay(200);
events.Any(e => e.Emission == EmissionKind.Activated).ShouldBeFalse(
"OneShot shelve suppresses activation emission");
eng.GetState("HighTemp")!.Active.ShouldBe(AlarmActiveState.Active,
"state still advances so startup recovery is consistent");
}
[Fact]
public async Task Predicate_runtime_exception_does_not_transition_state()
{
var up = new FakeUpstream();
up.Set("Temp", 150);
using var eng = Build(up, out _);
await eng.LoadAsync([
Alarm("BadScript", """throw new InvalidOperationException("boom");"""),
Alarm("GoodScript", """return (int)ctx.GetTag("Temp").Value > 100;"""),
], TestContext.Current.CancellationToken);
// Bad script doesn't activate + doesn't disable other alarms.
eng.GetState("BadScript")!.Active.ShouldBe(AlarmActiveState.Inactive);
eng.GetState("GoodScript")!.Active.ShouldBe(AlarmActiveState.Active);
}
[Fact]
public async Task Disable_prevents_activation_until_re_enabled()
{
var up = new FakeUpstream();
up.Set("Temp", 50);
using var eng = Build(up, out _);
await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
TestContext.Current.CancellationToken);
await eng.DisableAsync("HighTemp", "alice", TestContext.Current.CancellationToken);
up.Push("Temp", 150);
await Task.Delay(100);
eng.GetState("HighTemp")!.Active.ShouldBe(AlarmActiveState.Inactive,
"disabled alarm ignores predicate");
await eng.EnableAsync("HighTemp", "alice", TestContext.Current.CancellationToken);
up.Push("Temp", 160);
await WaitForAsync(() => eng.GetState("HighTemp")!.Active == AlarmActiveState.Active);
}
[Fact]
public async Task AddComment_appends_to_audit_without_state_change()
{
var up = new FakeUpstream();
up.Set("Temp", 50);
using var eng = Build(up, out var store);
await eng.LoadAsync([Alarm("A", """return false;""")], TestContext.Current.CancellationToken);
await eng.AddCommentAsync("A", "alice", "peeking at this", TestContext.Current.CancellationToken);
var s = await store.LoadAsync("A", TestContext.Current.CancellationToken);
s.ShouldNotBeNull();
s!.Comments.Count.ShouldBe(1);
s.Comments[0].User.ShouldBe("alice");
s.Comments[0].Kind.ShouldBe("AddComment");
}
[Fact]
public async Task Predicate_scripts_cannot_SetVirtualTag()
{
var up = new FakeUpstream();
up.Set("Temp", 100);
using var eng = Build(up, out _);
// The script compiles fine but throws at runtime when SetVirtualTag is called.
// The engine swallows the exception + leaves state unchanged.
await eng.LoadAsync([
new ScriptedAlarmDefinition(
"Bad", "Plant/Line1", "Bad",
AlarmKind.AlarmCondition, AlarmSeverity.High, "bad",
"""
ctx.SetVirtualTag("NotAllowed", 1);
return true;
"""),
], TestContext.Current.CancellationToken);
// Bad alarm's predicate threw — state unchanged.
eng.GetState("Bad")!.Active.ShouldBe(AlarmActiveState.Inactive);
}
[Fact]
public async Task Dispose_releases_upstream_subscriptions()
{
var up = new FakeUpstream();
up.Set("Temp", 50);
var eng = Build(up, out _);
await eng.LoadAsync([Alarm("A", """return (int)ctx.GetTag("Temp").Value > 100;""")],
TestContext.Current.CancellationToken);
up.ActiveSubscriptionCount.ShouldBe(1);
eng.Dispose();
up.ActiveSubscriptionCount.ShouldBe(0);
}
private static async Task WaitForAsync(Func<bool> cond, int timeoutMs = 2000)
{
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
while (DateTime.UtcNow < deadline)
{
if (cond()) return;
await Task.Delay(25);
}
throw new TimeoutException("Condition did not become true in time");
}
}

View File

@@ -0,0 +1,142 @@
using Serilog;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests;
[Trait("Category", "Unit")]
public sealed class ScriptedAlarmSourceTests
{
private static async Task<(ScriptedAlarmEngine e, ScriptedAlarmSource s, FakeUpstream u)> BuildAsync()
{
var up = new FakeUpstream();
up.Set("Temp", 50);
var logger = new LoggerConfiguration().CreateLogger();
var engine = new ScriptedAlarmEngine(up, new InMemoryAlarmStateStore(),
new ScriptLoggerFactory(logger), logger);
await engine.LoadAsync([
new ScriptedAlarmDefinition(
"Plant/Line1::HighTemp",
"Plant/Line1",
"HighTemp",
AlarmKind.LimitAlarm,
AlarmSeverity.High,
"Temp {Temp}C",
"""return (int)ctx.GetTag("Temp").Value > 100;"""),
new ScriptedAlarmDefinition(
"Plant/Line2::OtherAlarm",
"Plant/Line2",
"OtherAlarm",
AlarmKind.AlarmCondition,
AlarmSeverity.Low,
"other",
"""return false;"""),
], CancellationToken.None);
var source = new ScriptedAlarmSource(engine);
return (engine, source, up);
}
[Fact]
public async Task Subscribe_with_empty_filter_receives_every_alarm_emission()
{
var (engine, source, up) = await BuildAsync();
using var _e = engine;
using var _s = source;
var events = new List<AlarmEventArgs>();
source.OnAlarmEvent += (_, e) => events.Add(e);
var handle = await source.SubscribeAlarmsAsync([], TestContext.Current.CancellationToken);
up.Push("Temp", 150);
await Task.Delay(200);
events.Count.ShouldBe(1);
events[0].ConditionId.ShouldBe("Plant/Line1::HighTemp");
events[0].SourceNodeId.ShouldBe("Plant/Line1");
events[0].Severity.ShouldBe(AlarmSeverity.High);
events[0].AlarmType.ShouldBe("LimitAlarm");
events[0].Message.ShouldBe("Temp 150C");
await source.UnsubscribeAlarmsAsync(handle, TestContext.Current.CancellationToken);
}
[Fact]
public async Task Subscribe_with_equipment_prefix_filters_by_that_prefix()
{
var (engine, source, up) = await BuildAsync();
using var _e = engine;
using var _s = source;
var events = new List<AlarmEventArgs>();
source.OnAlarmEvent += (_, e) => events.Add(e);
// Subscribe only to Line1 alarms.
var handle = await source.SubscribeAlarmsAsync(["Plant/Line1"], TestContext.Current.CancellationToken);
up.Push("Temp", 150);
await Task.Delay(200);
events.Count.ShouldBe(1);
events[0].SourceNodeId.ShouldBe("Plant/Line1");
await source.UnsubscribeAlarmsAsync(handle, TestContext.Current.CancellationToken);
}
[Fact]
public async Task Unsubscribe_stops_further_events()
{
var (engine, source, up) = await BuildAsync();
using var _e = engine;
using var _s = source;
var events = new List<AlarmEventArgs>();
source.OnAlarmEvent += (_, e) => events.Add(e);
var handle = await source.SubscribeAlarmsAsync([], TestContext.Current.CancellationToken);
await source.UnsubscribeAlarmsAsync(handle, TestContext.Current.CancellationToken);
up.Push("Temp", 150);
await Task.Delay(200);
events.Count.ShouldBe(0);
}
[Fact]
public async Task AcknowledgeAsync_routes_to_engine_with_default_user()
{
var (engine, source, up) = await BuildAsync();
using var _e = engine;
using var _s = source;
up.Push("Temp", 150);
await Task.Delay(200);
engine.GetState("Plant/Line1::HighTemp")!.Acked.ShouldBe(AlarmAckedState.Unacknowledged);
await source.AcknowledgeAsync([new AlarmAcknowledgeRequest(
"Plant/Line1", "Plant/Line1::HighTemp", "ack via opcua")],
TestContext.Current.CancellationToken);
var state = engine.GetState("Plant/Line1::HighTemp")!;
state.Acked.ShouldBe(AlarmAckedState.Acknowledged);
state.LastAckUser.ShouldBe("opcua-client");
state.LastAckComment.ShouldBe("ack via opcua");
}
[Fact]
public async Task Null_arguments_rejected()
{
var (engine, source, _) = await BuildAsync();
using var _e = engine;
using var _s = source;
await Should.ThrowAsync<ArgumentNullException>(async () =>
await source.SubscribeAlarmsAsync(null!, TestContext.Current.CancellationToken));
await Should.ThrowAsync<ArgumentNullException>(async () =>
await source.UnsubscribeAlarmsAsync(null!, TestContext.Current.CancellationToken));
await Should.ThrowAsync<ArgumentNullException>(async () =>
await source.AcknowledgeAsync(null!, TestContext.Current.CancellationToken));
}
}

View File

@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3" Version="1.1.0"/>
<PackageReference Include="Shouldly" Version="4.3.0"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms\ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.csproj"/>
</ItemGroup>
<ItemGroup>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
</ItemGroup>
</Project>

View File

@@ -147,6 +147,117 @@ public sealed class EquipmentNodeWalkerTests
variable.AttributeInfo.DriverDataType.ShouldBe(DriverDataType.String);
}
[Fact]
public void Walk_Emits_VirtualTag_Variables_With_Virtual_Source_Discriminator()
{
var eq = Eq("eq-1", "line-1", "oven-3");
var vtag = new VirtualTag
{
VirtualTagRowId = Guid.NewGuid(), GenerationId = 1,
VirtualTagId = "vt-1", EquipmentId = "eq-1", Name = "LineRate",
DataType = "Float32", ScriptId = "scr-1", Historize = true,
};
var content = new EquipmentNamespaceContent(
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")],
[eq], [], VirtualTags: [vtag]);
var rec = new RecordingBuilder("root");
EquipmentNodeWalker.Walk(rec, content);
var equipmentNode = rec.Children[0].Children[0].Children[0];
var v = equipmentNode.Variables.Single(x => x.BrowseName == "LineRate");
v.AttributeInfo.Source.ShouldBe(NodeSourceKind.Virtual);
v.AttributeInfo.VirtualTagId.ShouldBe("vt-1");
v.AttributeInfo.ScriptedAlarmId.ShouldBeNull();
v.AttributeInfo.IsHistorized.ShouldBeTrue();
v.AttributeInfo.DriverDataType.ShouldBe(DriverDataType.Float32);
}
[Fact]
public void Walk_Emits_ScriptedAlarm_Variables_With_ScriptedAlarm_Source_And_IsAlarm()
{
var eq = Eq("eq-1", "line-1", "oven-3");
var alarm = new ScriptedAlarm
{
ScriptedAlarmRowId = Guid.NewGuid(), GenerationId = 1,
ScriptedAlarmId = "al-1", EquipmentId = "eq-1", Name = "HighTemp",
AlarmType = "LimitAlarm", MessageTemplate = "{Temp} exceeded",
PredicateScriptId = "scr-9", Severity = 800,
};
var content = new EquipmentNamespaceContent(
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")],
[eq], [], ScriptedAlarms: [alarm]);
var rec = new RecordingBuilder("root");
EquipmentNodeWalker.Walk(rec, content);
var v = rec.Children[0].Children[0].Children[0].Variables.Single(x => x.BrowseName == "HighTemp");
v.AttributeInfo.Source.ShouldBe(NodeSourceKind.ScriptedAlarm);
v.AttributeInfo.ScriptedAlarmId.ShouldBe("al-1");
v.AttributeInfo.VirtualTagId.ShouldBeNull();
v.AttributeInfo.IsAlarm.ShouldBeTrue();
v.AttributeInfo.DriverDataType.ShouldBe(DriverDataType.Boolean);
}
[Fact]
public void Walk_Skips_Disabled_VirtualTags_And_Alarms()
{
var eq = Eq("eq-1", "line-1", "oven-3");
var vtag = new VirtualTag
{
VirtualTagRowId = Guid.NewGuid(), GenerationId = 1,
VirtualTagId = "vt-1", EquipmentId = "eq-1", Name = "Disabled",
DataType = "Float32", ScriptId = "scr-1", Enabled = false,
};
var alarm = new ScriptedAlarm
{
ScriptedAlarmRowId = Guid.NewGuid(), GenerationId = 1,
ScriptedAlarmId = "al-1", EquipmentId = "eq-1", Name = "DisabledAlarm",
AlarmType = "LimitAlarm", MessageTemplate = "x",
PredicateScriptId = "scr-9", Enabled = false,
};
var content = new EquipmentNamespaceContent(
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")],
[eq], [], VirtualTags: [vtag], ScriptedAlarms: [alarm]);
var rec = new RecordingBuilder("root");
EquipmentNodeWalker.Walk(rec, content);
rec.Children[0].Children[0].Children[0].Variables.ShouldBeEmpty();
}
[Fact]
public void Walk_Null_VirtualTags_And_ScriptedAlarms_Is_Safe()
{
// Backwards-compat — callers that don't populate the new collections still work.
var eq = Eq("eq-1", "line-1", "oven-3");
var content = new EquipmentNamespaceContent(
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], []);
var rec = new RecordingBuilder("root");
EquipmentNodeWalker.Walk(rec, content); // must not throw
rec.Children[0].Children[0].Children[0].Variables.ShouldBeEmpty();
}
[Fact]
public void Driver_tag_default_NodeSourceKind_is_Driver()
{
var eq = Eq("eq-1", "line-1", "oven-3");
var tag = NewTag("t-1", "Temp", "Int32", "plc-01", "eq-1");
var content = new EquipmentNamespaceContent(
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")],
[eq], [tag]);
var rec = new RecordingBuilder("root");
EquipmentNodeWalker.Walk(rec, content);
var v = rec.Children[0].Children[0].Children[0].Variables.Single();
v.AttributeInfo.Source.ShouldBe(NodeSourceKind.Driver);
v.AttributeInfo.VirtualTagId.ShouldBeNull();
v.AttributeInfo.ScriptedAlarmId.ShouldBeNull();
}
// ----- builders for test seed rows -----
private static UnsArea Area(string id, string name) => new()

View File

@@ -0,0 +1,166 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags.Tests;
/// <summary>
/// Verifies cycle detection + topological sort on the virtual-tag dependency
/// graph. Publish-time correctness depends on these being right — a missed cycle
/// would deadlock cascade evaluation; a wrong topological order would miscompute
/// chained virtual tags.
/// </summary>
[Trait("Category", "Unit")]
public sealed class DependencyGraphTests
{
private static IReadOnlySet<string> Set(params string[] items) =>
new HashSet<string>(items, StringComparer.Ordinal);
[Fact]
public void Empty_graph_produces_empty_sort_and_no_cycles()
{
var g = new DependencyGraph();
g.TopologicalSort().ShouldBeEmpty();
g.DetectCycles().ShouldBeEmpty();
}
[Fact]
public void Single_node_with_no_deps()
{
var g = new DependencyGraph();
g.Add("A", Set());
g.TopologicalSort().ShouldBe(new[] { "A" });
g.DetectCycles().ShouldBeEmpty();
}
[Fact]
public void Topological_order_places_dependencies_before_dependents()
{
var g = new DependencyGraph();
g.Add("B", Set("A")); // B depends on A
g.Add("C", Set("B", "A")); // C depends on B + A
g.Add("A", Set()); // A is a leaf
var order = g.TopologicalSort();
var idx = order.Select((x, i) => (x, i)).ToDictionary(p => p.x, p => p.i);
idx["A"].ShouldBeLessThan(idx["B"]);
idx["B"].ShouldBeLessThan(idx["C"]);
}
[Fact]
public void Self_loop_detected_as_cycle()
{
var g = new DependencyGraph();
g.Add("A", Set("A"));
var cycles = g.DetectCycles();
cycles.Count.ShouldBe(1);
cycles[0].ShouldContain("A");
}
[Fact]
public void Two_node_cycle_detected()
{
var g = new DependencyGraph();
g.Add("A", Set("B"));
g.Add("B", Set("A"));
var cycles = g.DetectCycles();
cycles.Count.ShouldBe(1);
cycles[0].Count.ShouldBe(2);
}
[Fact]
public void Three_node_cycle_detected()
{
var g = new DependencyGraph();
g.Add("A", Set("B"));
g.Add("B", Set("C"));
g.Add("C", Set("A"));
var cycles = g.DetectCycles();
cycles.Count.ShouldBe(1);
cycles[0].Count.ShouldBe(3);
}
[Fact]
public void Multiple_disjoint_cycles_all_reported()
{
var g = new DependencyGraph();
// Cycle 1: A -> B -> A
g.Add("A", Set("B"));
g.Add("B", Set("A"));
// Cycle 2: X -> Y -> Z -> X
g.Add("X", Set("Y"));
g.Add("Y", Set("Z"));
g.Add("Z", Set("X"));
// Clean leaf: M
g.Add("M", Set());
var cycles = g.DetectCycles();
cycles.Count.ShouldBe(2);
}
[Fact]
public void Topological_sort_throws_DependencyCycleException_on_cycle()
{
var g = new DependencyGraph();
g.Add("A", Set("B"));
g.Add("B", Set("A"));
Should.Throw<DependencyCycleException>(() => g.TopologicalSort())
.Cycles.ShouldNotBeEmpty();
}
[Fact]
public void DirectDependents_returns_direct_only()
{
var g = new DependencyGraph();
g.Add("B", Set("A"));
g.Add("C", Set("B"));
g.DirectDependents("A").ShouldBe(new[] { "B" });
g.DirectDependents("B").ShouldBe(new[] { "C" });
g.DirectDependents("C").ShouldBeEmpty();
}
[Fact]
public void TransitiveDependentsInOrder_returns_topological_closure()
{
var g = new DependencyGraph();
g.Add("B", Set("A"));
g.Add("C", Set("B"));
g.Add("D", Set("C"));
var closure = g.TransitiveDependentsInOrder("A");
closure.ShouldBe(new[] { "B", "C", "D" });
}
[Fact]
public void Readding_a_node_overwrites_prior_dependencies()
{
var g = new DependencyGraph();
g.Add("X", Set("A"));
g.DirectDependencies("X").ShouldBe(new[] { "A" });
// Re-add with different deps (simulates script edit + republish).
g.Add("X", Set("B", "C"));
g.DirectDependencies("X").OrderBy(s => s).ShouldBe(new[] { "B", "C" });
// A should no longer list X as a dependent.
g.DirectDependents("A").ShouldBeEmpty();
}
[Fact]
public void Leaf_dependencies_not_registered_as_nodes_are_treated_as_implicit()
{
// A is referenced but never Add'd as a node — it's an upstream driver tag.
var g = new DependencyGraph();
g.Add("B", Set("A"));
g.TopologicalSort().ShouldBe(new[] { "B" });
g.DirectDependents("A").ShouldBe(new[] { "B" });
}
[Fact]
public void Deep_graph_no_stack_overflow()
{
// Iterative Tarjan's + Kahn's — 10k deep chain must complete without blowing the stack.
var g = new DependencyGraph();
for (var i = 1; i < 10_000; i++)
g.Add($"N{i}", Set($"N{i - 1}"));
var order = g.TopologicalSort();
order.Count.ShouldBe(9_999);
}
}

View File

@@ -0,0 +1,70 @@
using System.Collections.Concurrent;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags.Tests;
/// <summary>
/// In-memory <see cref="ITagUpstreamSource"/> for tests. Seed tag values via
/// <see cref="Set"/>, push changes via <see cref="Push"/>. Tracks subscriptions so
/// tests can assert the engine disposes them on reload / shutdown.
/// </summary>
public sealed class FakeUpstream : ITagUpstreamSource
{
private readonly ConcurrentDictionary<string, DataValueSnapshot> _values = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, List<Action<string, DataValueSnapshot>>> _subs = new(StringComparer.Ordinal);
public int ActiveSubscriptionCount { get; private set; }
public void Set(string path, object value, uint statusCode = 0u)
{
var now = DateTime.UtcNow;
_values[path] = new DataValueSnapshot(value, statusCode, now, now);
}
public void Push(string path, object value, uint statusCode = 0u)
{
Set(path, value, statusCode);
if (_subs.TryGetValue(path, out var list))
{
Action<string, DataValueSnapshot>[] snap;
lock (list) { snap = list.ToArray(); }
foreach (var obs in snap) obs(path, _values[path]);
}
}
public DataValueSnapshot ReadTag(string path)
=> _values.TryGetValue(path, out var v)
? v
: new DataValueSnapshot(null, 0x80340000u, null, DateTime.UtcNow);
public IDisposable SubscribeTag(string path, Action<string, DataValueSnapshot> observer)
{
var list = _subs.GetOrAdd(path, _ => []);
lock (list) { list.Add(observer); }
ActiveSubscriptionCount++;
return new Unsub(this, path, observer);
}
private sealed class Unsub : IDisposable
{
private readonly FakeUpstream _up;
private readonly string _path;
private readonly Action<string, DataValueSnapshot> _observer;
public Unsub(FakeUpstream up, string path, Action<string, DataValueSnapshot> observer)
{
_up = up; _path = path; _observer = observer;
}
public void Dispose()
{
if (_up._subs.TryGetValue(_path, out var list))
{
lock (list)
{
if (list.Remove(_observer))
_up.ActiveSubscriptionCount--;
}
}
}
}
}

View File

@@ -0,0 +1,118 @@
using Serilog;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags.Tests;
[Trait("Category", "Unit")]
public sealed class TimerTriggerSchedulerTests
{
[Fact]
public async Task Timer_interval_causes_periodic_reevaluation()
{
var up = new FakeUpstream();
// Counter source — re-eval should pick up new value each tick.
var counter = 0;
var logger = new LoggerConfiguration().CreateLogger();
using var engine = new VirtualTagEngine(up,
new ScriptLoggerFactory(logger),
logger);
engine.Load([new VirtualTagDefinition(
"Counter", DriverDataType.Int32,
"""return ctx.Now.Millisecond;""", // changes on every evaluation
ChangeTriggered: false,
TimerInterval: TimeSpan.FromMilliseconds(100))]);
using var sched = new TimerTriggerScheduler(engine, logger);
sched.Start([new VirtualTagDefinition(
"Counter", DriverDataType.Int32,
"""return ctx.Now.Millisecond;""",
ChangeTriggered: false,
TimerInterval: TimeSpan.FromMilliseconds(100))]);
// Watch the value change across ticks.
var snapshots = new List<object?>();
using var sub = engine.Subscribe("Counter", (_, v) => snapshots.Add(v.Value));
await Task.Delay(500);
snapshots.Count.ShouldBeGreaterThanOrEqualTo(3, "At least 3 ticks in 500ms at 100ms cadence");
}
[Fact]
public async Task Tags_without_TimerInterval_not_scheduled()
{
var up = new FakeUpstream();
var logger = new LoggerConfiguration().CreateLogger();
using var engine = new VirtualTagEngine(up,
new ScriptLoggerFactory(logger), logger);
engine.Load([new VirtualTagDefinition(
"NoTimer", DriverDataType.Int32, """return 1;""")]);
using var sched = new TimerTriggerScheduler(engine, logger);
sched.Start([new VirtualTagDefinition(
"NoTimer", DriverDataType.Int32, """return 1;""")]);
var events = new List<int>();
using var sub = engine.Subscribe("NoTimer", (_, v) => events.Add((int)(v.Value ?? 0)));
await Task.Delay(300);
events.Count.ShouldBe(0, "No TimerInterval = no timer ticks");
}
[Fact]
public void Start_groups_tags_by_interval_into_shared_timers()
{
// Smoke test — Start on a definition list with two distinct intervals must not
// throw. Group count matches unique intervals.
var up = new FakeUpstream();
var logger = new LoggerConfiguration().CreateLogger();
using var engine = new VirtualTagEngine(up,
new ScriptLoggerFactory(logger), logger);
engine.Load([
new VirtualTagDefinition("Fast", DriverDataType.Int32, """return 1;""",
TimerInterval: TimeSpan.FromSeconds(1)),
new VirtualTagDefinition("Slow", DriverDataType.Int32, """return 2;""",
TimerInterval: TimeSpan.FromSeconds(5)),
new VirtualTagDefinition("AlsoFast", DriverDataType.Int32, """return 3;""",
TimerInterval: TimeSpan.FromSeconds(1)),
]);
using var sched = new TimerTriggerScheduler(engine, logger);
Should.NotThrow(() => sched.Start(new[]
{
new VirtualTagDefinition("Fast", DriverDataType.Int32, """return 1;""", TimerInterval: TimeSpan.FromSeconds(1)),
new VirtualTagDefinition("Slow", DriverDataType.Int32, """return 2;""", TimerInterval: TimeSpan.FromSeconds(5)),
new VirtualTagDefinition("AlsoFast", DriverDataType.Int32, """return 3;""", TimerInterval: TimeSpan.FromSeconds(1)),
}));
}
[Fact]
public void Disposed_scheduler_stops_firing()
{
var up = new FakeUpstream();
var logger = new LoggerConfiguration().CreateLogger();
using var engine = new VirtualTagEngine(up,
new ScriptLoggerFactory(logger), logger);
engine.Load([new VirtualTagDefinition(
"T", DriverDataType.Int32, """return 1;""",
TimerInterval: TimeSpan.FromMilliseconds(50))]);
var sched = new TimerTriggerScheduler(engine, logger);
sched.Start([new VirtualTagDefinition(
"T", DriverDataType.Int32, """return 1;""",
TimerInterval: TimeSpan.FromMilliseconds(50))]);
sched.Dispose();
// After dispose, second Start throws ObjectDisposedException.
Should.Throw<ObjectDisposedException>(() =>
sched.Start([new VirtualTagDefinition(
"T", DriverDataType.Int32, """return 1;""",
TimerInterval: TimeSpan.FromMilliseconds(50))]));
}
}

View File

@@ -0,0 +1,307 @@
using Serilog;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags.Tests;
/// <summary>
/// End-to-end VirtualTagEngine behavior: load config, subscribe to upstream,
/// evaluate on change, cascade through dependent virtual tags, timer-driven
/// re-evaluation, error isolation, historize flag, cycle rejection.
/// </summary>
[Trait("Category", "Unit")]
public sealed class VirtualTagEngineTests
{
private static VirtualTagEngine Build(
FakeUpstream upstream,
IHistoryWriter? history = null,
TimeSpan? scriptTimeout = null,
Func<DateTime>? clock = null)
{
var rootLogger = new LoggerConfiguration().CreateLogger();
return new VirtualTagEngine(
upstream,
new ScriptLoggerFactory(rootLogger),
rootLogger,
history,
clock,
scriptTimeout);
}
[Fact]
public async Task Simple_script_reads_upstream_and_returns_coerced_value()
{
var up = new FakeUpstream();
up.Set("InTag", 10.0);
using var engine = Build(up);
engine.Load([new VirtualTagDefinition(
Path: "LineRate",
DataType: DriverDataType.Float64,
ScriptSource: """return (double)ctx.GetTag("InTag").Value * 2.0;""")]);
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
var result = engine.Read("LineRate");
result.StatusCode.ShouldBe(0u);
result.Value.ShouldBe(20.0);
}
[Fact]
public async Task Upstream_change_triggers_cascade_through_two_levels()
{
var up = new FakeUpstream();
up.Set("A", 1.0);
using var engine = Build(up);
engine.Load([
new VirtualTagDefinition("B", DriverDataType.Float64,
"""return (double)ctx.GetTag("A").Value + 10.0;"""),
new VirtualTagDefinition("C", DriverDataType.Float64,
"""return (double)ctx.GetTag("B").Value * 2.0;"""),
]);
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
engine.Read("B").Value.ShouldBe(11.0);
engine.Read("C").Value.ShouldBe(22.0);
// Change upstream — cascade should recompute B (11→15.0) then C (30.0)
up.Push("A", 5.0);
await WaitForConditionAsync(() => Equals(engine.Read("B").Value, 15.0));
engine.Read("B").Value.ShouldBe(15.0);
engine.Read("C").Value.ShouldBe(30.0);
}
[Fact]
public async Task Cycle_in_virtual_tags_rejected_at_Load()
{
var up = new FakeUpstream();
using var engine = Build(up);
Should.Throw<DependencyCycleException>(() => engine.Load([
new VirtualTagDefinition("A", DriverDataType.Int32, """return (int)ctx.GetTag("B").Value + 1;"""),
new VirtualTagDefinition("B", DriverDataType.Int32, """return (int)ctx.GetTag("A").Value + 1;"""),
]));
await Task.CompletedTask;
}
[Fact]
public async Task Script_compile_error_surfaces_at_Load_with_all_failures()
{
var up = new FakeUpstream();
using var engine = Build(up);
var ex = Should.Throw<InvalidOperationException>(() => engine.Load([
new VirtualTagDefinition("A", DriverDataType.Int32, """return undefinedIdentifier;"""),
new VirtualTagDefinition("B", DriverDataType.Int32, """return 42;"""),
new VirtualTagDefinition("C", DriverDataType.Int32, """var x = anotherUndefined; return x;"""),
]));
ex.Message.ShouldContain("2 script(s) did not compile");
ex.Message.ShouldContain("A");
ex.Message.ShouldContain("C");
await Task.CompletedTask;
}
[Fact]
public async Task Script_runtime_exception_isolates_to_owning_tag()
{
var up = new FakeUpstream();
up.Set("OK", 10);
using var engine = Build(up);
engine.Load([
new VirtualTagDefinition("GoodTag", DriverDataType.Int32,
"""return (int)ctx.GetTag("OK").Value * 2;"""),
new VirtualTagDefinition("BadTag", DriverDataType.Int32,
"""throw new InvalidOperationException("boom");"""),
]);
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
engine.Read("GoodTag").StatusCode.ShouldBe(0u);
engine.Read("GoodTag").Value.ShouldBe(20);
engine.Read("BadTag").StatusCode.ShouldBe(0x80020000u, "BadInternalError for thrown script");
engine.Read("BadTag").Value.ShouldBeNull();
}
[Fact]
public async Task Timeout_maps_to_BadInternalError_without_killing_the_engine()
{
var up = new FakeUpstream();
using var engine = Build(up, scriptTimeout: TimeSpan.FromMilliseconds(30));
engine.Load([
new VirtualTagDefinition("Hang", DriverDataType.Int32, """
var end = Environment.TickCount64 + 5000;
while (Environment.TickCount64 < end) { }
return 1;
"""),
new VirtualTagDefinition("Ok", DriverDataType.Int32, """return 42;"""),
]);
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
engine.Read("Hang").StatusCode.ShouldBe(0x80020000u);
engine.Read("Ok").Value.ShouldBe(42);
}
[Fact]
public async Task Subscribers_receive_engine_emitted_changes()
{
var up = new FakeUpstream();
up.Set("In", 1);
using var engine = Build(up);
engine.Load([new VirtualTagDefinition(
"Out", DriverDataType.Int32, """return (int)ctx.GetTag("In").Value + 100;""")]);
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
var received = new List<DataValueSnapshot>();
using var sub = engine.Subscribe("Out", (p, v) => received.Add(v));
up.Push("In", 5);
await WaitForConditionAsync(() => received.Count >= 1);
received[^1].Value.ShouldBe(105);
}
[Fact]
public async Task Historize_flag_routes_to_history_writer()
{
var recorded = new List<(string, DataValueSnapshot)>();
var history = new TestHistory(recorded);
var up = new FakeUpstream();
up.Set("In", 1);
using var engine = Build(up, history);
engine.Load([
new VirtualTagDefinition("H", DriverDataType.Int32,
"""return (int)ctx.GetTag("In").Value + 1;""", Historize: true),
new VirtualTagDefinition("NoH", DriverDataType.Int32,
"""return (int)ctx.GetTag("In").Value - 1;""", Historize: false),
]);
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
recorded.Select(p => p.Item1).ShouldContain("H");
recorded.Select(p => p.Item1).ShouldNotContain("NoH");
}
[Fact]
public async Task Change_driven_false_ignores_upstream_push()
{
var up = new FakeUpstream();
up.Set("In", 1);
using var engine = Build(up);
engine.Load([new VirtualTagDefinition(
"Manual", DriverDataType.Int32,
"""return (int)ctx.GetTag("In").Value * 10;""",
ChangeTriggered: false)]);
// Initial eval seeds the value.
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
engine.Read("Manual").Value.ShouldBe(10);
// Upstream change fires but change-driven is off — no recompute.
up.Push("In", 99);
await Task.Delay(100);
engine.Read("Manual").Value.ShouldBe(10, "change-driven=false ignores upstream deltas");
}
[Fact]
public async Task Reload_replaces_existing_tags_and_resubscribes_cleanly()
{
var up = new FakeUpstream();
up.Set("A", 1);
up.Set("B", 2);
using var engine = Build(up);
engine.Load([new VirtualTagDefinition(
"T", DriverDataType.Int32, """return (int)ctx.GetTag("A").Value * 2;""")]);
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
engine.Read("T").Value.ShouldBe(2);
up.ActiveSubscriptionCount.ShouldBe(1);
// Reload — T now depends on B instead of A.
engine.Load([new VirtualTagDefinition(
"T", DriverDataType.Int32, """return (int)ctx.GetTag("B").Value * 3;""")]);
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
engine.Read("T").Value.ShouldBe(6);
up.ActiveSubscriptionCount.ShouldBe(1, "previous subscription on A must be disposed");
await Task.CompletedTask;
}
[Fact]
public async Task Dispose_releases_upstream_subscriptions()
{
var up = new FakeUpstream();
up.Set("A", 1);
var engine = Build(up);
engine.Load([new VirtualTagDefinition(
"T", DriverDataType.Int32, """return (int)ctx.GetTag("A").Value;""")]);
up.ActiveSubscriptionCount.ShouldBe(1);
engine.Dispose();
up.ActiveSubscriptionCount.ShouldBe(0);
await Task.CompletedTask;
}
[Fact]
public async Task SetVirtualTag_within_script_updates_target_and_triggers_observers()
{
var up = new FakeUpstream();
up.Set("In", 5);
using var engine = Build(up);
engine.Load([
new VirtualTagDefinition("Target", DriverDataType.Int32,
"""return 0;""", ChangeTriggered: false), // placeholder value, operator-written via SetVirtualTag
new VirtualTagDefinition("Driver", DriverDataType.Int32,
"""
var v = (int)ctx.GetTag("In").Value;
ctx.SetVirtualTag("Target", v * 100);
return v;
"""),
]);
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
engine.Read("Target").Value.ShouldBe(500);
engine.Read("Driver").Value.ShouldBe(5);
}
[Fact]
public async Task Type_coercion_from_script_double_to_config_int32()
{
var up = new FakeUpstream();
up.Set("In", 3.7);
using var engine = Build(up);
engine.Load([new VirtualTagDefinition(
"Rounded", DriverDataType.Int32,
"""return (double)ctx.GetTag("In").Value;""")]);
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
engine.Read("Rounded").Value.ShouldBe(4, "Convert.ToInt32 rounds 3.7 to 4");
}
private static async Task WaitForConditionAsync(Func<bool> cond, int timeoutMs = 2000)
{
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
while (DateTime.UtcNow < deadline)
{
if (cond()) return;
await Task.Delay(25);
}
throw new TimeoutException("Condition did not become true in time");
}
private sealed class TestHistory : IHistoryWriter
{
private readonly List<(string, DataValueSnapshot)> _buf;
public TestHistory(List<(string, DataValueSnapshot)> buf) => _buf = buf;
public void Record(string path, DataValueSnapshot value)
{
lock (_buf) { _buf.Add((path, value)); }
}
}
}

View File

@@ -0,0 +1,132 @@
using Serilog;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags.Tests;
/// <summary>
/// Verifies the IReadable + ISubscribable adapter that DriverNodeManager dispatches
/// to for NodeSource.Virtual per ADR-002. Key contract: OPC UA clients see virtual
/// tags via the same capability interfaces as driver tags, so dispatch stays
/// source-agnostic.
/// </summary>
[Trait("Category", "Unit")]
public sealed class VirtualTagSourceTests
{
private static (VirtualTagEngine engine, VirtualTagSource source, FakeUpstream up) Build()
{
var up = new FakeUpstream();
up.Set("In", 10);
var logger = new LoggerConfiguration().CreateLogger();
var engine = new VirtualTagEngine(up, new ScriptLoggerFactory(logger), logger);
engine.Load([new VirtualTagDefinition(
"Out", DriverDataType.Int32, """return (int)ctx.GetTag("In").Value * 2;""")]);
return (engine, new VirtualTagSource(engine), up);
}
[Fact]
public async Task ReadAsync_returns_engine_cached_values()
{
var (engine, source, _) = Build();
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
var results = await source.ReadAsync(["Out"], TestContext.Current.CancellationToken);
results.Count.ShouldBe(1);
results[0].Value.ShouldBe(20);
results[0].StatusCode.ShouldBe(0u);
engine.Dispose();
}
[Fact]
public async Task ReadAsync_unknown_path_returns_Bad_quality()
{
var (engine, source, _) = Build();
var results = await source.ReadAsync(["NoSuchTag"], TestContext.Current.CancellationToken);
results[0].StatusCode.ShouldBe(0x80340000u);
engine.Dispose();
}
[Fact]
public async Task SubscribeAsync_fires_initial_data_callback()
{
var (engine, source, _) = Build();
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
var events = new List<DataChangeEventArgs>();
source.OnDataChange += (_, e) => events.Add(e);
var handle = await source.SubscribeAsync(["Out"], TimeSpan.FromMilliseconds(100),
TestContext.Current.CancellationToken);
handle.ShouldNotBeNull();
// Per OPC UA convention, initial-data callback fires on subscribe.
events.Count.ShouldBeGreaterThanOrEqualTo(1);
events[0].FullReference.ShouldBe("Out");
events[0].Snapshot.Value.ShouldBe(20);
await source.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
engine.Dispose();
}
[Fact]
public async Task SubscribeAsync_fires_on_upstream_change_via_engine_cascade()
{
var (engine, source, up) = Build();
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
var events = new List<DataChangeEventArgs>();
source.OnDataChange += (_, e) => events.Add(e);
var handle = await source.SubscribeAsync(["Out"], TimeSpan.Zero,
TestContext.Current.CancellationToken);
var initialCount = events.Count;
up.Push("In", 50);
// Wait for the cascade.
var deadline = DateTime.UtcNow.AddSeconds(2);
while (DateTime.UtcNow < deadline && events.Count <= initialCount) await Task.Delay(25);
events.Count.ShouldBeGreaterThan(initialCount);
events[^1].Snapshot.Value.ShouldBe(100);
await source.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
engine.Dispose();
}
[Fact]
public async Task UnsubscribeAsync_stops_further_events()
{
var (engine, source, up) = Build();
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
var events = new List<DataChangeEventArgs>();
source.OnDataChange += (_, e) => events.Add(e);
var handle = await source.SubscribeAsync(["Out"], TimeSpan.Zero,
TestContext.Current.CancellationToken);
await source.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
var countAfterUnsub = events.Count;
up.Push("In", 99);
await Task.Delay(200);
events.Count.ShouldBe(countAfterUnsub, "Unsubscribe must stop OnDataChange emissions");
engine.Dispose();
}
[Fact]
public async Task Null_arguments_rejected()
{
var (engine, source, _) = Build();
await Should.ThrowAsync<ArgumentNullException>(async () =>
await source.ReadAsync(null!, TestContext.Current.CancellationToken));
await Should.ThrowAsync<ArgumentNullException>(async () =>
await source.SubscribeAsync(null!, TimeSpan.Zero, TestContext.Current.CancellationToken));
await Should.ThrowAsync<ArgumentNullException>(async () =>
await source.UnsubscribeAsync(null!, TestContext.Current.CancellationToken));
engine.Dispose();
}
}

View File

@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Core.VirtualTags.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3" Version="1.1.0"/>
<PackageReference Include="Shouldly" Version="4.3.0"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Core.VirtualTags\ZB.MOM.WW.OtOpcUa.Core.VirtualTags.csproj"/>
</ItemGroup>
<ItemGroup>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,89 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
/// <summary>
/// Phase 7 Stream G follow-up — verifies the NodeSourceKind dispatch kernel that
/// DriverNodeManager's OnReadValue + OnWriteValue use to route per-node calls to
/// the right backend per ADR-002. Pure functions; no OPC UA stack required.
/// </summary>
[Trait("Category", "Unit")]
public sealed class DriverNodeManagerSourceDispatchTests
{
private sealed class FakeReadable : IReadable
{
public string Name { get; init; } = "";
public Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken) =>
Task.FromResult<IReadOnlyList<DataValueSnapshot>>([]);
}
[Fact]
public void Driver_source_routes_to_driver_readable()
{
var drv = new FakeReadable { Name = "drv" };
var vt = new FakeReadable { Name = "vt" };
var al = new FakeReadable { Name = "al" };
DriverNodeManager.SelectReadable(NodeSourceKind.Driver, drv, vt, al).ShouldBeSameAs(drv);
}
[Fact]
public void Virtual_source_routes_to_virtual_readable()
{
var drv = new FakeReadable();
var vt = new FakeReadable();
var al = new FakeReadable();
DriverNodeManager.SelectReadable(NodeSourceKind.Virtual, drv, vt, al).ShouldBeSameAs(vt);
}
[Fact]
public void ScriptedAlarm_source_routes_to_alarm_readable()
{
var drv = new FakeReadable();
var vt = new FakeReadable();
var al = new FakeReadable();
DriverNodeManager.SelectReadable(NodeSourceKind.ScriptedAlarm, drv, vt, al).ShouldBeSameAs(al);
}
[Fact]
public void Virtual_source_without_virtual_readable_returns_null()
{
// Engine not wired → dispatch layer surfaces BadNotFound (the null propagates
// through to the OnReadValue null-check).
DriverNodeManager.SelectReadable(
NodeSourceKind.Virtual, driverReadable: new FakeReadable(),
virtualReadable: null, scriptedAlarmReadable: null).ShouldBeNull();
}
[Fact]
public void ScriptedAlarm_source_without_alarm_readable_returns_null()
{
DriverNodeManager.SelectReadable(
NodeSourceKind.ScriptedAlarm, driverReadable: new FakeReadable(),
virtualReadable: new FakeReadable(), scriptedAlarmReadable: null).ShouldBeNull();
}
[Fact]
public void Driver_source_without_driver_readable_returns_null()
{
// Pre-existing BadNotReadable behavior — unchanged by Phase 7 wiring.
DriverNodeManager.SelectReadable(
NodeSourceKind.Driver, driverReadable: null,
virtualReadable: new FakeReadable(), scriptedAlarmReadable: new FakeReadable()).ShouldBeNull();
}
[Fact]
public void IsWriteAllowedBySource_only_Driver_returns_true()
{
// Plan decision #6 — OPC UA writes to virtual tags / scripted alarms rejected.
DriverNodeManager.IsWriteAllowedBySource(NodeSourceKind.Driver).ShouldBeTrue();
DriverNodeManager.IsWriteAllowedBySource(NodeSourceKind.Virtual).ShouldBeFalse();
DriverNodeManager.IsWriteAllowedBySource(NodeSourceKind.ScriptedAlarm).ShouldBeFalse();
}
}