Compare commits

...

10 Commits

Author SHA1 Message Date
Joseph Doherty
e11350cf80 Phase 7 follow-up #244 — DriverSubscriptionBridge
Pumps live driver OnDataChange notifications into CachedTagUpstreamSource so
ctx.GetTag in user scripts sees the freshest driver value. The last missing piece
between #243 (composition kernel) and #246 (Program.cs wire-in).

## DriverSubscriptionBridge

IAsyncDisposable. Per DriverFeed: groups all paths for one ISubscribable into a
single SubscribeAsync call (consolidating polled drivers' work + giving
native-subscription drivers one watch list), keeps a per-feed reverse map from
driver-opaque fullRef back to script-side UNS path, hooks OnDataChange to
translate + push into the cache. DisposeAsync awaits UnsubscribeAsync per active
subscription + unhooks every handler so events post-dispose are silent.

Empty PathToFullRef map → feed skipped (no SubscribeAsync call). Subscribe failure
on any feed unhooks that feed's handler + propagates so misconfiguration aborts
bridge start cleanly. Double-Start throws InvalidOperationException; double-Dispose
is idempotent.

OTOPCUA0001 suppressed at the two ISubscribable call sites with comments
explaining the carve-out: bridge is the lifecycle-coordinator for Phase 7
subscriptions (one Subscribe at engine compose, one Unsubscribe at shutdown),
not the per-call hot-path. Driver Read dispatch still goes through CapabilityInvoker
via DriverNodeManager.

## Tests — 9 new = 29 Phase 7 tests total

DriverSubscriptionBridgeTests covers: SubscribeAsync called with distinct fullRefs,
OnDataChange pushes to cache keyed by UNS path, unmapped fullRef ignored, empty
PathToFullRef skips Subscribe, DisposeAsync unsubscribes + unhooks (post-dispose
events don't push), StartAsync called twice throws, DisposeAsync idempotent,
Subscribe failure unhooks handler + propagates, ctor null guards.

## Phase 7 production wiring chain status
- #243  composition kernel
- #245  scripted-alarm IReadable adapter
- #244  this — driver bridge
- #246 pending — Program.cs Compose call + SqliteStoreAndForwardSink lifecycle
- #240 pending — live E2E smoke (unblocks once #246 lands)
2026-04-20 21:53:05 -04:00
a5bd60768d Merge pull request 'Phase 7 follow-up #245 — ScriptedAlarmReadable adapter over engine state' (#191) from phase-7-fu-245-alarm-readable into v2 2026-04-20 21:32:57 -04:00
Joseph Doherty
d6a8bb1064 Phase 7 follow-up #245 — ScriptedAlarmReadable adapter over engine state
Task #245 — exposes each scripted alarm's current ActiveState as IReadable so
OPC UA variable reads on Source=ScriptedAlarm nodes return the live predicate
truth instead of BadNotFound.

## ScriptedAlarmReadable

Wraps ScriptedAlarmEngine + implements IReadable:
- Known alarm + Active → DataValueSnapshot(true, Good)
- Known alarm + Inactive → DataValueSnapshot(false, Good)
- Unknown alarm id → DataValueSnapshot(null, BadNodeIdUnknown) — surfaces
  misconfiguration rather than silently reading false
- Batch reads preserve request order

Phase7EngineComposer.Compose now returns this as ScriptedAlarmReadable when
ScriptedAlarm rows are present. ScriptedAlarmSource (IAlarmSource for the event
stream) stays in place — the IReadable is a separate adapter over the same engine.

## Tests — 6 new + 1 updated composer test = 19 total Phase 7 tests

ScriptedAlarmReadableTests covers: inactive + active predicate → bool snapshot,
unknown alarm id → BadNodeIdUnknown, batch order preservation, null-engine +
null-fullReferences guards. The active-predicate test uses ctx.GetTag on a seeded
upstream value to drive a real cascade through the engine.

Updated Phase7EngineComposerTests to assert ScriptedAlarmReadable is non-null
when alarms compose, null when only virtual tags.

## Follow-ups remaining
- #244 — driver-bridge feed populating CachedTagUpstreamSource
- #246 — Program.cs Compose call + SqliteStoreAndForwardSink lifecycle
2026-04-20 21:30:56 -04:00
f3053580a0 Merge pull request 'Phase 7 follow-up #243 — CachedTagUpstreamSource + Phase7EngineComposer' (#190) from phase-7-fu-243-compose into v2 2026-04-20 21:25:46 -04:00
Joseph Doherty
f64a8049d8 Phase 7 follow-up #243 — CachedTagUpstreamSource + Phase7EngineComposer
Ships the composition kernel that maps Config DB rows (Script / VirtualTag /
ScriptedAlarm) to the runtime definitions VirtualTagEngine + ScriptedAlarmEngine
consume, builds the engine instances, and wires OnEvent → historian-sink routing.

## src/ZB.MOM.WW.OtOpcUa.Server/Phase7/

- CachedTagUpstreamSource — implements both Core.VirtualTags.ITagUpstreamSource and
  Core.ScriptedAlarms.ITagUpstreamSource (identical shape, distinct namespaces) on one
  concrete type so the composer can hand one instance to both engines. Thread-safe
  ConcurrentDictionary value cache with synchronous ReadTag + fire-on-write
  Push(path, snapshot) that fans out to every observer registered via SubscribeTag.
  Unknown-path reads return a BadNodeIdUnknown-quality snapshot (status 0x80340000)
  so scripts branch on quality naturally.
- Phase7EngineComposer.Compose(scripts, virtualTags, scriptedAlarms, upstream,
  alarmStateStore, historianSink, rootScriptLogger, loggerFactory) — single static
  entry point that:
  * Indexes scripts by ScriptId, resolves VirtualTag.ScriptId + ScriptedAlarm.PredicateScriptId
    to full SourceCode
  * Projects DB rows to VirtualTagDefinition + ScriptedAlarmDefinition (mapping
    DataType string → DriverDataType enum, AlarmType string → AlarmKind enum,
    Severity 1..1000 → AlarmSeverity bucket matching the OPC UA Part 9 bands
    that AbCipAlarmProjection + OpcUaClient MapSeverity already use)
  * Constructs VirtualTagEngine + loads definitions (throws InvalidOperationException
    with the list of scripts that failed to compile — aggregated like Streams B+C)
  * Constructs ScriptedAlarmEngine + loads definitions + wires OnEvent →
    IAlarmHistorianSink.EnqueueAsync using ScriptedAlarmEvent.Emission as the event
    kind + Condition.LastAckUser/LastAckComment for audit fields
  * Returns Phase7ComposedSources with Disposables list the caller owns

Empty Phase 7 config returns Phase7ComposedSources.Empty so deployments without
scripts / alarms behave exactly as pre-Phase-7. Non-null sources flow into
OpcUaApplicationHost's virtualReadable / scriptedAlarmReadable plumbing landed by
task #239 — DriverNodeManager then dispatches reads by NodeSourceKind per PR #186.

## Tests — 12/12

CachedTagUpstreamSourceTests (6):
- Unknown-path read returns BadNodeIdUnknown-quality snapshot
- Push-then-Read returns cached value
- Push fans out to subscribers in registration order
- Push to one path doesn't fire another path's observer
- Dispose of subscription handle stops fan-out
- Satisfies both Core.VirtualTags + Core.ScriptedAlarms ITagUpstreamSource interfaces

Phase7EngineComposerTests (6):
- Empty rows → Phase7ComposedSources.Empty (both sources null)
- VirtualTag rows → VirtualReadable non-null + Disposables populated
- Missing script reference throws InvalidOperationException with the missing ScriptId
  in the message
- Disabled VirtualTag row skipped by projection
- TimerIntervalMs → TimeSpan.FromMilliseconds round-trip
- Severity 1..1000 maps to Low/Medium/High/Critical at 250/500/750 boundaries
  (matches AbCipAlarmProjection + OpcUaClient.MapSeverity banding)

## Scope — what this PR does NOT do

The composition kernel is the tricky part; the remaining wiring is three narrower
follow-ups that each build on this PR:

- task #244 — driver-bridge feed that populates CachedTagUpstreamSource from live
  driver subscriptions. Without this, ctx.GetTag returns BadNodeIdUnknown even when
  the driver has a fresh value.
- task #245 — ScriptedAlarmReadable adapter exposing each alarm's current Active
  state as IReadable. Phase7EngineComposer.Compose currently returns
  ScriptedAlarmReadable=null so reads on Source=ScriptedAlarm variables return
  BadNotFound per the ADR-002 "misconfiguration not silent fallback" signal.
- task #246 — Program.cs call to Phase7EngineComposer.Compose with config rows
  loaded from the sealed-cache DB read, plus SqliteStoreAndForwardSink lifecycle
  wiring at %ProgramData%/OtOpcUa/alarm-historian-queue.db with the Galaxy.Host
  IPC writer from Stream D.

Task #240 (live OPC UA E2E smoke) depends on all three follow-ups landing.
2026-04-20 21:23:31 -04:00
c7f0855427 Merge pull request 'Phase 7 follow-ups #239 (plumbing) + #241 (diff-proc extension)' (#189) from phase-7-fu-239-bootstrap into v2 2026-04-20 21:10:06 -04:00
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
19 changed files with 3464 additions and 4 deletions

View File

@@ -36,6 +36,7 @@
<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,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

@@ -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,84 @@
using System.Collections.Concurrent;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Server.Phase7;
/// <summary>
/// Production <c>ITagUpstreamSource</c> for the Phase 7 engines (implements both the
/// Core.VirtualTags and Core.ScriptedAlarms variants — identical shape, distinct
/// namespaces). Per the interface docstring, reads are synchronous — user scripts
/// call <c>ctx.GetTag</c> inline — so we serve from a last-known-value cache that
/// the driver-bridge populates asynchronously via <see cref="Push"/>.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="Push"/> is called by the driver-bridge (wiring added by task #244)
/// every time a driver's <c>ISubscribable.OnDataChange</c> fires. Subscribers
/// registered via <see cref="SubscribeTag"/> are notified synchronously on the
/// calling thread — the VirtualTagEngine + ScriptedAlarmEngine handle their own
/// async hand-off via <c>SemaphoreSlim</c>.
/// </para>
/// <para>
/// Reads of a path that has never been <see cref="Push"/>-ed return
/// <see cref="UpstreamNotConfigured"/>-quality — which scripts see as
/// <c>ctx.GetTag("...").StatusCode == BadNodeIdUnknown</c> and can branch on.
/// </para>
/// </remarks>
public sealed class CachedTagUpstreamSource
: Core.VirtualTags.ITagUpstreamSource,
Core.ScriptedAlarms.ITagUpstreamSource
{
private readonly ConcurrentDictionary<string, DataValueSnapshot> _values = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, List<Action<string, DataValueSnapshot>>> _observers
= new(StringComparer.Ordinal);
public DataValueSnapshot ReadTag(string path)
{
if (string.IsNullOrEmpty(path)) throw new ArgumentException("path required", nameof(path));
return _values.TryGetValue(path, out var snap)
? snap
: new DataValueSnapshot(null, UpstreamNotConfigured, null, DateTime.UtcNow);
}
public IDisposable SubscribeTag(string path, Action<string, DataValueSnapshot> observer)
{
if (string.IsNullOrEmpty(path)) throw new ArgumentException("path required", nameof(path));
ArgumentNullException.ThrowIfNull(observer);
var list = _observers.GetOrAdd(path, _ => []);
lock (list) list.Add(observer);
return new Unsub(this, path, observer);
}
/// <summary>
/// Driver-bridge write path — called when a driver delivers a value change for
/// <paramref name="path"/>. Updates the cache + fans out to every observer.
/// Safe for concurrent callers; observers fire on the caller's thread.
/// </summary>
public void Push(string path, DataValueSnapshot snapshot)
{
if (string.IsNullOrEmpty(path)) throw new ArgumentException("path required", nameof(path));
ArgumentNullException.ThrowIfNull(snapshot);
_values[path] = snapshot;
if (!_observers.TryGetValue(path, out var list)) return;
Action<string, DataValueSnapshot>[] snapshotList;
lock (list) snapshotList = list.ToArray();
foreach (var observer in snapshotList) observer(path, snapshot);
}
/// <summary>Mirror of OPC UA <c>StatusCodes.BadNodeIdUnknown</c> without pulling the OPC stack dependency.</summary>
public const uint UpstreamNotConfigured = 0x80340000;
private sealed class Unsub(CachedTagUpstreamSource owner, string path, Action<string, DataValueSnapshot> observer) : IDisposable
{
private bool _disposed;
public void Dispose()
{
if (_disposed) return;
_disposed = true;
if (owner._observers.TryGetValue(path, out var list))
lock (list) list.Remove(observer);
}
}
}

View File

@@ -0,0 +1,146 @@
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Server.Phase7;
/// <summary>
/// Phase 7 follow-up (task #244). Subscribes to live driver <see cref="ISubscribable"/>
/// surfaces for every input path the Phase 7 engines care about + pushes incoming
/// <see cref="DataChangeEventArgs.Snapshot"/>s into <see cref="CachedTagUpstreamSource"/>
/// so <c>ctx.GetTag</c> reads see the freshest driver value.
/// </summary>
/// <remarks>
/// <para>
/// Each <see cref="DriverFeed"/> declares a driver + the path-to-fullRef map for the
/// attributes that driver provides. The bridge groups by driver so each <see cref="ISubscribable"/>
/// gets one <c>SubscribeAsync</c> call with a batched fullRef list — drivers that
/// poll under the hood (Modbus, AB CIP, S7) consolidate the polls; drivers with
/// native subscriptions (Galaxy, OPC UA Client, TwinCAT) get a single watch list.
/// </para>
/// <para>
/// Because driver fullRefs are opaque + driver-specific (Galaxy
/// <c>"DelmiaReceiver_001.Temp"</c>, Modbus <c>"40001"</c>, AB CIP
/// <c>"Temperature[0]"</c>), the bridge keeps a per-feed reverse map from fullRef
/// back to UNS path. <c>OnDataChange</c> fires keyed by fullRef; the bridge
/// translates to the script-side path before calling <see cref="CachedTagUpstreamSource.Push"/>.
/// </para>
/// <para>
/// Lifecycle: construct → <see cref="StartAsync"/> with the feeds → keep alive
/// alongside the engines → <see cref="DisposeAsync"/> unsubscribes from every
/// driver + unhooks the OnDataChange handlers. Driver subscriptions don't leak
/// even on abnormal shutdown because the disposal awaits each
/// <c>UnsubscribeAsync</c>.
/// </para>
/// </remarks>
public sealed class DriverSubscriptionBridge : IAsyncDisposable
{
private readonly CachedTagUpstreamSource _sink;
private readonly ILogger<DriverSubscriptionBridge> _logger;
private readonly List<ActiveSubscription> _active = [];
private bool _started;
private bool _disposed;
public DriverSubscriptionBridge(
CachedTagUpstreamSource sink,
ILogger<DriverSubscriptionBridge> logger)
{
_sink = sink ?? throw new ArgumentNullException(nameof(sink));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Subscribe each feed's driver to its declared fullRefs + wire push-to-cache.
/// Idempotent guard rejects double-start. Throws on the first subscribe failure
/// so misconfiguration surfaces fast — partial-subscribe state doesn't linger.
/// </summary>
public async Task StartAsync(IEnumerable<DriverFeed> feeds, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(feeds);
if (_disposed) throw new ObjectDisposedException(nameof(DriverSubscriptionBridge));
if (_started) throw new InvalidOperationException("DriverSubscriptionBridge already started");
_started = true;
foreach (var feed in feeds)
{
if (feed.PathToFullRef.Count == 0) continue;
// Reverse map for OnDataChange dispatch — driver fires keyed by FullReference,
// we push keyed by the script-side path.
var fullRefToPath = feed.PathToFullRef
.ToDictionary(kv => kv.Value, kv => kv.Key, StringComparer.Ordinal);
var fullRefs = feed.PathToFullRef.Values.Distinct(StringComparer.Ordinal).ToList();
EventHandler<DataChangeEventArgs> handler = (_, e) =>
{
if (fullRefToPath.TryGetValue(e.FullReference, out var unsPath))
_sink.Push(unsPath, e.Snapshot);
};
feed.Driver.OnDataChange += handler;
try
{
// OTOPCUA0001 suppression — the analyzer flags ISubscribable calls outside
// CapabilityInvoker. This bridge IS the lifecycle-coordinator for Phase 7
// subscriptions: it runs once at engine compose, doesn't hot-path per
// script evaluation (the engines read from the cache instead), and surfaces
// any subscribe failure by aborting bridge start. Wrapping in the per-call
// resilience pipeline would add nothing — there's no caller to retry on
// behalf of, and the breaker/bulkhead semantics belong to actual driver Read
// dispatch, which still goes through CapabilityInvoker via DriverNodeManager.
#pragma warning disable OTOPCUA0001
var handle = await feed.Driver.SubscribeAsync(fullRefs, feed.PublishingInterval, ct).ConfigureAwait(false);
#pragma warning restore OTOPCUA0001
_active.Add(new ActiveSubscription(feed.Driver, handle, handler));
_logger.LogInformation(
"Phase 7 bridge subscribed {Count} attribute(s) from driver {Driver} (handle {Handle})",
fullRefs.Count, feed.Driver.GetType().Name, handle.DiagnosticId);
}
catch
{
feed.Driver.OnDataChange -= handler;
throw;
}
}
}
public async ValueTask DisposeAsync()
{
if (_disposed) return;
_disposed = true;
foreach (var sub in _active)
{
sub.Driver.OnDataChange -= sub.Handler;
try
{
#pragma warning disable OTOPCUA0001 // bridge lifecycle — see StartAsync suppression rationale
await sub.Driver.UnsubscribeAsync(sub.Handle, CancellationToken.None).ConfigureAwait(false);
#pragma warning restore OTOPCUA0001
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Driver {Driver} UnsubscribeAsync threw on bridge dispose (handle {Handle})",
sub.Driver.GetType().Name, sub.Handle.DiagnosticId);
}
}
_active.Clear();
}
private sealed record ActiveSubscription(
ISubscribable Driver,
ISubscriptionHandle Handle,
EventHandler<DataChangeEventArgs> Handler);
}
/// <summary>
/// One driver's contribution to the Phase 7 bridge — the driver's <see cref="ISubscribable"/>
/// surface plus the path-to-fullRef map the bridge uses to translate driver-side
/// <see cref="DataChangeEventArgs.FullReference"/> back to script-side paths.
/// </summary>
/// <param name="Driver">The driver's subscribable surface (every shipped driver implements <see cref="ISubscribable"/>).</param>
/// <param name="PathToFullRef">UNS path the script uses → driver-opaque fullRef. Empty map = nothing to subscribe (skipped).</param>
/// <param name="PublishingInterval">Forwarded to the driver's <see cref="ISubscribable.SubscribeAsync"/>.</param>
public sealed record DriverFeed(
ISubscribable Driver,
IReadOnlyDictionary<string, string> PathToFullRef,
TimeSpan PublishingInterval);

View File

@@ -0,0 +1,208 @@
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
namespace ZB.MOM.WW.OtOpcUa.Server.Phase7;
/// <summary>
/// Phase 7 follow-up (task #243) — maps the generation's <see cref="Script"/> /
/// <see cref="VirtualTag"/> / <see cref="ScriptedAlarm"/> rows into the runtime
/// definitions <see cref="VirtualTagEngine"/> + <see cref="ScriptedAlarmEngine"/>
/// expect, builds the engine instances, and returns the <see cref="IReadable"/>
/// sources plus an <see cref="IAlarmSource"/> for the <c>DriverNodeManager</c>
/// wiring added by task #239.
/// </summary>
/// <remarks>
/// <para>
/// Empty Phase 7 config (no virtual tags + no scripted alarms) is a valid state:
/// <see cref="Compose"/> returns a <see cref="Phase7ComposedSources"/> with null
/// sources so Program.cs can pass them through to <c>OpcUaApplicationHost</c>
/// unchanged — deployments without scripts behave exactly as they did before
/// Phase 7.
/// </para>
/// <para>
/// The caller owns the returned <see cref="Phase7ComposedSources.Disposables"/>
/// and must dispose them on shutdown. Engine cascades + timer ticks run off
/// background threads until then.
/// </para>
/// </remarks>
public static class Phase7EngineComposer
{
public static Phase7ComposedSources Compose(
IReadOnlyList<Script> scripts,
IReadOnlyList<VirtualTag> virtualTags,
IReadOnlyList<ScriptedAlarm> scriptedAlarms,
CachedTagUpstreamSource upstream,
IAlarmStateStore alarmStateStore,
IAlarmHistorianSink historianSink,
Serilog.ILogger rootScriptLogger,
ILoggerFactory loggerFactory)
{
ArgumentNullException.ThrowIfNull(scripts);
ArgumentNullException.ThrowIfNull(virtualTags);
ArgumentNullException.ThrowIfNull(scriptedAlarms);
ArgumentNullException.ThrowIfNull(upstream);
ArgumentNullException.ThrowIfNull(alarmStateStore);
ArgumentNullException.ThrowIfNull(historianSink);
ArgumentNullException.ThrowIfNull(rootScriptLogger);
ArgumentNullException.ThrowIfNull(loggerFactory);
if (virtualTags.Count == 0 && scriptedAlarms.Count == 0)
return Phase7ComposedSources.Empty;
var scriptById = scripts
.Where(s => s.Enabled())
.ToDictionary(s => s.ScriptId, StringComparer.Ordinal);
var scriptLoggerFactory = new ScriptLoggerFactory(rootScriptLogger);
var disposables = new List<IDisposable>();
// Engines take Serilog.ILogger — each engine gets its own so rolling-file emissions
// stay keyed to the right source in the scripts-*.log.
VirtualTagSource? vtSource = null;
if (virtualTags.Count > 0)
{
var vtDefs = ProjectVirtualTags(virtualTags, scriptById).ToList();
var vtEngine = new VirtualTagEngine(upstream, scriptLoggerFactory, rootScriptLogger);
vtEngine.Load(vtDefs);
vtSource = new VirtualTagSource(vtEngine);
disposables.Add(vtEngine);
}
IReadable? alarmReadable = null;
if (scriptedAlarms.Count > 0)
{
var alarmDefs = ProjectScriptedAlarms(scriptedAlarms, scriptById).ToList();
var alarmEngine = new ScriptedAlarmEngine(upstream, alarmStateStore, scriptLoggerFactory, rootScriptLogger);
// Wire alarm emissions to the historian sink (Stream D). Fire-and-forget because
// the sink's EnqueueAsync is already non-blocking from the producer's view.
var engineLogger = loggerFactory.CreateLogger("Phase7HistorianRouter");
alarmEngine.OnEvent += (_, e) => _ = RouteToHistorianAsync(e, historianSink, engineLogger);
alarmEngine.LoadAsync(alarmDefs, CancellationToken.None).GetAwaiter().GetResult();
var alarmSource = new ScriptedAlarmSource(alarmEngine);
// Task #245 — expose each alarm's current Active state as IReadable so OPC UA
// variable reads on Source=ScriptedAlarm nodes return the live predicate truth
// instead of BadNotFound. ScriptedAlarmSource stays registered as IAlarmSource
// for the event stream; the IReadable is a separate adapter over the same engine.
alarmReadable = new ScriptedAlarmReadable(alarmEngine);
disposables.Add(alarmEngine);
disposables.Add(alarmSource);
}
return new Phase7ComposedSources(vtSource, alarmReadable, disposables);
}
internal static IEnumerable<VirtualTagDefinition> ProjectVirtualTags(
IReadOnlyList<VirtualTag> rows, IReadOnlyDictionary<string, Script> scriptById)
{
foreach (var row in rows)
{
if (!row.Enabled) continue;
if (!scriptById.TryGetValue(row.ScriptId, out var script))
throw new InvalidOperationException(
$"VirtualTag '{row.VirtualTagId}' references unknown / disabled Script '{row.ScriptId}' in this generation");
yield return new VirtualTagDefinition(
Path: row.VirtualTagId,
DataType: ParseDataType(row.DataType),
ScriptSource: script.SourceCode,
ChangeTriggered: row.ChangeTriggered,
TimerInterval: row.TimerIntervalMs.HasValue
? TimeSpan.FromMilliseconds(row.TimerIntervalMs.Value)
: null,
Historize: row.Historize);
}
}
internal static IEnumerable<ScriptedAlarmDefinition> ProjectScriptedAlarms(
IReadOnlyList<ScriptedAlarm> rows, IReadOnlyDictionary<string, Script> scriptById)
{
foreach (var row in rows)
{
if (!row.Enabled) continue;
if (!scriptById.TryGetValue(row.PredicateScriptId, out var script))
throw new InvalidOperationException(
$"ScriptedAlarm '{row.ScriptedAlarmId}' references unknown / disabled predicate Script '{row.PredicateScriptId}'");
yield return new ScriptedAlarmDefinition(
AlarmId: row.ScriptedAlarmId,
EquipmentPath: row.EquipmentId,
AlarmName: row.Name,
Kind: ParseAlarmKind(row.AlarmType),
Severity: MapSeverity(row.Severity),
MessageTemplate: row.MessageTemplate,
PredicateScriptSource: script.SourceCode,
HistorizeToAveva: row.HistorizeToAveva,
Retain: row.Retain);
}
}
private static DriverDataType ParseDataType(string raw) =>
Enum.TryParse<DriverDataType>(raw, ignoreCase: true, out var parsed) ? parsed : DriverDataType.String;
private static AlarmKind ParseAlarmKind(string raw) => raw switch
{
"AlarmCondition" => AlarmKind.AlarmCondition,
"LimitAlarm" => AlarmKind.LimitAlarm,
"DiscreteAlarm" => AlarmKind.DiscreteAlarm,
"OffNormalAlarm" => AlarmKind.OffNormalAlarm,
_ => throw new InvalidOperationException($"Unknown AlarmType '{raw}' — DB check constraint should have caught this"),
};
// OPC UA Part 9 severity bands (1..1000) → AlarmSeverity enum. Matches the same
// banding the AB CIP ALMA projection + OpcUaClient MapSeverity use.
private static AlarmSeverity MapSeverity(int s) => s switch
{
<= 250 => AlarmSeverity.Low,
<= 500 => AlarmSeverity.Medium,
<= 750 => AlarmSeverity.High,
_ => AlarmSeverity.Critical,
};
private static async Task RouteToHistorianAsync(
ScriptedAlarmEvent e, IAlarmHistorianSink sink, Microsoft.Extensions.Logging.ILogger log)
{
try
{
var historianEvent = new AlarmHistorianEvent(
AlarmId: e.AlarmId,
EquipmentPath: e.EquipmentPath,
AlarmName: e.AlarmName,
AlarmTypeName: e.Kind.ToString(),
Severity: e.Severity,
EventKind: e.Emission.ToString(),
Message: e.Message,
User: e.Condition.LastAckUser ?? "system",
Comment: e.Condition.LastAckComment,
TimestampUtc: e.TimestampUtc);
await sink.EnqueueAsync(historianEvent, CancellationToken.None).ConfigureAwait(false);
}
catch (Exception ex)
{
log.LogWarning(ex, "Historian enqueue failed for alarm {AlarmId}/{Emission}", e.AlarmId, e.Emission);
}
}
}
/// <summary>What <see cref="Phase7EngineComposer.Compose"/> returns.</summary>
/// <param name="VirtualReadable">Non-null when virtual tags were composed; pass to <c>OpcUaApplicationHost.virtualReadable</c>.</param>
/// <param name="ScriptedAlarmReadable">Non-null when scripted alarms were composed; pass to <c>OpcUaApplicationHost.scriptedAlarmReadable</c>.</param>
/// <param name="Disposables">Engine + source instances the caller owns. Dispose on shutdown.</param>
public sealed record Phase7ComposedSources(
IReadable? VirtualReadable,
IReadable? ScriptedAlarmReadable,
IReadOnlyList<IDisposable> Disposables)
{
public static readonly Phase7ComposedSources Empty =
new(null, null, Array.Empty<IDisposable>());
}
internal static class ScriptEnabledExtensions
{
// Script has no explicit Enabled column; every row in the generation is a live script.
public static bool Enabled(this Script _) => true;
}

View File

@@ -0,0 +1,58 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
namespace ZB.MOM.WW.OtOpcUa.Server.Phase7;
/// <summary>
/// <see cref="IReadable"/> adapter exposing each scripted alarm's current
/// <see cref="AlarmActiveState"/> as an OPC UA boolean. Phase 7 follow-up (task #245).
/// </summary>
/// <remarks>
/// <para>
/// Paired with the <see cref="NodeSourceKind.ScriptedAlarm"/> dispatch in
/// <c>DriverNodeManager.OnReadValue</c>. Full-reference lookup is the
/// <c>ScriptedAlarmId</c> the walker wrote into <c>DriverAttributeInfo.FullName</c>
/// when emitting the alarm variable node.
/// </para>
/// <para>
/// Unknown alarm ids return <c>BadNodeIdUnknown</c> so misconfiguration surfaces
/// instead of silently reading <c>false</c>. Alarms whose predicate has never
/// been evaluated (brand new, before the engine's first cascade tick) report
/// <see cref="AlarmActiveState.Inactive"/> via <see cref="AlarmConditionState.Fresh"/>,
/// which matches the Part 9 initial-state semantics.
/// </para>
/// </remarks>
public sealed class ScriptedAlarmReadable : IReadable
{
/// <summary>OPC UA <c>StatusCodes.BadNodeIdUnknown</c> — kept local so we don't pull the OPC stack.</summary>
private const uint BadNodeIdUnknown = 0x80340000;
private readonly ScriptedAlarmEngine _engine;
public ScriptedAlarmReadable(ScriptedAlarmEngine engine)
{
_engine = engine ?? throw new ArgumentNullException(nameof(engine));
}
public Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(fullReferences);
var now = DateTime.UtcNow;
var results = new DataValueSnapshot[fullReferences.Count];
for (var i = 0; i < fullReferences.Count; i++)
{
var alarmId = fullReferences[i];
var state = _engine.GetState(alarmId);
if (state is null)
{
results[i] = new DataValueSnapshot(null, BadNodeIdUnknown, null, now);
continue;
}
var active = state.Active == AlarmActiveState.Active;
results[i] = new DataValueSnapshot(active, 0u, now, now);
}
return Task.FromResult<IReadOnlyList<DataValueSnapshot>>(results);
}
}

View File

@@ -30,6 +30,10 @@
<ItemGroup>
<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.VirtualTags\ZB.MOM.WW.OtOpcUa.Core.VirtualTags.csproj"/>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms\ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.csproj"/>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.csproj"/>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Analyzers\ZB.MOM.WW.OtOpcUa.Analyzers.csproj"
OutputItemType="Analyzer" ReferenceOutputAssembly="false"/>
</ItemGroup>

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,83 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Server.Phase7;
namespace ZB.MOM.WW.OtOpcUa.Server.Tests.Phase7;
/// <summary>
/// Covers the Phase 7 driver-to-engine bridge cache (task #243). Verifies the
/// cache serves last-known values synchronously, fans out Push updates to
/// subscribers, and cleans up on Dispose.
/// </summary>
[Trait("Category", "Unit")]
public sealed class CachedTagUpstreamSourceTests
{
private static DataValueSnapshot Snap(object? v) =>
new(v, 0u, DateTime.UtcNow, DateTime.UtcNow);
[Fact]
public void ReadTag_unknown_path_returns_BadNodeIdUnknown_snapshot()
{
var c = new CachedTagUpstreamSource();
var snap = c.ReadTag("/nowhere");
snap.Value.ShouldBeNull();
snap.StatusCode.ShouldBe(CachedTagUpstreamSource.UpstreamNotConfigured);
}
[Fact]
public void Push_then_Read_returns_cached_value()
{
var c = new CachedTagUpstreamSource();
c.Push("/Line1/Temp", Snap(42));
c.ReadTag("/Line1/Temp").Value.ShouldBe(42);
}
[Fact]
public void Push_fans_out_to_subscribers_in_registration_order()
{
var c = new CachedTagUpstreamSource();
var events = new List<string>();
c.SubscribeTag("/X", (p, s) => events.Add($"A:{p}:{s.Value}"));
c.SubscribeTag("/X", (p, s) => events.Add($"B:{p}:{s.Value}"));
c.Push("/X", Snap(7));
events.ShouldBe(["A:/X:7", "B:/X:7"]);
}
[Fact]
public void Push_to_different_path_does_not_fire_foreign_observer()
{
var c = new CachedTagUpstreamSource();
var fired = 0;
c.SubscribeTag("/X", (_, _) => fired++);
c.Push("/Y", Snap(1));
fired.ShouldBe(0);
}
[Fact]
public void Dispose_of_subscription_stops_fan_out()
{
var c = new CachedTagUpstreamSource();
var fired = 0;
var sub = c.SubscribeTag("/X", (_, _) => fired++);
c.Push("/X", Snap(1));
sub.Dispose();
c.Push("/X", Snap(2));
fired.ShouldBe(1);
}
[Fact]
public void Satisfies_both_VirtualTag_and_ScriptedAlarm_upstream_interfaces()
{
var c = new CachedTagUpstreamSource();
// Single instance is assignable to both — the composer passes it through for
// both engine constructors per the task #243 wiring.
((Core.VirtualTags.ITagUpstreamSource)c).ShouldNotBeNull();
((Core.ScriptedAlarms.ITagUpstreamSource)c).ShouldNotBeNull();
}
}

View File

@@ -0,0 +1,226 @@
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Server.Phase7;
namespace ZB.MOM.WW.OtOpcUa.Server.Tests.Phase7;
/// <summary>
/// Task #244 — covers the bridge that pumps live driver <c>OnDataChange</c>
/// notifications into the Phase 7 <see cref="CachedTagUpstreamSource"/>.
/// </summary>
[Trait("Category", "Unit")]
public sealed class DriverSubscriptionBridgeTests
{
private sealed class FakeDriver : ISubscribable
{
public List<IReadOnlyList<string>> SubscribeCalls { get; } = [];
public List<ISubscriptionHandle> Unsubscribed { get; } = [];
public ISubscriptionHandle? LastHandle { get; private set; }
public event EventHandler<DataChangeEventArgs>? OnDataChange;
public Task<ISubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
{
SubscribeCalls.Add(fullReferences);
LastHandle = new Handle($"sub-{SubscribeCalls.Count}");
return Task.FromResult(LastHandle);
}
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
{
Unsubscribed.Add(handle);
return Task.CompletedTask;
}
public void Fire(string fullRef, object value)
{
OnDataChange?.Invoke(this, new DataChangeEventArgs(
LastHandle!, fullRef,
new DataValueSnapshot(value, 0u, DateTime.UtcNow, DateTime.UtcNow)));
}
private sealed record Handle(string DiagnosticId) : ISubscriptionHandle;
}
[Fact]
public async Task StartAsync_calls_SubscribeAsync_with_distinct_fullRefs()
{
var sink = new CachedTagUpstreamSource();
var driver = new FakeDriver();
await using var bridge = new DriverSubscriptionBridge(sink, NullLogger<DriverSubscriptionBridge>.Instance);
await bridge.StartAsync(new[]
{
new DriverFeed(driver,
new Dictionary<string, string>
{
["/Site/L1/A/Temp"] = "DR.Temp",
["/Site/L1/A/Pressure"] = "DR.Pressure",
},
TimeSpan.FromSeconds(1)),
}, CancellationToken.None);
driver.SubscribeCalls.Count.ShouldBe(1);
driver.SubscribeCalls[0].ShouldContain("DR.Temp");
driver.SubscribeCalls[0].ShouldContain("DR.Pressure");
}
[Fact]
public async Task OnDataChange_pushes_to_cache_keyed_by_UNS_path()
{
var sink = new CachedTagUpstreamSource();
var driver = new FakeDriver();
await using var bridge = new DriverSubscriptionBridge(sink, NullLogger<DriverSubscriptionBridge>.Instance);
await bridge.StartAsync(new[]
{
new DriverFeed(driver,
new Dictionary<string, string> { ["/Site/L1/A/Temp"] = "DR.Temp" },
TimeSpan.FromSeconds(1)),
}, CancellationToken.None);
driver.Fire("DR.Temp", 42.5);
sink.ReadTag("/Site/L1/A/Temp").Value.ShouldBe(42.5);
}
[Fact]
public async Task OnDataChange_with_unmapped_fullRef_is_ignored()
{
var sink = new CachedTagUpstreamSource();
var driver = new FakeDriver();
await using var bridge = new DriverSubscriptionBridge(sink, NullLogger<DriverSubscriptionBridge>.Instance);
await bridge.StartAsync(new[]
{
new DriverFeed(driver,
new Dictionary<string, string> { ["/p"] = "DR.A" },
TimeSpan.FromSeconds(1)),
}, CancellationToken.None);
driver.Fire("DR.B", 99); // not in map
sink.ReadTag("/p").StatusCode.ShouldBe(CachedTagUpstreamSource.UpstreamNotConfigured,
"unmapped fullRef shouldn't pollute the cache");
}
[Fact]
public async Task Empty_PathToFullRef_skips_SubscribeAsync_call()
{
var sink = new CachedTagUpstreamSource();
var driver = new FakeDriver();
await using var bridge = new DriverSubscriptionBridge(sink, NullLogger<DriverSubscriptionBridge>.Instance);
await bridge.StartAsync(new[]
{
new DriverFeed(driver, new Dictionary<string, string>(), TimeSpan.FromSeconds(1)),
}, CancellationToken.None);
driver.SubscribeCalls.ShouldBeEmpty();
}
[Fact]
public async Task DisposeAsync_unsubscribes_each_active_subscription()
{
var sink = new CachedTagUpstreamSource();
var driver = new FakeDriver();
var bridge = new DriverSubscriptionBridge(sink, NullLogger<DriverSubscriptionBridge>.Instance);
await bridge.StartAsync(new[]
{
new DriverFeed(driver,
new Dictionary<string, string> { ["/p"] = "DR.A" },
TimeSpan.FromSeconds(1)),
}, CancellationToken.None);
await bridge.DisposeAsync();
driver.Unsubscribed.Count.ShouldBe(1);
driver.Unsubscribed[0].ShouldBeSameAs(driver.LastHandle);
}
[Fact]
public async Task DisposeAsync_unhooks_OnDataChange_so_post_dispose_events_dont_push()
{
var sink = new CachedTagUpstreamSource();
var driver = new FakeDriver();
var bridge = new DriverSubscriptionBridge(sink, NullLogger<DriverSubscriptionBridge>.Instance);
await bridge.StartAsync(new[]
{
new DriverFeed(driver,
new Dictionary<string, string> { ["/p"] = "DR.A" },
TimeSpan.FromSeconds(1)),
}, CancellationToken.None);
await bridge.DisposeAsync();
driver.Fire("DR.A", 999); // post-dispose event
sink.ReadTag("/p").StatusCode.ShouldBe(CachedTagUpstreamSource.UpstreamNotConfigured);
}
[Fact]
public async Task StartAsync_called_twice_throws()
{
var sink = new CachedTagUpstreamSource();
await using var bridge = new DriverSubscriptionBridge(sink, NullLogger<DriverSubscriptionBridge>.Instance);
await bridge.StartAsync(Array.Empty<DriverFeed>(), CancellationToken.None);
await Should.ThrowAsync<InvalidOperationException>(
() => bridge.StartAsync(Array.Empty<DriverFeed>(), CancellationToken.None));
}
[Fact]
public async Task DisposeAsync_is_idempotent()
{
var sink = new CachedTagUpstreamSource();
var bridge = new DriverSubscriptionBridge(sink, NullLogger<DriverSubscriptionBridge>.Instance);
await bridge.DisposeAsync();
await bridge.DisposeAsync(); // must not throw
}
[Fact]
public async Task Subscribe_failure_unhooks_handler_and_propagates()
{
var sink = new CachedTagUpstreamSource();
var failingDriver = new ThrowingDriver();
await using var bridge = new DriverSubscriptionBridge(sink, NullLogger<DriverSubscriptionBridge>.Instance);
var feeds = new[]
{
new DriverFeed(failingDriver,
new Dictionary<string, string> { ["/p"] = "DR.A" },
TimeSpan.FromSeconds(1)),
};
await Should.ThrowAsync<InvalidOperationException>(
() => bridge.StartAsync(feeds, CancellationToken.None));
// Handler should be unhooked — firing now would NPE if it wasn't (event has 0 subs).
failingDriver.HasAnyHandlers.ShouldBeFalse(
"handler must be removed when SubscribeAsync throws so it doesn't leak");
}
[Fact]
public void Null_sink_or_logger_rejected()
{
Should.Throw<ArgumentNullException>(() => new DriverSubscriptionBridge(null!, NullLogger<DriverSubscriptionBridge>.Instance));
Should.Throw<ArgumentNullException>(() => new DriverSubscriptionBridge(new CachedTagUpstreamSource(), null!));
}
private sealed class ThrowingDriver : ISubscribable
{
private EventHandler<DataChangeEventArgs>? _handler;
public bool HasAnyHandlers => _handler is not null;
public event EventHandler<DataChangeEventArgs>? OnDataChange
{
add => _handler = (EventHandler<DataChangeEventArgs>?)Delegate.Combine(_handler, value);
remove => _handler = (EventHandler<DataChangeEventArgs>?)Delegate.Remove(_handler, value);
}
public Task<ISubscriptionHandle> SubscribeAsync(IReadOnlyList<string> _, TimeSpan __, CancellationToken ___) =>
throw new InvalidOperationException("driver offline");
public Task UnsubscribeAsync(ISubscriptionHandle _, CancellationToken __) => Task.CompletedTask;
}
}

View File

@@ -0,0 +1,162 @@
using Microsoft.Extensions.Logging.Abstractions;
using Serilog;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
using ZB.MOM.WW.OtOpcUa.Server.Phase7;
namespace ZB.MOM.WW.OtOpcUa.Server.Tests.Phase7;
/// <summary>
/// Phase 7 follow-up (task #243) — verifies the composer that maps Config DB
/// rows to runtime engine definitions + wires up VirtualTagEngine +
/// ScriptedAlarmEngine + historian routing.
/// </summary>
[Trait("Category", "Unit")]
public sealed class Phase7EngineComposerTests
{
private static Script ScriptRow(string id, string source) => new()
{
ScriptRowId = Guid.NewGuid(), GenerationId = 1,
ScriptId = id, Name = id, SourceCode = source, SourceHash = "h",
};
private static VirtualTag VtRow(string id, string scriptId) => new()
{
VirtualTagRowId = Guid.NewGuid(), GenerationId = 1,
VirtualTagId = id, EquipmentId = "eq-1", Name = id,
DataType = "Float32", ScriptId = scriptId,
};
private static ScriptedAlarm AlarmRow(string id, string scriptId) => new()
{
ScriptedAlarmRowId = Guid.NewGuid(), GenerationId = 1,
ScriptedAlarmId = id, EquipmentId = "eq-1", Name = id,
AlarmType = "LimitAlarm", Severity = 500,
MessageTemplate = "x", PredicateScriptId = scriptId,
};
[Fact]
public void Compose_empty_rows_returns_Empty_sentinel()
{
var result = Phase7EngineComposer.Compose(
scripts: [],
virtualTags: [],
scriptedAlarms: [],
upstream: new CachedTagUpstreamSource(),
alarmStateStore: new InMemoryAlarmStateStore(),
historianSink: NullAlarmHistorianSink.Instance,
rootScriptLogger: new LoggerConfiguration().CreateLogger(),
loggerFactory: NullLoggerFactory.Instance);
result.ShouldBeSameAs(Phase7ComposedSources.Empty);
result.VirtualReadable.ShouldBeNull();
result.ScriptedAlarmReadable.ShouldBeNull();
}
[Fact]
public void Compose_VirtualTag_rows_returns_non_null_VirtualReadable()
{
var scripts = new[] { ScriptRow("scr-1", "return 1;") };
var vtags = new[] { VtRow("vt-1", "scr-1") };
var result = Phase7EngineComposer.Compose(
scripts, vtags, [],
upstream: new CachedTagUpstreamSource(),
alarmStateStore: new InMemoryAlarmStateStore(),
historianSink: NullAlarmHistorianSink.Instance,
rootScriptLogger: new LoggerConfiguration().CreateLogger(),
loggerFactory: NullLoggerFactory.Instance);
result.VirtualReadable.ShouldNotBeNull();
result.ScriptedAlarmReadable.ShouldBeNull("no alarms configured");
result.Disposables.Count.ShouldBeGreaterThan(0);
}
[Fact]
public void Compose_ScriptedAlarm_rows_returns_non_null_ScriptedAlarmReadable()
{
var scripts = new[] { ScriptRow("scr-1", "return false;") };
var alarms = new[] { AlarmRow("al-1", "scr-1") };
var result = Phase7EngineComposer.Compose(
scripts, [], alarms,
upstream: new CachedTagUpstreamSource(),
alarmStateStore: new InMemoryAlarmStateStore(),
historianSink: NullAlarmHistorianSink.Instance,
rootScriptLogger: new LoggerConfiguration().CreateLogger(),
loggerFactory: NullLoggerFactory.Instance);
result.ScriptedAlarmReadable.ShouldNotBeNull("task #245 — alarm Active state readable");
result.VirtualReadable.ShouldBeNull();
}
[Fact]
public void Compose_missing_script_reference_throws_with_actionable_message()
{
var vtags = new[] { VtRow("vt-1", "scr-missing") };
Should.Throw<InvalidOperationException>(() =>
Phase7EngineComposer.Compose(
scripts: [],
vtags, [],
upstream: new CachedTagUpstreamSource(),
alarmStateStore: new InMemoryAlarmStateStore(),
historianSink: NullAlarmHistorianSink.Instance,
rootScriptLogger: new LoggerConfiguration().CreateLogger(),
loggerFactory: NullLoggerFactory.Instance))
.Message.ShouldContain("scr-missing");
}
[Fact]
public void Compose_disabled_VirtualTag_is_skipped()
{
var scripts = new[] { ScriptRow("scr-1", "return 1;") };
var disabled = VtRow("vt-1", "scr-1");
disabled.Enabled = false;
var defs = Phase7EngineComposer.ProjectVirtualTags(
new[] { disabled },
new Dictionary<string, Script> { ["scr-1"] = scripts[0] }).ToList();
defs.ShouldBeEmpty();
}
[Fact]
public void ProjectVirtualTags_maps_timer_interval_milliseconds_to_TimeSpan()
{
var scripts = new[] { ScriptRow("scr-1", "return 1;") };
var vt = VtRow("vt-1", "scr-1");
vt.TimerIntervalMs = 2500;
var def = Phase7EngineComposer.ProjectVirtualTags(
new[] { vt },
new Dictionary<string, Script> { ["scr-1"] = scripts[0] }).Single();
def.TimerInterval.ShouldBe(TimeSpan.FromMilliseconds(2500));
}
[Fact]
public void ProjectScriptedAlarms_maps_Severity_numeric_to_AlarmSeverity_bucket()
{
var scripts = new[] { ScriptRow("scr-1", "return true;") };
var buckets = new[] { (1, AlarmSeverity.Low), (250, AlarmSeverity.Low),
(251, AlarmSeverity.Medium), (500, AlarmSeverity.Medium),
(501, AlarmSeverity.High), (750, AlarmSeverity.High),
(751, AlarmSeverity.Critical), (1000, AlarmSeverity.Critical) };
foreach (var (input, expected) in buckets)
{
var row = AlarmRow("a1", "scr-1");
row.Severity = input;
var def = Phase7EngineComposer.ProjectScriptedAlarms(
new[] { row },
new Dictionary<string, Script> { ["scr-1"] = scripts[0] }).Single();
def.Severity.ShouldBe(expected, $"severity {input} should map to {expected}");
}
}
}

View File

@@ -0,0 +1,120 @@
using Microsoft.Extensions.Logging.Abstractions;
using Serilog;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
using ZB.MOM.WW.OtOpcUa.Server.Phase7;
namespace ZB.MOM.WW.OtOpcUa.Server.Tests.Phase7;
/// <summary>
/// Task #245 — covers the IReadable adapter that surfaces each scripted alarm's
/// live <c>ActiveState</c> so OPC UA variable reads on Source=ScriptedAlarm nodes
/// return the predicate truth instead of BadNotFound.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ScriptedAlarmReadableTests
{
private static (ScriptedAlarmEngine engine, CachedTagUpstreamSource upstream) BuildEngineWith(
params (string alarmId, string predicateSource)[] alarms)
{
var upstream = new CachedTagUpstreamSource();
var logger = new LoggerConfiguration().CreateLogger();
var factory = new ScriptLoggerFactory(logger);
var engine = new ScriptedAlarmEngine(upstream, new InMemoryAlarmStateStore(), factory, logger);
var defs = alarms.Select(a => new ScriptedAlarmDefinition(
AlarmId: a.alarmId,
EquipmentPath: "/eq",
AlarmName: a.alarmId,
Kind: AlarmKind.LimitAlarm,
Severity: AlarmSeverity.Medium,
MessageTemplate: "x",
PredicateScriptSource: a.predicateSource)).ToList();
engine.LoadAsync(defs, CancellationToken.None).GetAwaiter().GetResult();
return (engine, upstream);
}
[Fact]
public async Task Reads_return_false_for_newly_loaded_alarm_with_inactive_predicate()
{
var (engine, _) = BuildEngineWith(("a1", "return false;"));
using var _e = engine;
var readable = new ScriptedAlarmReadable(engine);
var result = await readable.ReadAsync(["a1"], CancellationToken.None);
result.Count.ShouldBe(1);
result[0].Value.ShouldBe(false);
result[0].StatusCode.ShouldBe(0u, "Good quality when the engine has state");
}
[Fact]
public async Task Reads_return_true_when_predicate_evaluates_to_active()
{
var (engine, upstream) = BuildEngineWith(
("tempAlarm", "return ctx.GetTag(\"/Site/Line/Cell/Temp\").Value is double d && d > 100;"));
using var _e = engine;
// Seed the upstream value + nudge the engine so the alarm transitions to Active.
upstream.Push("/Site/Line/Cell/Temp",
new DataValueSnapshot(150.0, 0u, DateTime.UtcNow, DateTime.UtcNow));
// Allow the engine's change-driven cascade to run.
await Task.Delay(50);
var readable = new ScriptedAlarmReadable(engine);
var result = await readable.ReadAsync(["tempAlarm"], CancellationToken.None);
result[0].Value.ShouldBe(true);
}
[Fact]
public async Task Reads_return_BadNodeIdUnknown_for_missing_alarm()
{
var (engine, _) = BuildEngineWith(("a1", "return false;"));
using var _e = engine;
var readable = new ScriptedAlarmReadable(engine);
var result = await readable.ReadAsync(["a-not-loaded"], CancellationToken.None);
result[0].Value.ShouldBeNull();
result[0].StatusCode.ShouldBe(0x80340000u,
"BadNodeIdUnknown surfaces a misconfiguration, not a silent false");
}
[Fact]
public async Task Reads_batch_round_trip_preserves_order()
{
var (engine, _) = BuildEngineWith(
("a1", "return false;"),
("a2", "return false;"));
using var _e = engine;
var readable = new ScriptedAlarmReadable(engine);
var result = await readable.ReadAsync(["a2", "missing", "a1"], CancellationToken.None);
result.Count.ShouldBe(3);
result[0].Value.ShouldBe(false); // a2
result[1].StatusCode.ShouldBe(0x80340000u); // missing
result[2].Value.ShouldBe(false); // a1
}
[Fact]
public void Null_engine_rejected()
{
Should.Throw<ArgumentNullException>(() => new ScriptedAlarmReadable(null!));
}
[Fact]
public async Task Null_fullReferences_rejected()
{
var (engine, _) = BuildEngineWith(("a1", "return false;"));
using var _e = engine;
var readable = new ScriptedAlarmReadable(engine);
await Should.ThrowAsync<ArgumentNullException>(
() => readable.ReadAsync(null!, CancellationToken.None));
}
}