From d6a8bb1064ba3cc5b4ea4cfbd3c8b40163009350 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 20 Apr 2026 21:30:56 -0400 Subject: [PATCH] =?UTF-8?q?Phase=207=20follow-up=20#245=20=E2=80=94=20Scri?= =?UTF-8?q?ptedAlarmReadable=20adapter=20over=20engine=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Phase7/Phase7EngineComposer.cs | 16 +-- .../Phase7/ScriptedAlarmReadable.cs | 58 +++++++++ .../Phase7/Phase7EngineComposerTests.cs | 19 +++ .../Phase7/ScriptedAlarmReadableTests.cs | 120 ++++++++++++++++++ 4 files changed, 205 insertions(+), 8 deletions(-) create mode 100644 src/ZB.MOM.WW.OtOpcUa.Server/Phase7/ScriptedAlarmReadable.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Server.Tests/Phase7/ScriptedAlarmReadableTests.cs diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/Phase7/Phase7EngineComposer.cs b/src/ZB.MOM.WW.OtOpcUa.Server/Phase7/Phase7EngineComposer.cs index 289d841..b93e2a1 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Server/Phase7/Phase7EngineComposer.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Server/Phase7/Phase7EngineComposer.cs @@ -73,7 +73,7 @@ public static class Phase7EngineComposer disposables.Add(vtEngine); } - ScriptedAlarmSource? alarmSource = null; + IReadable? alarmReadable = null; if (scriptedAlarms.Count > 0) { var alarmDefs = ProjectScriptedAlarms(scriptedAlarms, scriptById).ToList(); @@ -83,17 +83,17 @@ public static class Phase7EngineComposer var engineLogger = loggerFactory.CreateLogger("Phase7HistorianRouter"); alarmEngine.OnEvent += (_, e) => _ = RouteToHistorianAsync(e, historianSink, engineLogger); alarmEngine.LoadAsync(alarmDefs, CancellationToken.None).GetAwaiter().GetResult(); - alarmSource = new ScriptedAlarmSource(alarmEngine); + 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); } - // ScriptedAlarmSource is an IAlarmSource, not an IReadable — scripted-alarm - // variable-read dispatch (task #245) needs a dedicated engine-state adapter. Until - // that ships, reads against Source=ScriptedAlarm nodes return BadNotFound per the - // DriverNodeManager null-check path (the ADR-002 "misconfiguration not silent - // fallback" signal). The alarm event stream still fires via IAlarmSource. - return new Phase7ComposedSources(vtSource, ScriptedAlarmReadable: null, disposables); + return new Phase7ComposedSources(vtSource, alarmReadable, disposables); } internal static IEnumerable ProjectVirtualTags( diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/Phase7/ScriptedAlarmReadable.cs b/src/ZB.MOM.WW.OtOpcUa.Server/Phase7/ScriptedAlarmReadable.cs new file mode 100644 index 0000000..3ce3303 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Server/Phase7/ScriptedAlarmReadable.cs @@ -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; + +/// +/// adapter exposing each scripted alarm's current +/// as an OPC UA boolean. Phase 7 follow-up (task #245). +/// +/// +/// +/// Paired with the dispatch in +/// DriverNodeManager.OnReadValue. Full-reference lookup is the +/// ScriptedAlarmId the walker wrote into DriverAttributeInfo.FullName +/// when emitting the alarm variable node. +/// +/// +/// Unknown alarm ids return BadNodeIdUnknown so misconfiguration surfaces +/// instead of silently reading false. Alarms whose predicate has never +/// been evaluated (brand new, before the engine's first cascade tick) report +/// via , +/// which matches the Part 9 initial-state semantics. +/// +/// +public sealed class ScriptedAlarmReadable : IReadable +{ + /// OPC UA StatusCodes.BadNodeIdUnknown — kept local so we don't pull the OPC stack. + private const uint BadNodeIdUnknown = 0x80340000; + + private readonly ScriptedAlarmEngine _engine; + + public ScriptedAlarmReadable(ScriptedAlarmEngine engine) + { + _engine = engine ?? throw new ArgumentNullException(nameof(engine)); + } + + public Task> ReadAsync( + IReadOnlyList 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>(results); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/Phase7/Phase7EngineComposerTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/Phase7/Phase7EngineComposerTests.cs index 03f9f3e..89f4469 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/Phase7/Phase7EngineComposerTests.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/Phase7/Phase7EngineComposerTests.cs @@ -72,9 +72,28 @@ public sealed class Phase7EngineComposerTests 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() { diff --git a/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/Phase7/ScriptedAlarmReadableTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/Phase7/ScriptedAlarmReadableTests.cs new file mode 100644 index 0000000..e21c16f --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/Phase7/ScriptedAlarmReadableTests.cs @@ -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; + +/// +/// Task #245 — covers the IReadable adapter that surfaces each scripted alarm's +/// live ActiveState so OPC UA variable reads on Source=ScriptedAlarm nodes +/// return the predicate truth instead of BadNotFound. +/// +[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(() => 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( + () => readable.ReadAsync(null!, CancellationToken.None)); + } +}