feat(siteeventlog): emit alarm-category events on alarm transitions (M1.5)
AlarmActor (computed) and NativeAlarmActor (native mirror) now fire-and-forget an 'alarm' site operational event on every state transition: - raise/activate: Error (priority/severity >= 700) or Warning - clear/return-to-normal, ack, inter-band transition: Info Both actors take a new optional IServiceProvider? ctor param (default null so existing direct-construction tests still compile); InstanceActor passes its _serviceProvider at the two Props.Create sites. Resolution is optional and the LogEventAsync call is fire-and-forget, so a logging failure never affects alarm evaluation. Rehydration replays are not re-logged. Adds a capturing FakeSiteEventLogger test helper + SingleServiceProvider.
This commit is contained in:
@@ -1,10 +1,12 @@
|
|||||||
using Akka.Actor;
|
using Akka.Actor;
|
||||||
using Microsoft.CodeAnalysis.Scripting;
|
using Microsoft.CodeAnalysis.Scripting;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
||||||
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
|
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.SiteEventLogging;
|
||||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
@@ -37,6 +39,14 @@ public class AlarmActor : ReceiveActor
|
|||||||
private readonly SiteRuntimeOptions _options;
|
private readonly SiteRuntimeOptions _options;
|
||||||
private readonly ILogger _logger;
|
private readonly ILogger _logger;
|
||||||
private readonly ISiteHealthCollector? _healthCollector;
|
private readonly ISiteHealthCollector? _healthCollector;
|
||||||
|
private readonly IServiceProvider? _serviceProvider;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// M1.5: priority at or above which a computed-alarm raise is logged as
|
||||||
|
/// <c>Error</c> to the site event log; below it, raises log as <c>Warning</c>.
|
||||||
|
/// Mirrors the 0–1000 alarm-severity scale.
|
||||||
|
/// </summary>
|
||||||
|
private const int ErrorPriorityThreshold = 700;
|
||||||
|
|
||||||
private AlarmState _currentState = AlarmState.Normal;
|
private AlarmState _currentState = AlarmState.Normal;
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -83,6 +93,9 @@ public class AlarmActor : ReceiveActor
|
|||||||
/// <param name="compiledTriggerExpression">Pre-compiled trigger expression, or <c>null</c> for non-expression triggers.</param>
|
/// <param name="compiledTriggerExpression">Pre-compiled trigger expression, or <c>null</c> for non-expression triggers.</param>
|
||||||
/// <param name="initialAttributes">Seed attribute snapshot so static attributes evaluate correctly at startup.</param>
|
/// <param name="initialAttributes">Seed attribute snapshot so static attributes evaluate correctly at startup.</param>
|
||||||
/// <param name="healthCollector">Optional health collector for surfacing alarm execution metrics.</param>
|
/// <param name="healthCollector">Optional health collector for surfacing alarm execution metrics.</param>
|
||||||
|
/// <param name="serviceProvider">Optional DI service provider used to resolve the optional
|
||||||
|
/// <see cref="ISiteEventLogger"/> for M1.5 <c>alarm</c> operational events. Fire-and-forget;
|
||||||
|
/// a logging failure never affects alarm evaluation.</param>
|
||||||
public AlarmActor(
|
public AlarmActor(
|
||||||
string alarmName,
|
string alarmName,
|
||||||
string instanceName,
|
string instanceName,
|
||||||
@@ -94,7 +107,8 @@ public class AlarmActor : ReceiveActor
|
|||||||
ILogger logger,
|
ILogger logger,
|
||||||
Script<object?>? compiledTriggerExpression = null,
|
Script<object?>? compiledTriggerExpression = null,
|
||||||
IReadOnlyDictionary<string, object?>? initialAttributes = null,
|
IReadOnlyDictionary<string, object?>? initialAttributes = null,
|
||||||
ISiteHealthCollector? healthCollector = null)
|
ISiteHealthCollector? healthCollector = null,
|
||||||
|
IServiceProvider? serviceProvider = null)
|
||||||
{
|
{
|
||||||
_alarmName = alarmName;
|
_alarmName = alarmName;
|
||||||
_instanceName = instanceName;
|
_instanceName = instanceName;
|
||||||
@@ -103,6 +117,7 @@ public class AlarmActor : ReceiveActor
|
|||||||
_options = options;
|
_options = options;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_healthCollector = healthCollector;
|
_healthCollector = healthCollector;
|
||||||
|
_serviceProvider = serviceProvider;
|
||||||
_priority = alarmConfig.PriorityLevel;
|
_priority = alarmConfig.PriorityLevel;
|
||||||
_onTriggerScriptName = alarmConfig.OnTriggerScriptCanonicalName;
|
_onTriggerScriptName = alarmConfig.OnTriggerScriptCanonicalName;
|
||||||
_onTriggerCompiledScript = onTriggerCompiledScript;
|
_onTriggerCompiledScript = onTriggerCompiledScript;
|
||||||
@@ -208,6 +223,9 @@ public class AlarmActor : ReceiveActor
|
|||||||
_instanceName, _alarmName, AlarmState.Active, _priority, DateTimeOffset.UtcNow);
|
_instanceName, _alarmName, AlarmState.Active, _priority, DateTimeOffset.UtcNow);
|
||||||
_instanceActor.Tell(alarmChanged);
|
_instanceActor.Tell(alarmChanged);
|
||||||
|
|
||||||
|
// M1.5: operational `alarm` event — raise. Severity by priority.
|
||||||
|
LogAlarmEvent(RaiseSeverity(_priority), $"Alarm {_alarmName} activated (priority {_priority})");
|
||||||
|
|
||||||
// Spawn AlarmExecutionActor if on-trigger script defined
|
// Spawn AlarmExecutionActor if on-trigger script defined
|
||||||
if (_onTriggerCompiledScript != null)
|
if (_onTriggerCompiledScript != null)
|
||||||
{
|
{
|
||||||
@@ -225,6 +243,9 @@ public class AlarmActor : ReceiveActor
|
|||||||
var alarmChanged = new AlarmStateChanged(
|
var alarmChanged = new AlarmStateChanged(
|
||||||
_instanceName, _alarmName, AlarmState.Normal, _priority, DateTimeOffset.UtcNow);
|
_instanceName, _alarmName, AlarmState.Normal, _priority, DateTimeOffset.UtcNow);
|
||||||
_instanceActor.Tell(alarmChanged);
|
_instanceActor.Tell(alarmChanged);
|
||||||
|
|
||||||
|
// M1.5: operational `alarm` event — return to normal.
|
||||||
|
LogAlarmEvent("Info", $"Alarm {_alarmName} cleared");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -265,6 +286,24 @@ public class AlarmActor : ReceiveActor
|
|||||||
};
|
};
|
||||||
_instanceActor.Tell(alarmChanged);
|
_instanceActor.Tell(alarmChanged);
|
||||||
|
|
||||||
|
// M1.5: operational `alarm` event. Entering a band from Normal is a raise
|
||||||
|
// (severity by the band's priority); returning to None is a clear; a
|
||||||
|
// level-to-level escalation/de-escalation is an informational transition.
|
||||||
|
if (newLevel == AlarmLevel.None)
|
||||||
|
{
|
||||||
|
LogAlarmEvent("Info", $"Alarm {_alarmName} cleared ({previousLevel} → Normal)");
|
||||||
|
}
|
||||||
|
else if (previousLevel == AlarmLevel.None)
|
||||||
|
{
|
||||||
|
LogAlarmEvent(RaiseSeverity(priority),
|
||||||
|
$"Alarm {_alarmName} activated at {newLevel} (priority {priority})");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
LogAlarmEvent("Info",
|
||||||
|
$"Alarm {_alarmName} transitioned {previousLevel} → {newLevel} (priority {priority})");
|
||||||
|
}
|
||||||
|
|
||||||
if (previousLevel == AlarmLevel.None
|
if (previousLevel == AlarmLevel.None
|
||||||
&& newLevel != AlarmLevel.None
|
&& newLevel != AlarmLevel.None
|
||||||
&& _onTriggerCompiledScript != null)
|
&& _onTriggerCompiledScript != null)
|
||||||
@@ -273,6 +312,27 @@ public class AlarmActor : ReceiveActor
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// M1.5: maps an alarm priority (0–1000) to a site-event severity for a
|
||||||
|
/// <i>raise</i> transition — <c>Error</c> at or above
|
||||||
|
/// <see cref="ErrorPriorityThreshold"/>, otherwise <c>Warning</c>. Clears and
|
||||||
|
/// inter-band transitions always log as <c>Info</c>.
|
||||||
|
/// </summary>
|
||||||
|
private static string RaiseSeverity(int priority) =>
|
||||||
|
priority >= ErrorPriorityThreshold ? "Error" : "Warning";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// M1.5: fire-and-forget an <c>alarm</c> operational event to the optional
|
||||||
|
/// <see cref="ISiteEventLogger"/>. Resolved optionally and never awaited so a
|
||||||
|
/// logging failure cannot affect alarm evaluation (matching the established
|
||||||
|
/// ScriptActor/ScriptExecutionActor pattern).
|
||||||
|
/// </summary>
|
||||||
|
private void LogAlarmEvent(string severity, string message)
|
||||||
|
{
|
||||||
|
_ = _serviceProvider?.GetService<ISiteEventLogger>()?.LogEventAsync(
|
||||||
|
"alarm", severity, _instanceName, $"AlarmActor:{_alarmName}", message);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the per-setpoint priority for the given level. Falls back to
|
/// Returns the per-setpoint priority for the given level. Falls back to
|
||||||
/// the alarm-level <see cref="_priority"/> when the HiLo config did not
|
/// the alarm-level <see cref="_priority"/> when the HiLo config did not
|
||||||
|
|||||||
@@ -763,7 +763,8 @@ public class InstanceActor : ReceiveActor
|
|||||||
_logger,
|
_logger,
|
||||||
triggerExpression,
|
triggerExpression,
|
||||||
attributeSnapshot,
|
attributeSnapshot,
|
||||||
_healthCollector));
|
_healthCollector,
|
||||||
|
_serviceProvider));
|
||||||
|
|
||||||
var actorRef = Context.ActorOf(props, $"alarm-{alarm.CanonicalName}");
|
var actorRef = Context.ActorOf(props, $"alarm-{alarm.CanonicalName}");
|
||||||
_alarmActors[alarm.CanonicalName] = actorRef;
|
_alarmActors[alarm.CanonicalName] = actorRef;
|
||||||
@@ -793,7 +794,8 @@ public class InstanceActor : ReceiveActor
|
|||||||
_storage,
|
_storage,
|
||||||
_options,
|
_options,
|
||||||
_logger,
|
_logger,
|
||||||
nativeKind));
|
nativeKind,
|
||||||
|
_serviceProvider));
|
||||||
|
|
||||||
var actorRef = Context.ActorOf(props, $"native-alarm-{nativeSource.CanonicalName}");
|
var actorRef = Context.ActorOf(props, $"native-alarm-{nativeSource.CanonicalName}");
|
||||||
_nativeAlarmActors[nativeSource.CanonicalName] = actorRef;
|
_nativeAlarmActors[nativeSource.CanonicalName] = actorRef;
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Akka.Actor;
|
using Akka.Actor;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.SiteEventLogging;
|
||||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
|
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
|
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
|
||||||
@@ -35,6 +37,14 @@ public class NativeAlarmActor : ReceiveActor
|
|||||||
private readonly SiteRuntimeOptions _options;
|
private readonly SiteRuntimeOptions _options;
|
||||||
private readonly ILogger _logger;
|
private readonly ILogger _logger;
|
||||||
private readonly AlarmKind _nativeKind;
|
private readonly AlarmKind _nativeKind;
|
||||||
|
private readonly IServiceProvider? _serviceProvider;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// M1.5: severity at or above which a native-alarm raise is logged as
|
||||||
|
/// <c>Error</c> to the site event log; below it, raises log as <c>Warning</c>.
|
||||||
|
/// Mirrors the 0–1000 condition-severity scale.
|
||||||
|
/// </summary>
|
||||||
|
private const int ErrorSeverityThreshold = 700;
|
||||||
|
|
||||||
/// <summary>Current mirrored conditions, keyed by source reference.</summary>
|
/// <summary>Current mirrored conditions, keyed by source reference.</summary>
|
||||||
private readonly Dictionary<string, NativeAlarmTransition> _alarms = new();
|
private readonly Dictionary<string, NativeAlarmTransition> _alarms = new();
|
||||||
@@ -54,6 +64,9 @@ public class NativeAlarmActor : ReceiveActor
|
|||||||
/// <param name="logger">Logger for diagnostics.</param>
|
/// <param name="logger">Logger for diagnostics.</param>
|
||||||
/// <param name="nativeKind">Alarm kind to stamp on emitted events (OPC UA vs MxAccess); set by the
|
/// <param name="nativeKind">Alarm kind to stamp on emitted events (OPC UA vs MxAccess); set by the
|
||||||
/// Instance Actor from the connection protocol. Defaults to <see cref="AlarmKind.NativeOpcUa"/>.</param>
|
/// Instance Actor from the connection protocol. Defaults to <see cref="AlarmKind.NativeOpcUa"/>.</param>
|
||||||
|
/// <param name="serviceProvider">Optional DI service provider used to resolve the optional
|
||||||
|
/// <see cref="ISiteEventLogger"/> for M1.5 <c>alarm</c> operational events. Fire-and-forget;
|
||||||
|
/// a logging failure never affects the mirror.</param>
|
||||||
public NativeAlarmActor(
|
public NativeAlarmActor(
|
||||||
ResolvedNativeAlarmSource source,
|
ResolvedNativeAlarmSource source,
|
||||||
string instanceName,
|
string instanceName,
|
||||||
@@ -62,7 +75,8 @@ public class NativeAlarmActor : ReceiveActor
|
|||||||
SiteStorageService storage,
|
SiteStorageService storage,
|
||||||
SiteRuntimeOptions options,
|
SiteRuntimeOptions options,
|
||||||
ILogger logger,
|
ILogger logger,
|
||||||
AlarmKind nativeKind = AlarmKind.NativeOpcUa)
|
AlarmKind nativeKind = AlarmKind.NativeOpcUa,
|
||||||
|
IServiceProvider? serviceProvider = null)
|
||||||
{
|
{
|
||||||
_source = source;
|
_source = source;
|
||||||
_instanceName = instanceName;
|
_instanceName = instanceName;
|
||||||
@@ -72,6 +86,7 @@ public class NativeAlarmActor : ReceiveActor
|
|||||||
_options = options;
|
_options = options;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_nativeKind = nativeKind;
|
_nativeKind = nativeKind;
|
||||||
|
_serviceProvider = serviceProvider;
|
||||||
|
|
||||||
Receive<RehydrationCompleted>(HandleRehydration);
|
Receive<RehydrationCompleted>(HandleRehydration);
|
||||||
Receive<NativeAlarmTransitionUpdate>(HandleTransition);
|
Receive<NativeAlarmTransitionUpdate>(HandleTransition);
|
||||||
@@ -150,7 +165,10 @@ public class NativeAlarmActor : ReceiveActor
|
|||||||
condition, string.Empty, string.Empty, string.Empty, string.Empty, string.Empty,
|
condition, string.Empty, string.Empty, string.Empty, string.Empty, string.Empty,
|
||||||
null, row.LastTransitionAt, string.Empty, string.Empty);
|
null, row.LastTransitionAt, string.Empty, string.Empty);
|
||||||
_alarms[row.SourceReference] = t;
|
_alarms[row.SourceReference] = t;
|
||||||
Emit(t, t.Condition);
|
// M1.5: rehydration replays last-known state on (re)start — surface it
|
||||||
|
// upward for the DebugView but do NOT re-log it as a fresh operational
|
||||||
|
// event (it is not a live transition).
|
||||||
|
Emit(t, t.Condition, logSiteEvent: false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,8 +295,16 @@ public class NativeAlarmActor : ReceiveActor
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Builds and tells the parent an enriched <see cref="AlarmStateChanged"/> for a condition.</summary>
|
/// <summary>
|
||||||
private void Emit(NativeAlarmTransition t, AlarmConditionState condition)
|
/// Builds and tells the parent an enriched <see cref="AlarmStateChanged"/> for a condition.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="t">The mirrored transition.</param>
|
||||||
|
/// <param name="condition">The condition state to surface (may differ from <paramref name="t"/>'s
|
||||||
|
/// own condition, e.g. a synthesised return-to-normal on snapshot swap).</param>
|
||||||
|
/// <param name="logSiteEvent">M1.5: when <c>true</c> (live + snapshot transitions), emit an
|
||||||
|
/// <c>alarm</c> operational event. Suppressed for SQLite rehydration so a node restart does not
|
||||||
|
/// re-log every last-known condition.</param>
|
||||||
|
private void Emit(NativeAlarmTransition t, AlarmConditionState condition, bool logSiteEvent = true)
|
||||||
{
|
{
|
||||||
var change = new AlarmStateChanged(
|
var change = new AlarmStateChanged(
|
||||||
_instanceName,
|
_instanceName,
|
||||||
@@ -301,6 +327,49 @@ public class NativeAlarmActor : ReceiveActor
|
|||||||
};
|
};
|
||||||
|
|
||||||
_instanceActor.Tell(change);
|
_instanceActor.Tell(change);
|
||||||
|
|
||||||
|
if (logSiteEvent)
|
||||||
|
{
|
||||||
|
LogAlarmEvent(t, condition);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// M1.5: fire-and-forget an <c>alarm</c> operational event mirroring a native
|
||||||
|
/// condition transition. An active condition is a raise (severity by the
|
||||||
|
/// condition's severity); an inactive condition is a return-to-normal; an
|
||||||
|
/// acknowledge transition is informational. Resolved optionally and never
|
||||||
|
/// awaited so a logging failure cannot affect the mirror (matching the
|
||||||
|
/// established ScriptActor/ScriptExecutionActor pattern).
|
||||||
|
/// </summary>
|
||||||
|
private void LogAlarmEvent(NativeAlarmTransition t, AlarmConditionState condition)
|
||||||
|
{
|
||||||
|
var logger = _serviceProvider?.GetService<ISiteEventLogger>();
|
||||||
|
if (logger == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string severity;
|
||||||
|
string message;
|
||||||
|
if (t.Kind == AlarmTransitionKind.Acknowledge)
|
||||||
|
{
|
||||||
|
severity = "Info";
|
||||||
|
message = $"Native alarm {t.SourceReference} acknowledged";
|
||||||
|
}
|
||||||
|
else if (condition.Active)
|
||||||
|
{
|
||||||
|
severity = condition.Severity >= ErrorSeverityThreshold ? "Error" : "Warning";
|
||||||
|
message = $"Native alarm {t.SourceReference} active (severity {condition.Severity})";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
severity = "Info";
|
||||||
|
message = $"Native alarm {t.SourceReference} returned to normal";
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = logger.LogEventAsync(
|
||||||
|
"alarm", severity, _instanceName, $"NativeAlarmActor:{_source.CanonicalName}", message);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void PersistUpsert(NativeAlarmTransition t)
|
private void PersistUpsert(NativeAlarmTransition t)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
|||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
||||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
|
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
|
||||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.TestSupport;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
|
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
|
||||||
|
|
||||||
@@ -877,6 +878,112 @@ public class AlarmActorTests : TestKit, IDisposable
|
|||||||
Assert.Equal(AlarmLevel.HighHigh, escalated.Level);
|
Assert.Equal(AlarmLevel.HighHigh, escalated.Level);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── M1.5: site event log `alarm` category ──────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AlarmActor_Raise_EmitsAlarmSiteEvent()
|
||||||
|
{
|
||||||
|
var siteLog = new FakeSiteEventLogger();
|
||||||
|
var sp = new SingleServiceProvider(siteLog);
|
||||||
|
|
||||||
|
var alarmConfig = new ResolvedAlarm
|
||||||
|
{
|
||||||
|
CanonicalName = "HighTemp",
|
||||||
|
TriggerType = "ValueMatch",
|
||||||
|
TriggerConfiguration = "{\"attributeName\":\"Status\",\"matchValue\":\"Critical\"}",
|
||||||
|
PriorityLevel = 800
|
||||||
|
};
|
||||||
|
|
||||||
|
var instanceProbe = CreateTestProbe();
|
||||||
|
var alarm = ActorOf(Props.Create(() => new AlarmActor(
|
||||||
|
"HighTemp", "Pump1", instanceProbe.Ref, alarmConfig,
|
||||||
|
null, _sharedLibrary, _options,
|
||||||
|
NullLogger<AlarmActor>.Instance, null, null, null, sp)));
|
||||||
|
|
||||||
|
alarm.Tell(new AttributeValueChanged(
|
||||||
|
"Pump1", "Status", "Status", "Critical", "Good", DateTimeOffset.UtcNow));
|
||||||
|
instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
// Background fire-and-forget; allow it to land.
|
||||||
|
AwaitAssert(() =>
|
||||||
|
{
|
||||||
|
var rows = siteLog.OfType("alarm");
|
||||||
|
Assert.Single(rows);
|
||||||
|
var row = rows[0];
|
||||||
|
Assert.Equal("Error", row.Severity); // priority 800 → Error
|
||||||
|
Assert.Equal("Pump1", row.InstanceId);
|
||||||
|
Assert.Equal("AlarmActor:HighTemp", row.Source);
|
||||||
|
}, TimeSpan.FromSeconds(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AlarmActor_RaiseLowPriority_EmitsWarningAlarmSiteEvent()
|
||||||
|
{
|
||||||
|
var siteLog = new FakeSiteEventLogger();
|
||||||
|
var sp = new SingleServiceProvider(siteLog);
|
||||||
|
|
||||||
|
var alarmConfig = new ResolvedAlarm
|
||||||
|
{
|
||||||
|
CanonicalName = "MinorTemp",
|
||||||
|
TriggerType = "ValueMatch",
|
||||||
|
TriggerConfiguration = "{\"attributeName\":\"Status\",\"matchValue\":\"Warn\"}",
|
||||||
|
PriorityLevel = 100
|
||||||
|
};
|
||||||
|
|
||||||
|
var instanceProbe = CreateTestProbe();
|
||||||
|
var alarm = ActorOf(Props.Create(() => new AlarmActor(
|
||||||
|
"MinorTemp", "Pump1", instanceProbe.Ref, alarmConfig,
|
||||||
|
null, _sharedLibrary, _options,
|
||||||
|
NullLogger<AlarmActor>.Instance, null, null, null, sp)));
|
||||||
|
|
||||||
|
alarm.Tell(new AttributeValueChanged(
|
||||||
|
"Pump1", "Status", "Status", "Warn", "Good", DateTimeOffset.UtcNow));
|
||||||
|
instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
AwaitAssert(() =>
|
||||||
|
{
|
||||||
|
var rows = siteLog.OfType("alarm");
|
||||||
|
Assert.Single(rows);
|
||||||
|
Assert.Equal("Warning", rows[0].Severity); // priority 100 → Warning
|
||||||
|
}, TimeSpan.FromSeconds(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AlarmActor_Clear_EmitsInfoAlarmSiteEvent()
|
||||||
|
{
|
||||||
|
var siteLog = new FakeSiteEventLogger();
|
||||||
|
var sp = new SingleServiceProvider(siteLog);
|
||||||
|
|
||||||
|
var alarmConfig = new ResolvedAlarm
|
||||||
|
{
|
||||||
|
CanonicalName = "HighTemp",
|
||||||
|
TriggerType = "ValueMatch",
|
||||||
|
TriggerConfiguration = "{\"attributeName\":\"Status\",\"matchValue\":\"Critical\"}",
|
||||||
|
PriorityLevel = 800
|
||||||
|
};
|
||||||
|
|
||||||
|
var instanceProbe = CreateTestProbe();
|
||||||
|
var alarm = ActorOf(Props.Create(() => new AlarmActor(
|
||||||
|
"HighTemp", "Pump1", instanceProbe.Ref, alarmConfig,
|
||||||
|
null, _sharedLibrary, _options,
|
||||||
|
NullLogger<AlarmActor>.Instance, null, null, null, sp)));
|
||||||
|
|
||||||
|
alarm.Tell(new AttributeValueChanged(
|
||||||
|
"Pump1", "Status", "Status", "Critical", "Good", DateTimeOffset.UtcNow));
|
||||||
|
instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||||
|
alarm.Tell(new AttributeValueChanged(
|
||||||
|
"Pump1", "Status", "Status", "Normal", "Critical", DateTimeOffset.UtcNow));
|
||||||
|
instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
AwaitAssert(() =>
|
||||||
|
{
|
||||||
|
var rows = siteLog.OfType("alarm");
|
||||||
|
Assert.Equal(2, rows.Count); // raise + clear
|
||||||
|
Assert.Equal("Error", rows[0].Severity);
|
||||||
|
Assert.Equal("Info", rows[1].Severity); // clear → Info
|
||||||
|
}, TimeSpan.FromSeconds(2));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AlarmActor_MalformedTriggerConfig_DoesNotCrash()
|
public void AlarmActor_MalformedTriggerConfig_DoesNotCrash()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
|||||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime;
|
using ZB.MOM.WW.ScadaBridge.SiteRuntime;
|
||||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
|
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
|
||||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
|
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.TestSupport;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
|
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
|
||||||
|
|
||||||
@@ -41,9 +42,10 @@ public class NativeAlarmActorTests : TestKit, IDisposable
|
|||||||
new(sourceRef, "T01", "AnalogLimit.Hi", kind, condition,
|
new(sourceRef, "T01", "AnalogLimit.Hi", kind, condition,
|
||||||
"Process", "hi", "hi", "", "", null, time ?? DateTimeOffset.UtcNow, "92", "90");
|
"Process", "hi", "hi", "", "", null, time ?? DateTimeOffset.UtcNow, "92", "90");
|
||||||
|
|
||||||
private IActorRef Spawn(IActorRef instanceActor, IActorRef dclManager) =>
|
private IActorRef Spawn(IActorRef instanceActor, IActorRef dclManager, IServiceProvider? serviceProvider = null) =>
|
||||||
ActorOf(Props.Create(() => new NativeAlarmActor(
|
ActorOf(Props.Create(() => new NativeAlarmActor(
|
||||||
Source(), "inst", instanceActor, dclManager, _storage, _options, NullLogger<NativeAlarmActor>.Instance)));
|
Source(), "inst", instanceActor, dclManager, _storage, _options,
|
||||||
|
NullLogger<NativeAlarmActor>.Instance, AlarmKind.NativeOpcUa, serviceProvider)));
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SubscribeOnStart_SendsRequestForSourceBinding()
|
public void SubscribeOnStart_SendsRequestForSourceBinding()
|
||||||
@@ -121,6 +123,63 @@ public class NativeAlarmActorTests : TestKit, IDisposable
|
|||||||
instance.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
instance.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── M1.5: site event log `alarm` category ──────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Raise_EmitsAlarmSiteEvent()
|
||||||
|
{
|
||||||
|
var siteLog = new FakeSiteEventLogger();
|
||||||
|
var instance = CreateTestProbe();
|
||||||
|
var dcl = CreateTestProbe();
|
||||||
|
var actor = Spawn(instance.Ref, dcl.Ref, new SingleServiceProvider(siteLog));
|
||||||
|
dcl.ExpectMsg<SubscribeAlarmsRequest>();
|
||||||
|
|
||||||
|
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition(
|
||||||
|
"T01.Hi", AlarmTransitionKind.Raise,
|
||||||
|
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800))));
|
||||||
|
instance.ExpectMsg<AlarmStateChanged>(m => m.State == AlarmState.Active);
|
||||||
|
|
||||||
|
AwaitAssert(() =>
|
||||||
|
{
|
||||||
|
var rows = siteLog.OfType("alarm");
|
||||||
|
Assert.Single(rows);
|
||||||
|
var row = rows[0];
|
||||||
|
Assert.Equal("Error", row.Severity); // severity 800 → Error
|
||||||
|
Assert.Equal("inst", row.InstanceId);
|
||||||
|
Assert.Equal("NativeAlarmActor:Pressure", row.Source);
|
||||||
|
}, TimeSpan.FromSeconds(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Clear_EmitsInfoAlarmSiteEvent()
|
||||||
|
{
|
||||||
|
var siteLog = new FakeSiteEventLogger();
|
||||||
|
var instance = CreateTestProbe();
|
||||||
|
var dcl = CreateTestProbe();
|
||||||
|
var actor = Spawn(instance.Ref, dcl.Ref, new SingleServiceProvider(siteLog));
|
||||||
|
dcl.ExpectMsg<SubscribeAlarmsRequest>();
|
||||||
|
|
||||||
|
var t0 = DateTimeOffset.UtcNow;
|
||||||
|
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition(
|
||||||
|
"T01.Hi", AlarmTransitionKind.Raise,
|
||||||
|
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800), t0)));
|
||||||
|
instance.ExpectMsg<AlarmStateChanged>(m => m.State == AlarmState.Active);
|
||||||
|
|
||||||
|
// Clear (inactive but not yet acked → stays mirrored, return-to-normal emit).
|
||||||
|
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition(
|
||||||
|
"T01.Hi", AlarmTransitionKind.Clear,
|
||||||
|
new AlarmConditionState(false, false, null, AlarmShelveState.Unshelved, false, 0), t0.AddSeconds(5))));
|
||||||
|
instance.ExpectMsg<AlarmStateChanged>(m => m.State == AlarmState.Normal);
|
||||||
|
|
||||||
|
AwaitAssert(() =>
|
||||||
|
{
|
||||||
|
var rows = siteLog.OfType("alarm");
|
||||||
|
Assert.Equal(2, rows.Count); // raise + clear
|
||||||
|
Assert.Equal("Error", rows[0].Severity);
|
||||||
|
Assert.Equal("Info", rows[1].Severity); // return-to-normal → Info
|
||||||
|
}, TimeSpan.FromSeconds(2));
|
||||||
|
}
|
||||||
|
|
||||||
void IDisposable.Dispose()
|
void IDisposable.Dispose()
|
||||||
{
|
{
|
||||||
Shutdown();
|
Shutdown();
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.SiteEventLogging;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.TestSupport;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// M1 Site Event Logging categories: a capturing fake <see cref="ISiteEventLogger"/>
|
||||||
|
/// used by the actor tests to assert that the right operational events are emitted.
|
||||||
|
/// Thread-safe — the actors fire-and-forget <c>LogEventAsync</c> from background
|
||||||
|
/// tasks, so multiple captures can land concurrently.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FakeSiteEventLogger : ISiteEventLogger
|
||||||
|
{
|
||||||
|
/// <summary>One captured <see cref="ISiteEventLogger.LogEventAsync"/> invocation.</summary>
|
||||||
|
public sealed record Entry(
|
||||||
|
string EventType,
|
||||||
|
string Severity,
|
||||||
|
string? InstanceId,
|
||||||
|
string Source,
|
||||||
|
string Message,
|
||||||
|
string? Details);
|
||||||
|
|
||||||
|
private readonly ConcurrentQueue<Entry> _entries = new();
|
||||||
|
|
||||||
|
/// <summary>All captured events, in arrival order.</summary>
|
||||||
|
public IReadOnlyList<Entry> Entries => _entries.ToArray();
|
||||||
|
|
||||||
|
/// <summary>Captured events filtered to a single category.</summary>
|
||||||
|
public IReadOnlyList<Entry> OfType(string eventType) =>
|
||||||
|
_entries.Where(e => e.EventType == eventType).ToArray();
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task LogEventAsync(
|
||||||
|
string eventType,
|
||||||
|
string severity,
|
||||||
|
string? instanceId,
|
||||||
|
string source,
|
||||||
|
string message,
|
||||||
|
string? details = null)
|
||||||
|
{
|
||||||
|
_entries.Enqueue(new Entry(eventType, severity, instanceId, source, message, details));
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public long FailedWriteCount => 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimal <see cref="IServiceProvider"/> that resolves a single
|
||||||
|
/// <see cref="ISiteEventLogger"/> — enough for the actors' optional
|
||||||
|
/// <c>_serviceProvider?.GetService<ISiteEventLogger>()</c> resolution
|
||||||
|
/// without pulling a full DI container into the actor tests.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SingleServiceProvider(ISiteEventLogger logger) : IServiceProvider
|
||||||
|
{
|
||||||
|
private readonly ISiteEventLogger _logger = logger;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public object? GetService(Type serviceType) =>
|
||||||
|
serviceType == typeof(ISiteEventLogger) ? _logger : null;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user