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
This commit is contained in:
Joseph Doherty
2026-04-20 21:30:56 -04:00
parent f3053580a0
commit d6a8bb1064
4 changed files with 205 additions and 8 deletions

View File

@@ -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<VirtualTagDefinition> ProjectVirtualTags(