diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs index 55ca8163..0e9dab79 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs @@ -422,8 +422,11 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers command.RevisionHash, isEnabled: true); - // Static overrides are reset on redeployment per design decision + // Static overrides and mirrored native alarm state are reset on + // redeployment per design decision — the new config may bind different + // sources, and the source snapshot re-seeds the mirror on (re)subscribe. await _storage.ClearStaticOverridesAsync(instanceName); + await _storage.ClearNativeAlarmsForInstanceAsync(instanceName); // Replicate to standby node _replicationActor?.Tell(new ReplicateConfigDeploy( diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs index e34b2cab..75909a8e 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs @@ -48,6 +48,13 @@ public class InstanceActor : ReceiveActor private readonly Dictionary _alarmPriorities = new(); private readonly Dictionary _scriptActors = new(); private readonly Dictionary _alarmActors = new(); + private readonly Dictionary _nativeAlarmActors = new(); + /// + /// Latest enriched per alarm name (computed and + /// native), so the DebugView snapshot carries the unified condition + native + /// metadata rather than a bare State/Priority projection. + /// + private readonly Dictionary _latestAlarmEvents = new(); private FlattenedConfiguration? _configuration; // DCL manager actor reference for subscribing to tag values @@ -505,6 +512,9 @@ public class InstanceActor : ReceiveActor { _alarmStates[changed.AlarmName] = changed.State; _alarmTimestamps[changed.AlarmName] = changed.Timestamp; + // Retain the full enriched event (Kind, Condition, native metadata) so the + // DebugView snapshot reflects it — native alarms have no _alarmActors entry. + _latestAlarmEvents[changed.AlarmName] = changed; // WP-23: Publish to site-wide stream _streamManager?.PublishAlarmStateChanged(changed); @@ -525,17 +535,10 @@ public class InstanceActor : ReceiveActor _attributeQualities.GetValueOrDefault(kvp.Key, "Good"), _attributeTimestamps.GetValueOrDefault(kvp.Key, now))).ToList(); - var alarmStates = _alarmActors.Keys.Select(name => new AlarmStateChanged( - _instanceUniqueName, - name, - _alarmStates.GetValueOrDefault(name, AlarmState.Normal), - _alarmPriorities.GetValueOrDefault(name, 0), - _alarmTimestamps[name])).ToList(); - var snapshot = new DebugViewSnapshot( _instanceUniqueName, attributeValues, - alarmStates, + BuildAlarmStatesSnapshot(), DateTimeOffset.UtcNow); Sender.Tell(snapshot); @@ -545,6 +548,34 @@ public class InstanceActor : ReceiveActor _instanceUniqueName, request.CorrelationId); } + /// + /// Builds the alarm-state list for a DebugView snapshot. Prefers the latest + /// enriched per alarm (computed alarms that have + /// fired plus all native alarms) and falls back to a bare Normal projection for + /// computed alarms that have not yet emitted an event. + /// + private List BuildAlarmStatesSnapshot() + { + var states = _latestAlarmEvents.Values.ToList(); + + foreach (var name in _alarmActors.Keys) + { + if (_latestAlarmEvents.ContainsKey(name)) + { + continue; + } + + states.Add(new AlarmStateChanged( + _instanceUniqueName, + name, + _alarmStates.GetValueOrDefault(name, AlarmState.Normal), + _alarmPriorities.GetValueOrDefault(name, 0), + _alarmTimestamps.GetValueOrDefault(name, DateTimeOffset.UtcNow))); + } + + return states; + } + /// /// WP-25: Debug view unsubscribe (SiteRuntime-013). /// This handler is a deliberate no-op acknowledgement: the Instance Actor holds @@ -576,17 +607,10 @@ public class InstanceActor : ReceiveActor _attributeQualities.GetValueOrDefault(kvp.Key, "Good"), _attributeTimestamps.GetValueOrDefault(kvp.Key, now))).ToList(); - var alarmStates = _alarmActors.Keys.Select(name => new AlarmStateChanged( - _instanceUniqueName, - name, - _alarmStates.GetValueOrDefault(name, AlarmState.Normal), - _alarmPriorities.GetValueOrDefault(name, 0), - _alarmTimestamps[name])).ToList(); - var snapshot = new DebugViewSnapshot( _instanceUniqueName, attributeValues, - alarmStates, + BuildAlarmStatesSnapshot(), DateTimeOffset.UtcNow); Sender.Tell(snapshot); @@ -747,9 +771,55 @@ public class InstanceActor : ReceiveActor _alarmTimestamps[alarm.CanonicalName] = DateTimeOffset.UtcNow; } + // Create Native Alarm Actors — read-only mirror of each bound source's + // native alarms (peers to the computed Alarm Actors). They subscribe through + // the DCL manager, so without one (e.g. in isolated tests) they are skipped. + foreach (var nativeSource in _configuration.NativeAlarmSources) + { + if (_dclManager == null) + { + _logger.LogWarning( + "Instance {Instance}: native alarm source {Source} skipped — no DCL manager available", + _instanceUniqueName, nativeSource.CanonicalName); + continue; + } + + var nativeKind = ResolveNativeKind(nativeSource.ConnectionName); + var props = Props.Create(() => new NativeAlarmActor( + nativeSource, + _instanceUniqueName, + Self, + _dclManager, + _storage, + _options, + _logger, + nativeKind)); + + var actorRef = Context.ActorOf(props, $"native-alarm-{nativeSource.CanonicalName}"); + _nativeAlarmActors[nativeSource.CanonicalName] = actorRef; + } + _logger.LogInformation( - "Instance {Instance}: created {Scripts} script actors and {Alarms} alarm actors", - _instanceUniqueName, _scriptActors.Count, _alarmActors.Count); + "Instance {Instance}: created {Scripts} script actors, {Alarms} alarm actors, {NativeAlarms} native alarm actors", + _instanceUniqueName, _scriptActors.Count, _alarmActors.Count, _nativeAlarmActors.Count); + } + + /// + /// Maps a bound connection's protocol to the native alarm kind stamped on + /// emitted events. MxAccess Gateway connections yield + /// ; everything else (OPC UA) yields + /// . + /// + private AlarmKind ResolveNativeKind(string connectionName) + { + var protocol = _configuration?.Connections is { } connections + && connections.TryGetValue(connectionName, out var cfg) + ? cfg.Protocol + : null; + + return protocol != null && protocol.Contains("Mx", StringComparison.OrdinalIgnoreCase) + ? AlarmKind.NativeMxAccess + : AlarmKind.NativeOpcUa; } /// diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Persistence/SiteStorageService.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Persistence/SiteStorageService.cs index 56f8b832..2109d103 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Persistence/SiteStorageService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Persistence/SiteStorageService.cs @@ -244,6 +244,14 @@ public class SiteStorageService await cmd.ExecuteNonQueryAsync(); } + await using (var cmd = connection.CreateCommand()) + { + cmd.Transaction = (SqliteTransaction)transaction; + cmd.CommandText = "DELETE FROM native_alarm_state WHERE instance_unique_name = @name"; + cmd.Parameters.AddWithValue("@name", instanceName); + await cmd.ExecuteNonQueryAsync(); + } + await using (var cmd = connection.CreateCommand()) { cmd.Transaction = (SqliteTransaction)transaction; diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorNativeAlarmTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorNativeAlarmTests.cs new file mode 100644 index 00000000..8656de72 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorNativeAlarmTests.cs @@ -0,0 +1,103 @@ +using System.Text.Json; +using Akka.Actor; +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.Streaming; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening; +using ZB.MOM.WW.ScadaBridge.SiteRuntime; +using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors; +using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence; +using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; + +namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors; + +/// +/// Task 16: InstanceActor spawns NativeAlarmActor children from the flattened +/// configuration and surfaces enriched native alarm state in the DebugView snapshot. +/// +public class InstanceActorNativeAlarmTests : TestKit, IDisposable +{ + private readonly SiteStorageService _storage; + private readonly ScriptCompilationService _compilationService; + private readonly SharedScriptLibrary _sharedScriptLibrary; + private readonly SiteRuntimeOptions _options = new(); + private readonly string _dbFile; + + public InstanceActorNativeAlarmTests() + { + _dbFile = Path.Combine(Path.GetTempPath(), $"instance-native-{Guid.NewGuid():N}.db"); + _storage = new SiteStorageService($"Data Source={_dbFile}", NullLogger.Instance); + _storage.InitializeAsync().GetAwaiter().GetResult(); + _compilationService = new ScriptCompilationService(NullLogger.Instance); + _sharedScriptLibrary = new SharedScriptLibrary(_compilationService, NullLogger.Instance); + } + + private IActorRef CreateInstanceActorWithDcl(string instanceName, FlattenedConfiguration config, IActorRef dclManager) => + ActorOf(Props.Create(() => new InstanceActor( + instanceName, + JsonSerializer.Serialize(config), + _storage, + _compilationService, + _sharedScriptLibrary, + null, + _options, + NullLogger.Instance, + dclManager))); + + private static FlattenedConfiguration ConfigWithNativeSource(string instanceName) => new() + { + InstanceUniqueName = instanceName, + NativeAlarmSources = + [ + new ResolvedNativeAlarmSource + { + CanonicalName = "Pressure", + ConnectionName = "Opc", + SourceReference = "ns=2;s=T01" + } + ] + }; + + [Fact] + public void SpawnsNativeAlarmActor_WhichSubscribesViaDcl() + { + var dcl = CreateTestProbe(); + CreateInstanceActorWithDcl("inst", ConfigWithNativeSource("inst"), dcl.Ref); + + var req = dcl.ExpectMsg(); + Assert.Equal("inst", req.InstanceUniqueName); + Assert.Equal("Opc", req.ConnectionName); + Assert.Equal("ns=2;s=T01", req.SourceReference); + } + + [Fact] + public void DebugViewSnapshot_IncludesNativeAlarm_AfterTransition() + { + var dcl = CreateTestProbe(); + var actor = CreateInstanceActorWithDcl("inst", ConfigWithNativeSource("inst"), dcl.Ref); + + // Simulate the NativeAlarmActor emitting an enriched event upward. + actor.Tell(new AlarmStateChanged("inst", "T01.Hi", AlarmState.Active, 800, DateTimeOffset.UtcNow) + { + Kind = AlarmKind.NativeOpcUa, + SourceReference = "T01.Hi", + Condition = new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800) + }); + + actor.Tell(new SubscribeDebugViewRequest("inst", "c")); + var snap = ExpectMsg(); + + Assert.Contains(snap.AlarmStates, a => + a.SourceReference == "T01.Hi" && a.Kind == AlarmKind.NativeOpcUa && a.Condition.Severity == 800); + } + + void IDisposable.Dispose() + { + Shutdown(); + try { File.Delete(_dbFile); } catch { /* cleanup */ } + } +}