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) }, }; ///