From d0af8847602dedd6e7956175c84f3149be45de5c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 1 Aug 2026 13:12:30 -0400 Subject: [PATCH] feat(scripts): add the Alarms.CurrentAsync() read accessor for site scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MES alarm-status API §5.2 (docs/plans/2026-06-30-mes-alarm-status-api.md, Phase 1 tasks 2-4). Site `Call` scripts had NO way to read alarm condition state: the `Alarm` global exists only inside an on-trigger handler and describes the one alarm that fired, and native mirrored conditions were reachable only from the Debug View. That gap blocked the CvdReactor SimpleAlarmStatus/AlarmStatus scripts entirely -- they cannot be written without it. `Alarms.CurrentAsync()` closes it. The data was already local: the script runs inside its own Instance Actor's context, so this is a LOCAL Ask -- the same mechanism attribute reads use, no cross-cluster hop. A dedicated GetAlarmSnapshotRequest/Response is used rather than reusing DebugSnapshotRequest, which would materialise every attribute value on every alarm poll; both are served from the same BuildAlarmStatesSnapshot(), so the script view and the operator's Debug View can never disagree. Deliberate shape decisions: - NOT scope-prefixed, unlike Attributes. Alarm identity is not a scope-relative attribute name (computed alarms are keyed by configured name, native conditions by a source-supplied reference), so prefixing would hand a composed script a silently truncated list. - Read-only. Native alarms are a read-only mirror of the source (no ack-back), so no acknowledge/shelve operation is exposed. - Placeholder rows are NOT pre-filtered: a caller must be able to tell "binding configured and quiet" from "binding unknown". The documented filter is `Active && !IsConfiguredPlaceholder`. - ScriptAlarm lives in Commons so the runtime accessor and the compile-only surface project to the SAME type -- a script binding at the design-time gate binds identically at the site. Condition is the authority for active/acked/severity, so one filter expression works across computed and native alarms. Mirrored on BOTH design-time surfaces. ScriptCompileSurface is covered by the reflection parity guard (AlarmsAccessor added to its mirror pairs). The Central UI Test-Run SandboxScriptHost is the third, hand-maintained mirror that the parity test cannot reach (Central UI does not reference Site Runtime); without it the design page would false-flag CS1061 on scripts the deploy gate accepts. It throws a labelled ScriptSandboxException at run time -- there is no central route to per-instance alarm state, and returning an empty list would read as "nothing is in alarm", which is worse than an error. ScriptTrustPolicy needs NO change, and the reason is structural rather than incidental: the trust boundary is a deny-list over API roots, not an allow-list of context members. A test pins that no ForbiddenScopes entry prefixes the Commons script-surface namespace, so a future deny-list entry cannot silently make ScriptAlarm untouchable. Tests: 6 accessor cases (Ask contract, full native projection incl. AckTime, unacked, computed-alarm derivation, placeholder visibility, scope-independence), 2 InstanceActor snapshot cases incl. equality with the Debug View row set, the full MES script shape compiling against ScriptCompileSurface, 2 trust cases, and a sandbox diagnose-clean case reading every projected ScriptAlarm field. --- .../ScriptAnalysis/SandboxScriptHost.cs | 30 +++ .../Instance/GetAlarmSnapshotRequest.cs | 40 ++++ .../Types/Scripts/ScriptAlarm.cs | 79 +++++++ .../ScriptCompileSurface.cs | 21 ++ .../Actors/InstanceActor.cs | 26 +++ .../Scripts/ScopeAccessors.cs | 53 +++++ .../Scripts/ScriptCompilationService.cs | 14 ++ .../Scripts/ScriptRuntimeContext.cs | 66 ++++++ .../ScriptAnalysisServiceTests.cs | 32 +++ .../RoslynScriptCompilerTests.cs | 47 ++++ .../ScriptTrustValidatorTests.cs | 30 +++ .../Actors/InstanceActorNativeAlarmTests.cs | 58 +++++ .../Scripts/AlarmsAccessorTests.cs | 204 ++++++++++++++++++ .../Scripts/CompileSurfaceParityTests.cs | 4 + 14 files changed, 704 insertions(+) create mode 100644 src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Instance/GetAlarmSnapshotRequest.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.Commons/Types/Scripts/ScriptAlarm.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/AlarmsAccessorTests.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/SandboxScriptHost.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/SandboxScriptHost.cs index 1e8f40c3..662c3ab6 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/SandboxScriptHost.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/SandboxScriptHost.cs @@ -79,6 +79,36 @@ public class SandboxScriptHost /// public SandboxCompositionAccessor? Parent => Scope.ParentPath == null ? null : new SandboxCompositionAccessor(Instance, Scope.ParentPath); + + /// + /// Read-only accessor for the instance's current alarm conditions, mirroring the site + /// runtime's Alarms global (MES alarm-status API §5.2). Present so a script using + /// Alarms.CurrentAsync() BINDS in the Test Run editor — without it the editor + /// would show a spurious CS1061 on a script that deploys and runs perfectly. + /// + public SandboxAlarmsAccessor Alarms { get; } = new(); +} + +/// +/// Sandbox mirror of ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts.AlarmsAccessor. +/// Exists for editor/compile parity only: alarm state is held per-instance by the site's +/// Instance Actor and there is no central Test Run route to it, so an actual call throws a +/// clearly-labelled rather than quietly returning an +/// empty list that would read as "nothing is in alarm". +/// +public class SandboxAlarmsAccessor +{ + /// + /// Mirrors AlarmsAccessor.CurrentAsync for compile parity; unsupported at run + /// time in the central Test Run sandbox. + /// + /// Token used to cancel the read (unused in the sandbox). + /// Never returns; always throws in the sandbox. + public Task> CurrentAsync(CancellationToken cancellationToken = default) + => throw new ScriptSandboxException( + "Alarms.CurrentAsync() reads the site Instance Actor's live alarm conditions, " + + "which aren't available in the central Test Run sandbox — deploy to a site to " + + "exercise alarm-reading scripts."); } /// diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Instance/GetAlarmSnapshotRequest.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Instance/GetAlarmSnapshotRequest.cs new file mode 100644 index 00000000..9effbb09 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Instance/GetAlarmSnapshotRequest.cs @@ -0,0 +1,40 @@ +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming; + +namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance; + +/// +/// Request for the Instance Actor's CURRENT alarm-condition snapshot, backing the +/// script-facing Alarms.CurrentAsync() accessor (MES alarm-status API §5.2). +/// +/// +/// A dedicated message rather than a reuse of DebugSnapshotRequest: the script +/// accessor needs only the alarm rows, and the debug snapshot additionally materialises +/// every attribute value on every call. Same local Ask path as +/// — the script runs against its own Instance Actor, so +/// there is no cross-cluster hop. +/// +/// +/// Application-level correlation id echoed on the response. +/// Unique name of the instance whose alarms are requested. +/// When the request was issued (UTC). +public record GetAlarmSnapshotRequest( + string CorrelationId, + string InstanceUniqueName, + DateTimeOffset Timestamp); + +/// +/// The Instance Actor's reply to a — the same +/// enriched alarm rows the Debug View snapshot carries (computed alarms, mirrored native +/// conditions, and configured-but-quiet native binding placeholders), projected to +/// ScriptAlarm by the accessor rather than by the actor so the message stays a +/// plain mirror of the internal state. +/// +/// Correlation id from the originating request. +/// Unique name of the instance the snapshot belongs to. +/// The instance's current alarm rows. +/// When the snapshot was taken (UTC). +public record GetAlarmSnapshotResponse( + string CorrelationId, + string InstanceUniqueName, + IReadOnlyList Alarms, + DateTimeOffset Timestamp); diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Scripts/ScriptAlarm.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Scripts/ScriptAlarm.cs new file mode 100644 index 00000000..a637e652 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Scripts/ScriptAlarm.cs @@ -0,0 +1,79 @@ +namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts; + +/// +/// One alarm condition as seen by a site script through Alarms.CurrentAsync() +/// (MES alarm-status API §5.2). A flat, script-friendly projection of the instance's +/// retained AlarmStateChanged — computed alarms and mirrored native (OPC UA A&C / +/// MxAccess Gateway) conditions alike — so a script can answer "what is currently in +/// alarm on this instance?" without knowing the actor-internal message shape. +/// +/// +/// Read-only snapshot. The values are the instance's state at the moment the +/// accessor's Ask was served; nothing here writes back to the source. Native alarms are a +/// read-only mirror by design (no ack-back). +/// +/// +/// +/// Placement in Commons so the SiteRuntime runtime accessor and the ScriptAnalysis +/// compile-only surface project to the SAME type — a script that binds against the +/// design-time surface therefore also binds at the site. Commons is already in +/// ScriptTrustPolicy.DefaultAssemblies, so the type resolves in the script +/// compilation without widening the trust reference set. +/// +/// +/// +/// The alarm's name on the instance — the computed alarm's configured name, or the native +/// condition's per-condition source reference. +/// +/// Native per-condition key (e.g. "Tank01.Level.HiHi"); empty for computed alarms. +/// +/// Canonical name of the native alarm SOURCE BINDING this condition belongs to +/// (e.g. "LeftSideAlarms"); empty for computed alarms. Scripts scope by source with this. +/// +/// Whether the condition is currently active (in alarm). +/// Whether the condition has been acknowledged at the source. +/// Confirmed vs unconfirmed; null when the condition is not confirmable. +/// Whether the source has shelved the condition (any shelve sub-state). +/// Whether the source has suppressed the condition. +/// Severity on the unified 0–1000 scale. +/// "Computed", "NativeOpcUa" or "NativeMxAccess". +/// Event/per-band operator message; may be empty. +/// Native alarm type (e.g. "AnalogLimitAlarm.HiHi"); empty for computed alarms. +/// Native alarm category/taxonomy; empty for computed alarms. +/// Operator who acknowledged at the source (display-only); empty otherwise. +/// Operator comment captured at the source (display-only); empty otherwise. +/// When the condition originally became active, if the source reports it. +/// Timestamp of the transition this snapshot row reflects. +/// +/// When the condition was acknowledged, or null while unacknowledged. See +/// AlarmStateChanged.AckTime for provenance — source ack instant where the protocol +/// supplies one (OPC UA A&C), DCL observation time of the ack transition otherwise. +/// +/// Current source value (display-only); empty for computed alarms. +/// Limit/threshold value for native limit alarms (display-only); empty otherwise. +/// +/// True for a placeholder row standing in for a CONFIGURED native source binding that +/// currently holds no conditions. Scripts enumerating real alarms should filter these out. +/// +public sealed record ScriptAlarm( + string Name, + string SourceReference, + string NativeSourceCanonicalName, + bool Active, + bool Acknowledged, + bool? Confirmed, + bool Shelved, + bool Suppressed, + int Severity, + string Kind, + string Message, + string AlarmTypeName, + string Category, + string OperatorUser, + string OperatorComment, + DateTimeOffset? OriginalRaiseTime, + DateTimeOffset Timestamp, + DateTimeOffset? AckTime, + string CurrentValue, + string LimitValue, + bool IsConfiguredPlaceholder); diff --git a/src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ScriptCompileSurface.cs b/src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ScriptCompileSurface.cs index 4fb2251a..40e4f6cc 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ScriptCompileSurface.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ScriptCompileSurface.cs @@ -64,6 +64,9 @@ public sealed class ScriptCompileSurface /// Mirrors ScriptGlobals.Parent. public CompileCompositionAccessor? Parent => throw new NotSupportedException(CompileOnly); + /// Mirrors ScriptGlobals.Alarms. + public CompileAlarmsAccessor Alarms => throw new NotSupportedException(CompileOnly); + /// Compile-only mirror of ScriptRuntimeContext (the Instance global). public sealed class CompileInstance { @@ -98,6 +101,24 @@ public sealed class ScriptCompileSurface /// Mirrors ScriptRuntimeContext.Tracking. public CompileTracking Tracking => throw new NotSupportedException(CompileOnly); + + /// Mirrors ScriptRuntimeContext.Alarms. + public CompileAlarmsAccessor Alarms => throw new NotSupportedException(CompileOnly); + } + + /// + /// Compile-only mirror of AlarmsAccessor (MES alarm-status API §5.2). + /// returns the SAME type the runtime + /// accessor returns — not a compile-only stand-in — so a script that reads + /// a.Severity / a.AckTime binds identically at design time and at the site. + /// + public sealed class CompileAlarmsAccessor + { + /// Mirrors AlarmsAccessor.CurrentAsync. + /// Token used to cancel the read. + /// The instance's current alarm conditions. + public Task> CurrentAsync(CancellationToken cancellationToken = default) + => throw new NotSupportedException(CompileOnly); } /// Compile-only mirror of ScriptRuntimeContext.ExternalSystemHelper. diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs index f1e03047..aeed7315 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs @@ -252,6 +252,9 @@ public class InstanceActor : ReceiveActor // Debug snapshot (one-shot, no subscription) Receive(HandleDebugSnapshot); + // Script-facing alarm snapshot backing Alarms.CurrentAsync() + Receive(HandleGetAlarmSnapshot); + // Handle internal messages Receive(HandleOverridesLoaded); } @@ -1138,6 +1141,29 @@ public class InstanceActor : ReceiveActor _alarmTimestamps.Remove(dropped.SourceReference); } + /// + /// Serves the script-facing alarm snapshot behind Alarms.CurrentAsync() + /// (MES alarm-status API §5.2). Reuses , so a + /// script sees exactly the alarm set the Debug View shows for the same instant — + /// computed alarms, mirrored native conditions, and the configured-but-quiet native + /// binding placeholders (the accessor's caller decides whether to filter those). + /// + /// + /// Deliberately does NOT build the attribute list the debug snapshot carries: a script + /// polling its alarms should not pay to materialise every attribute value. The reply is + /// a plain mirror of the retained AlarmStateChanged rows; the projection to + /// ScriptAlarm happens in the accessor, keeping the actor free of script types. + /// + /// + private void HandleGetAlarmSnapshot(GetAlarmSnapshotRequest request) + { + Sender.Tell(new GetAlarmSnapshotResponse( + request.CorrelationId, + _instanceUniqueName, + BuildAlarmStatesSnapshot(), + DateTimeOffset.UtcNow)); + } + /// /// Debug view subscribe — returns snapshot and begins streaming. /// diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScopeAccessors.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScopeAccessors.cs index 48ed18ca..2aade65f 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScopeAccessors.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScopeAccessors.cs @@ -1,4 +1,5 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; @@ -292,6 +293,58 @@ public class ChildrenAccessor } } +/// +/// Read-only accessor for the instance's CURRENT alarm conditions, exposed to scripts as +/// the Alarms global (MES alarm-status API §5.2). It closes the gap that a site +/// Call script previously had NO way to read alarm state: the on-trigger +/// Alarm context only exists inside an alarm handler, and native mirrored +/// conditions were reachable only from the Debug View. +/// +/// +/// Not scope-prefixed, unlike . Alarm identity is not +/// a scope-relative attribute name: computed alarms are keyed by their configured name and +/// native conditions by a source-supplied reference, and a caller typically wants the whole +/// instance's alarm set to filter itself (e.g. by +/// ). Every scope therefore sees the +/// same list rather than a silently truncated one. +/// +/// +/// +/// Read-only by design. Native alarms are a read-only mirror of the source (no +/// ack-back), so this accessor deliberately offers no acknowledge/shelve operation. +/// +/// +public class AlarmsAccessor +{ + private readonly ScriptRuntimeContext _ctx; + + /// + /// Initializes a new alarms accessor over a script runtime context. + /// + /// The script runtime context whose Instance Actor is queried. + public AlarmsAccessor(ScriptRuntimeContext ctx) + { + _ctx = ctx; + } + + /// + /// Returns a snapshot of the instance's current alarm conditions — computed alarms and + /// mirrored native (OPC UA A&C / MxAccess Gateway) conditions alike. Served by a + /// LOCAL Ask to the instance's own actor, the same path attribute reads use. + /// + /// + /// The list includes placeholder rows for CONFIGURED native source bindings that hold no + /// conditions (), so a caller can tell + /// "binding is quiet" from "binding is unknown"; callers enumerating real alarms should + /// filter on Active && !IsConfiguredPlaceholder. + /// + /// + /// Cancels the wait for the actor's reply. + /// The instance's current alarm conditions; empty when it holds none. + public Task> CurrentAsync(CancellationToken cancellationToken = default) + => _ctx.GetAlarmsAsync(cancellationToken); +} + internal static class ScopeAccessorFactory { /// diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptCompilationService.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptCompilationService.cs index 485323f3..78efb0f3 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptCompilationService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptCompilationService.cs @@ -269,6 +269,20 @@ public class ScriptGlobals /// public ChildrenAccessor Children => new(Instance, Scope.SelfPath); + /// + /// Read-only view of the instance's CURRENT alarm conditions — computed alarms + /// and mirrored native (OPC UA A&C / MxAccess Gateway) conditions alike. + /// Usage: var alarms = await Alarms.CurrentAsync(); + /// + /// + /// Unlike the global — which exists only inside an on-trigger + /// handler and describes the ONE alarm that fired — this is available to every script + /// and describes the whole instance. Not scope-prefixed: alarm identity is not a + /// scope-relative attribute name, so every scope sees the same list. + /// + /// + public AlarmsAccessor Alarms => Instance.Alarms; + /// /// Parent composition (null when this script is on a root-level template). /// Parent.Attributes["SpeedRPM"] reaches the parent's attribute; diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptRuntimeContext.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptRuntimeContext.cs index a7ac779e..06ea16c8 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptRuntimeContext.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptRuntimeContext.cs @@ -9,9 +9,11 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification; using ZB.MOM.WW.ScadaBridge.Commons.Messages.ScriptExecution; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming; using ZB.MOM.WW.ScadaBridge.Commons.Types; using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts; using AuditEvent = ZB.MOM.WW.Audit.AuditEvent; using ZB.MOM.WW.ScadaBridge.SiteEventLogging; using ZB.MOM.WW.ScadaBridge.StoreAndForward; @@ -696,6 +698,70 @@ public class ScriptRuntimeContext // threaded so NotifyTarget.Send can stamp it onto NotificationSubmit. _sourceNode); + /// + /// Read-only access to the instance's CURRENT alarm conditions (MES alarm-status API + /// §5.2). Alarms.CurrentAsync() returns one per alarm + /// the instance holds — computed alarms and mirrored native (OPC UA A&C / MxAccess + /// Gateway) conditions alike — so a Call script can answer "what is in alarm + /// right now?" without an on-trigger Alarm context. + /// + public AlarmsAccessor Alarms => new(this); + + /// + /// Backing read for : Asks THIS instance's + /// Instance Actor for its retained alarm rows and projects them to + /// . The script executes inside its own instance's context, so + /// this is a LOCAL Ask on the same node — the same mechanism as + /// , never a cross-cluster hop. + /// + /// + /// Cancels the wait for the actor's reply. Defaults to ; + /// the Ask is bounded by the context's ask timeout regardless. + /// + /// The instance's current alarm conditions; empty when it holds none. + internal async Task> GetAlarmsAsync( + CancellationToken cancellationToken = default) + { + var request = new GetAlarmSnapshotRequest( + Guid.NewGuid().ToString(), _instanceName, DateTimeOffset.UtcNow); + + var response = await _instanceActor.Ask( + request, _askTimeout, cancellationToken); + + return response.Alarms.Select(ToScriptAlarm).ToList(); + } + + /// + /// Projects a retained onto the flat, script-facing + /// shape. Condition is the authority for the + /// active/acked/severity fields (it already folds a computed alarm's State + Priority + /// into the same unified shape), so computed and native alarms read identically from a + /// script. Shelved collapses the shelve sub-states to a single boolean — scripts + /// care whether the operator has parked the alarm, not which shelve flavour was used. + /// + private static ScriptAlarm ToScriptAlarm(AlarmStateChanged a) => new( + Name: a.AlarmName, + SourceReference: a.SourceReference, + NativeSourceCanonicalName: a.NativeSourceCanonicalName, + Active: a.Condition.Active, + Acknowledged: a.Condition.Acknowledged, + Confirmed: a.Condition.Confirmed, + Shelved: a.Condition.Shelve != AlarmShelveState.Unshelved, + Suppressed: a.Condition.Suppressed, + Severity: a.Condition.Severity, + Kind: a.Kind.ToString(), + Message: a.Message, + AlarmTypeName: a.AlarmTypeName, + Category: a.Category, + OperatorUser: a.OperatorUser, + OperatorComment: a.OperatorComment, + OriginalRaiseTime: a.OriginalRaiseTime, + Timestamp: a.Timestamp, + AckTime: a.AckTime, + CurrentValue: a.CurrentValue, + LimitValue: a.LimitValue, + IsConfiguredPlaceholder: a.IsConfiguredPlaceholder); + /// /// Site-local tracking-status API for cached operations. /// Tracking.Status(trackedOperationId) reads the site SQLite tracking row diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ScriptAnalysis/ScriptAnalysisServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ScriptAnalysis/ScriptAnalysisServiceTests.cs index 041bf6ab..9799f3f7 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ScriptAnalysis/ScriptAnalysisServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ScriptAnalysis/ScriptAnalysisServiceTests.cs @@ -719,4 +719,36 @@ public class ScriptAnalysisServiceTests Assert.DoesNotContain(resp.Markers, m => m.Code.StartsWith("CS")); Assert.DoesNotContain(resp.Markers, m => m.Code.StartsWith("SCADA")); } + + // ── Alarms read surface (MES alarm-status API §5.2) ─────────────────── + + [Fact] + public void InstanceScript_AlarmsCurrentAsync_DiagnoseClean() + { + // SandboxScriptHost is the THIRD hand-maintained mirror of the runtime globals + // (Central UI does not reference Site Runtime, so the reflection parity test in + // SiteRuntime.Tests cannot cover it). Without `Alarms` here the editor would + // false-flag CS1061 on the MES alarm-status scripts even though the deploy gate + // accepts them. Every ScriptAlarm field those scripts project is read below, so a + // rename on the record surfaces here rather than in the design page. + var code = + "var alarms = await Alarms.CurrentAsync();\n" + + "var rows = alarms\n" + + " .Where(a => a.Active && !a.IsConfiguredPlaceholder)\n" + + " .Where(a => a.NativeSourceCanonicalName.StartsWith(\"Left\"))\n" + + " .Where(a => a.Severity >= 900 && a.Severity <= 999)\n" + + " .Select(a => new {\n" + + " Name = a.Name,\n" + + " Description = string.IsNullOrEmpty(a.Message) ? a.AlarmTypeName : a.Message,\n" + + " StatusCode = a.Acknowledged ? \"Triggered.Acked\" : \"Triggered\",\n" + + " TriggeredDT = a.OriginalRaiseTime ?? a.Timestamp,\n" + + " AckDT = a.AckTime,\n" + + " AckComment = a.OperatorComment })\n" + + " .ToList();\n" + + "return new { WasSuccessful = true, Alarms = rows };"; + var resp = _svc.Diagnose(new DiagnoseRequest(code)); + + Assert.DoesNotContain(resp.Markers, m => m.Code.StartsWith("CS")); + Assert.DoesNotContain(resp.Markers, m => m.Code.StartsWith("SCADA")); + } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/RoslynScriptCompilerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/RoslynScriptCompilerTests.cs index 28b07faf..c22a18ba 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/RoslynScriptCompilerTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/RoslynScriptCompilerTests.cs @@ -54,12 +54,59 @@ public class RoslynScriptCompilerTests var resolved = Attributes.Resolve("Temperature"); var conn = await Database.Connection("hist"); var scope = Scope; + var currentAlarms = await Alarms.CurrentAsync(); + var viaInstance = await Instance.Alarms.CurrentAsync(); """; var diagnostics = RoslynScriptCompiler.Compile(code, typeof(ScriptCompileSurface)); Assert.Empty(diagnostics); } + [Fact] + public void Compile_Empty_ForTheMesAlarmStatusScriptShape() + { + // MES alarm-status API §5.3: the CvdReactor.SimpleAlarmStatus body must bind at + // design time. Every ScriptAlarm field the endpoint projects is read here, so a + // rename on the record breaks this test rather than the deployed script. + const string code = """ + const int MesBandMin = 900; + const int MesBandMax = 999; + + var raw = (Parameters["SAPID"] as string) ?? ""; + var code = Parameters["MachineCode"]?.ToString() ?? ""; + + var core = raw.EndsWith("_LT") ? raw.Substring(0, raw.Length - 3) : raw; + var side = core.EndsWith("_A") ? "Left" : core.EndsWith("_B") ? "Right" : null; + + System.Func InScope = src => + string.IsNullOrEmpty(src) + || side == null + || src.StartsWith(side, System.StringComparison.OrdinalIgnoreCase) + || src.StartsWith("Reactor", System.StringComparison.OrdinalIgnoreCase); + + var alarms = await Alarms.CurrentAsync(); + var infos = alarms + .Where(a => a.Active && !a.IsConfiguredPlaceholder) + .Where(a => InScope(a.NativeSourceCanonicalName)) + .Where(a => a.Severity >= MesBandMin && a.Severity <= MesBandMax) + .Select(a => new { + Name = a.Name, + HierarchicalName = code + "." + a.Name, + Description = string.IsNullOrEmpty(a.Message) ? a.AlarmTypeName : a.Message, + IsFlaggedForMES = a.Severity >= MesBandMin && a.Severity <= MesBandMax, + Severity = a.Severity, + StatusCode = a.Acknowledged ? "Triggered.Acked" : "Triggered", + TriggeredDT = (a.OriginalRaiseTime ?? a.Timestamp), + AckDT = a.AckTime, + AckComment = a.OperatorComment, + }).ToList(); + + return new { WasSuccessful = true, ErrorText = (string)null, Alarms = infos }; + """; + + Assert.Empty(RoslynScriptCompiler.Compile(code, typeof(ScriptCompileSurface))); + } + [Fact] public void Compile_Empty_ForTriggerExpression() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/ScriptTrustValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/ScriptTrustValidatorTests.cs index b422cf40..77c915ab 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/ScriptTrustValidatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/ScriptTrustValidatorTests.cs @@ -194,6 +194,36 @@ public class ScriptTrustValidatorTests Assert.Empty(ScriptTrustValidator.FindViolations(code)); } + [Fact] + public void Allows_AlarmsCurrentAsync_AndTheScriptAlarmType() + { + // MES alarm-status API §5.2/task 4. The trust model is a DENY-list over API roots, + // not an allow-list of context members, so `Alarms` needs no policy entry — but the + // ScriptAlarm projection it returns lives in Commons, which must resolve as a + // permitted namespace. This pins that: a script naming the type explicitly (not just + // via `var`) produces no violation. + const string code = """ + var alarms = await Alarms.CurrentAsync(); + System.Collections.Generic.IReadOnlyList< + ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts.ScriptAlarm> typed = alarms; + var acked = typed.Where(a => a.AckTime != null).Select(a => a.Name).ToList(); + """; + + Assert.Empty(ScriptTrustValidator.FindViolations(code)); + } + + [Fact] + public void ForbiddenScopes_DoNotCover_TheCommonsScriptSurface() + { + // Guards the above from the other direction: if someone ever adds a deny-list root + // that swallows the ZB.MOM.WW namespace, every script accessor's return type would + // become a trust violation. Fail loudly here rather than at deploy time. + Assert.DoesNotContain( + ScriptTrustPolicy.ForbiddenScopes, + scope => typeof(ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts.ScriptAlarm) + .Namespace!.StartsWith(scope, StringComparison.Ordinal)); + } + // ---- ScriptAnalysis-003: adversarial bypass-vector coverage -------------- // (a) TPA-FALLBACK DEGRADATION (the SA-001 hole). Forces Pass 1 onto the diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorNativeAlarmTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorNativeAlarmTests.cs index 8cfcbf41..2d776630 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorNativeAlarmTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorNativeAlarmTests.cs @@ -4,6 +4,7 @@ using Akka.TestKit.Xunit2; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection; using ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming; using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; @@ -148,6 +149,63 @@ public class InstanceActorNativeAlarmTests : TestKit, IDisposable a.NativeSourceCanonicalName == "Pressure" && a.IsConfiguredPlaceholder); } + // ── MES alarm-status API §5.2: the script-facing alarm snapshot ──────── + + [Fact] + public void GetAlarmSnapshot_ReturnsTheSameRowsAsTheDebugViewSnapshot() + { + // Alarms.CurrentAsync() must not diverge from what the operator sees in the Debug + // View — both are served from BuildAlarmStatesSnapshot, so a live native condition + // AND the quiet-binding placeholder appear in each. + var dcl = CreateTestProbe(); + var actor = CreateInstanceActorWithDcl("inst", ConfigWithNativeSource("inst"), dcl.Ref); + dcl.ExpectMsg(); + + var ackedAt = new DateTimeOffset(2026, 8, 1, 12, 0, 0, TimeSpan.Zero); + actor.Tell(new AlarmStateChanged("inst", "T01.Hi", AlarmState.Active, 900, DateTimeOffset.UtcNow) + { + Kind = AlarmKind.NativeOpcUa, + SourceReference = "T01.Hi", + NativeSourceCanonicalName = "Pressure", + Condition = new AlarmConditionState(true, true, null, AlarmShelveState.Unshelved, false, 900), + AckTime = ackedAt + }); + + actor.Tell(new GetAlarmSnapshotRequest("c1", "inst", DateTimeOffset.UtcNow)); + var reply = ExpectMsg(); + + Assert.Equal("c1", reply.CorrelationId); + Assert.Equal("inst", reply.InstanceUniqueName); + + var live = Assert.Single(reply.Alarms, a => a.SourceReference == "T01.Hi"); + Assert.Equal(AlarmState.Active, live.State); + Assert.Equal(900, live.Condition.Severity); + // §6.4: the ack instant reaches the script accessor, not just the gRPC stream. + Assert.Equal(ackedAt, live.AckTime); + + actor.Tell(new SubscribeDebugViewRequest("inst", "c")); + var debug = ExpectMsg(); + Assert.Equal( + debug.AlarmStates.Select(a => a.AlarmName).OrderBy(n => n), + reply.Alarms.Select(a => a.AlarmName).OrderBy(n => n)); + } + + [Fact] + public void GetAlarmSnapshot_WithNoAlarms_RepliesWithTheConfiguredPlaceholdersOnly() + { + var dcl = CreateTestProbe(); + var actor = CreateInstanceActorWithDcl("inst", ConfigWithNativeSource("inst"), dcl.Ref); + dcl.ExpectMsg(); + + actor.Tell(new GetAlarmSnapshotRequest("c2", "inst", DateTimeOffset.UtcNow)); + var reply = ExpectMsg(); + + var placeholder = Assert.Single(reply.Alarms); + Assert.True(placeholder.IsConfiguredPlaceholder); + Assert.Equal("Pressure", placeholder.NativeSourceCanonicalName); + Assert.Null(placeholder.AckTime); + } + void IDisposable.Dispose() { // TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb; diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/AlarmsAccessorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/AlarmsAccessorTests.cs new file mode 100644 index 00000000..65ad6949 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/AlarmsAccessorTests.cs @@ -0,0 +1,204 @@ +using Akka.Actor; +using Akka.TestKit.Xunit2; +using Microsoft.Extensions.Logging.Abstractions; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; +using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; + +namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts; + +/// +/// MES alarm-status API §5.2: the script-facing Alarms.CurrentAsync() accessor. +/// Routes a real against a TestProbe standing in for the +/// Instance Actor (the same harness shape AttributeAccessorWaitAsyncTests uses), so +/// the Ask contract and the ScriptAlarm projection +/// are both exercised for real. +/// +public class AlarmsAccessorTests : TestKit, IDisposable +{ + private ScriptRuntimeContext MakeContext(IActorRef instanceActor) => + new( + instanceActor, + instanceActor, + sharedScriptLibrary: null!, + currentCallDepth: 0, + maxCallDepth: 10, + askTimeout: TimeSpan.FromSeconds(5), + instanceName: "CvdReactor01", + logger: NullLogger.Instance); + + void IDisposable.Dispose() => Shutdown(); + + private static readonly DateTimeOffset RaisedAt = new(2026, 8, 1, 10, 0, 0, TimeSpan.Zero); + private static readonly DateTimeOffset AckedAt = new(2026, 8, 1, 10, 5, 0, TimeSpan.Zero); + private static readonly DateTimeOffset ObservedAt = new(2026, 8, 1, 10, 6, 0, TimeSpan.Zero); + + /// An acknowledged native condition mirrored from a source binding. + private static AlarmStateChanged NativeAcked() => + new("CvdReactor01", "Z28061.HeartbeatTimeoutAlarm", AlarmState.Active, 950, ObservedAt) + { + Kind = AlarmKind.NativeMxAccess, + Condition = new AlarmConditionState( + Active: true, Acknowledged: true, Confirmed: null, + Shelve: AlarmShelveState.TimedShelved, Suppressed: true, Severity: 950), + SourceReference = "Z28061.HeartbeatTimeoutAlarm", + NativeSourceCanonicalName = "ReactorAlarms", + AlarmTypeName = "DiscreteAlarm", + Category = "Process", + Message = "Heartbeat lost", + OperatorUser = "op1", + OperatorComment = "investigating", + OriginalRaiseTime = RaisedAt, + AckTime = AckedAt, + CurrentValue = "FAULT", + LimitValue = "0", + }; + + /// Replies to the accessor's Ask with the supplied alarm rows. + private void RespondWith(Akka.TestKit.TestProbe probe, params AlarmStateChanged[] alarms) + { + var request = probe.ExpectMsg(TimeSpan.FromSeconds(5)); + probe.Reply(new GetAlarmSnapshotResponse( + request.CorrelationId, request.InstanceUniqueName, alarms, DateTimeOffset.UtcNow)); + } + + [Fact] + public async Task CurrentAsync_AsksTheInstanceActor_ForItsOwnInstance() + { + // The script runs inside its own instance's context, so the read is a LOCAL Ask + // stamped with that instance's name — never a cross-instance or cross-cluster hop. + var probe = CreateTestProbe(); + var ctx = MakeContext(probe.Ref); + + var pending = ctx.Alarms.CurrentAsync(); + + var request = probe.ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Equal("CvdReactor01", request.InstanceUniqueName); + Assert.NotEmpty(request.CorrelationId); + + probe.Reply(new GetAlarmSnapshotResponse( + request.CorrelationId, request.InstanceUniqueName, + Array.Empty(), DateTimeOffset.UtcNow)); + + Assert.Empty(await pending); + } + + [Fact] + public async Task CurrentAsync_ProjectsEveryNativeField_IncludingAckTime() + { + var probe = CreateTestProbe(); + var ctx = MakeContext(probe.Ref); + + var pending = ctx.Alarms.CurrentAsync(); + RespondWith(probe, NativeAcked()); + + var alarm = Assert.Single(await pending); + + Assert.Equal("Z28061.HeartbeatTimeoutAlarm", alarm.Name); + Assert.Equal("Z28061.HeartbeatTimeoutAlarm", alarm.SourceReference); + Assert.Equal("ReactorAlarms", alarm.NativeSourceCanonicalName); + Assert.True(alarm.Active); + Assert.True(alarm.Acknowledged); + Assert.Null(alarm.Confirmed); + Assert.True(alarm.Shelved); // TimedShelved collapses to a single boolean + Assert.True(alarm.Suppressed); + Assert.Equal(950, alarm.Severity); + Assert.Equal("NativeMxAccess", alarm.Kind); + Assert.Equal("Heartbeat lost", alarm.Message); + Assert.Equal("DiscreteAlarm", alarm.AlarmTypeName); + Assert.Equal("Process", alarm.Category); + Assert.Equal("op1", alarm.OperatorUser); + Assert.Equal("investigating", alarm.OperatorComment); + Assert.Equal(RaisedAt, alarm.OriginalRaiseTime); + Assert.Equal(ObservedAt, alarm.Timestamp); + Assert.Equal(AckedAt, alarm.AckTime); // §6.4 — the whole point of the enrichment + Assert.Equal("FAULT", alarm.CurrentValue); + Assert.Equal("0", alarm.LimitValue); + Assert.False(alarm.IsConfiguredPlaceholder); + } + + [Fact] + public async Task CurrentAsync_UnackedNativeAlarm_ReportsNoAckTime() + { + var probe = CreateTestProbe(); + var ctx = MakeContext(probe.Ref); + + var pending = ctx.Alarms.CurrentAsync(); + RespondWith(probe, NativeAcked() with + { + Condition = new AlarmConditionState( + true, Acknowledged: false, null, AlarmShelveState.Unshelved, false, 950), + AckTime = null, + }); + + var alarm = Assert.Single(await pending); + Assert.False(alarm.Acknowledged); + Assert.Null(alarm.AckTime); + Assert.False(alarm.Shelved); + } + + [Fact] + public async Task CurrentAsync_ComputedAlarm_ReadsFromTheDerivedCondition() + { + // A computed alarm sets no explicit Condition — it is derived from State + Priority. + // Scripts must still see a coherent Active/Acknowledged/Severity triple so one + // filter expression works across computed and native alarms alike. + var probe = CreateTestProbe(); + var ctx = MakeContext(probe.Ref); + + var pending = ctx.Alarms.CurrentAsync(); + RespondWith(probe, + new AlarmStateChanged("CvdReactor01", "HighTemp", AlarmState.Active, 700, ObservedAt)); + + var alarm = Assert.Single(await pending); + Assert.Equal("HighTemp", alarm.Name); + Assert.Equal("Computed", alarm.Kind); + Assert.True(alarm.Active); + Assert.True(alarm.Acknowledged); // computed alarms are auto-acked… + Assert.Null(alarm.AckTime); // …but carry no ack instant + Assert.Equal(700, alarm.Severity); + Assert.Equal(string.Empty, alarm.NativeSourceCanonicalName); + } + + [Fact] + public async Task CurrentAsync_SurfacesPlaceholderRows_SoQuietBindingsAreDistinguishable() + { + // The accessor deliberately does NOT pre-filter placeholders: a caller needs to tell + // "this binding is configured and quiet" from "this binding does not exist". + var probe = CreateTestProbe(); + var ctx = MakeContext(probe.Ref); + + var pending = ctx.Alarms.CurrentAsync(); + RespondWith(probe, + NativeAcked(), + new AlarmStateChanged("CvdReactor01", "LeftSideAlarms", AlarmState.Normal, 0, ObservedAt) + { + Kind = AlarmKind.NativeMxAccess, + NativeSourceCanonicalName = "LeftSideAlarms", + IsConfiguredPlaceholder = true, + }); + + var alarms = await pending; + Assert.Equal(2, alarms.Count); + + // …and the documented caller-side filter yields only the real alarm. + var real = Assert.Single(alarms, a => a.Active && !a.IsConfiguredPlaceholder); + Assert.Equal("Z28061.HeartbeatTimeoutAlarm", real.Name); + } + + [Fact] + public async Task CurrentAsync_IsNotScopePrefixed_EveryScopeSeesTheSameList() + { + // Unlike Attributes, alarm identity is not a scope-relative name. A composed script + // must not silently receive a truncated alarm set. + var probe = CreateTestProbe(); + var ctx = MakeContext(probe.Ref); + + var pending = new AlarmsAccessor(ctx).CurrentAsync(); + RespondWith(probe, NativeAcked()); + + Assert.Single(await pending); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/CompileSurfaceParityTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/CompileSurfaceParityTests.cs index 0706034c..1d1272f4 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/CompileSurfaceParityTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/CompileSurfaceParityTests.cs @@ -106,6 +106,10 @@ public class CompileSurfaceParityTests // enforcing a mirror for it automatically — rather than silently // re-opening the WaitAsync-class gap. new object[] { typeof(ChildrenAccessor), typeof(ScriptCompileSurface.CompileChildrenAccessor) }, + // MES alarm-status API §5.2: the Alarms accessor joins the guarded set so a + // future read method (e.g. an ActiveAsync convenience) cannot ship on the + // runtime accessor while the design-time gate stays blind to it. + new object[] { typeof(AlarmsAccessor), typeof(ScriptCompileSurface.CompileAlarmsAccessor) }, }; ///