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