diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/NativeAlarmActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/NativeAlarmActor.cs index dfa42102..7a543c1b 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/NativeAlarmActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/NativeAlarmActor.cs @@ -191,13 +191,26 @@ public class NativeAlarmActor : ReceiveActor continue; } - // Metadata (type/category/message) is not persisted — rehydrate the - // condition + key so the DebugView reflects last-known state until the - // first source snapshot replaces it. + // Restore the persisted display metadata (UA4) so the rehydrated condition renders + // fully (type/category/message/values) until the first source snapshot replaces it. + // Null/malformed metadata falls back to empty strings (pre-UA4 rows behave as before). + NativeAlarmMetadata meta = EmptyMetadata; + if (!string.IsNullOrEmpty(row.MetadataJson)) + { + try + { + meta = JsonSerializer.Deserialize(row.MetadataJson) ?? EmptyMetadata; + } + catch (JsonException) + { + meta = EmptyMetadata; + } + } + var t = new NativeAlarmTransition( - row.SourceReference, string.Empty, string.Empty, AlarmTransitionKind.Snapshot, - condition, string.Empty, string.Empty, string.Empty, string.Empty, string.Empty, - null, row.LastTransitionAt, string.Empty, string.Empty); + row.SourceReference, string.Empty, meta.AlarmTypeName, AlarmTransitionKind.Snapshot, + condition, meta.Category, string.Empty, meta.Message, string.Empty, string.Empty, + null, row.LastTransitionAt, meta.CurrentValue, meta.LimitValue); _alarms[row.SourceReference] = t; // Rehydration replays last-known state on (re)start — surface it // upward for the DebugView but do NOT re-log it as a fresh operational @@ -479,7 +492,12 @@ public class NativeAlarmActor : ReceiveActor return; var batch = _dirtyUpserts.Values - .Select(t => (t.SourceReference, JsonSerializer.Serialize(t.Condition), t.TransitionTime)) + .Select(t => ( + t.SourceReference, + JsonSerializer.Serialize(t.Condition), + (string?)JsonSerializer.Serialize(new NativeAlarmMetadata( + t.AlarmTypeName, t.Category, t.Message, t.CurrentValue, t.LimitValue)), + t.TransitionTime)) .ToList(); _dirtyUpserts.Clear(); @@ -519,4 +537,16 @@ public class NativeAlarmActor : ReceiveActor public static readonly FlushPersistence Instance = new(); private FlushPersistence() { } } + + /// Empty metadata fallback for pre-UA4 rows or malformed metadata_json. + private static readonly NativeAlarmMetadata EmptyMetadata = + new(string.Empty, string.Empty, string.Empty, string.Empty, string.Empty); + + /// + /// Persisted display metadata for a native alarm condition (UA4). Serialized into the + /// metadata_json column so a rehydrated condition renders fully (type/category/message/ + /// current+limit values) before the first source snapshot re-supplies it. + /// + private sealed record NativeAlarmMetadata( + string AlarmTypeName, string Category, string Message, string CurrentValue, string LimitValue); } diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Persistence/SiteStorageService.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Persistence/SiteStorageService.cs index 1a31deae..a4c86003 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Persistence/SiteStorageService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Persistence/SiteStorageService.cs @@ -152,6 +152,7 @@ public class SiteStorageService source_reference TEXT NOT NULL, condition_json TEXT NOT NULL, last_transition_at TEXT NOT NULL, + metadata_json TEXT, PRIMARY KEY (instance_unique_name, source_canonical_name, source_reference) ); "; @@ -169,6 +170,10 @@ public class SiteStorageService // (added in primary/backup data connections feature) await TryAddColumnAsync(connection, "data_connection_definitions", "backup_configuration", "TEXT"); await TryAddColumnAsync(connection, "data_connection_definitions", "failover_retry_count", "INTEGER NOT NULL DEFAULT 3"); + + // Native-alarm display metadata (UA4) — restored on rehydration so a persisted condition + // renders fully (type/category/message/values) before the first source snapshot arrives. + await TryAddColumnAsync(connection, "native_alarm_state", "metadata_json", "TEXT"); } private async Task TryAddColumnAsync(SqliteConnection connection, string table, string column, string type) @@ -483,7 +488,7 @@ public class SiteStorageService /// A task that completes when the alarm condition has been inserted or updated. public async Task UpsertNativeAlarmAsync( string instanceName, string sourceCanonicalName, string sourceReference, - string conditionJson, DateTimeOffset lastTransitionAt) + string conditionJson, DateTimeOffset lastTransitionAt, string? metadataJson = null) { await using var connection = new SqliteConnection(_connectionString); await connection.OpenAsync(); @@ -491,17 +496,19 @@ public class SiteStorageService await using var command = connection.CreateCommand(); command.CommandText = @" INSERT INTO native_alarm_state - (instance_unique_name, source_canonical_name, source_reference, condition_json, last_transition_at) - VALUES (@name, @source, @ref, @json, @at) + (instance_unique_name, source_canonical_name, source_reference, condition_json, last_transition_at, metadata_json) + VALUES (@name, @source, @ref, @json, @at, @meta) ON CONFLICT(instance_unique_name, source_canonical_name, source_reference) DO UPDATE SET condition_json = excluded.condition_json, - last_transition_at = excluded.last_transition_at"; + last_transition_at = excluded.last_transition_at, + metadata_json = excluded.metadata_json"; command.Parameters.AddWithValue("@name", instanceName); command.Parameters.AddWithValue("@source", sourceCanonicalName); command.Parameters.AddWithValue("@ref", sourceReference); command.Parameters.AddWithValue("@json", conditionJson); command.Parameters.AddWithValue("@at", lastTransitionAt.ToString("O")); + command.Parameters.AddWithValue("@meta", (object?)metadataJson ?? DBNull.Value); await command.ExecuteNonQueryAsync(); } @@ -518,7 +525,7 @@ public class SiteStorageService /// A task that completes when all rows have been committed. public async Task UpsertNativeAlarmsAsync( string instanceName, string sourceCanonicalName, - IReadOnlyList<(string SourceReference, string ConditionJson, DateTimeOffset LastTransitionAt)> rows) + IReadOnlyList<(string SourceReference, string ConditionJson, string? MetadataJson, DateTimeOffset LastTransitionAt)> rows) { if (rows.Count == 0) return; @@ -531,17 +538,19 @@ public class SiteStorageService command.Transaction = transaction; command.CommandText = @" INSERT INTO native_alarm_state - (instance_unique_name, source_canonical_name, source_reference, condition_json, last_transition_at) - VALUES (@name, @source, @ref, @json, @at) + (instance_unique_name, source_canonical_name, source_reference, condition_json, last_transition_at, metadata_json) + VALUES (@name, @source, @ref, @json, @at, @meta) ON CONFLICT(instance_unique_name, source_canonical_name, source_reference) DO UPDATE SET condition_json = excluded.condition_json, - last_transition_at = excluded.last_transition_at"; + last_transition_at = excluded.last_transition_at, + metadata_json = excluded.metadata_json"; var pName = command.Parameters.Add("@name", SqliteType.Text); var pSource = command.Parameters.Add("@source", SqliteType.Text); var pRef = command.Parameters.Add("@ref", SqliteType.Text); var pJson = command.Parameters.Add("@json", SqliteType.Text); var pAt = command.Parameters.Add("@at", SqliteType.Text); + var pMeta = command.Parameters.Add("@meta", SqliteType.Text); pName.Value = instanceName; pSource.Value = sourceCanonicalName; @@ -550,6 +559,7 @@ public class SiteStorageService pRef.Value = row.SourceReference; pJson.Value = row.ConditionJson; pAt.Value = row.LastTransitionAt.ToString("O"); + pMeta.Value = (object?)row.MetadataJson ?? DBNull.Value; await command.ExecuteNonQueryAsync(); } @@ -595,7 +605,7 @@ public class SiteStorageService await using var command = connection.CreateCommand(); command.CommandText = @" - SELECT source_reference, condition_json, last_transition_at + SELECT source_reference, condition_json, last_transition_at, metadata_json FROM native_alarm_state WHERE instance_unique_name = @name AND source_canonical_name = @source"; command.Parameters.AddWithValue("@name", instanceName); @@ -608,7 +618,8 @@ public class SiteStorageService results.Add(new NativeAlarmRow( reader.GetString(0), reader.GetString(1), - DateTimeOffset.Parse(reader.GetString(2), null, System.Globalization.DateTimeStyles.RoundtripKind))); + DateTimeOffset.Parse(reader.GetString(2), null, System.Globalization.DateTimeStyles.RoundtripKind), + reader.IsDBNull(3) ? null : reader.GetString(3))); } return results; @@ -1020,5 +1031,8 @@ public class StoredDataConnectionDefinition /// /// A single mirrored native alarm condition row from the site-local native_alarm_state table. +/// carries the serialized display metadata (type/category/message/ +/// values) restored on rehydration (UA4); null on rows written before the column existed. /// -public record NativeAlarmRow(string SourceReference, string ConditionJson, DateTimeOffset LastTransitionAt); +public record NativeAlarmRow( + string SourceReference, string ConditionJson, DateTimeOffset LastTransitionAt, string? MetadataJson = null); diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/NativeAlarmActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/NativeAlarmActorTests.cs index 4bd5512e..f8dd29ec 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/NativeAlarmActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/NativeAlarmActorTests.cs @@ -184,6 +184,42 @@ public class NativeAlarmActorTests : TestKit, IDisposable Assert.Contains(rows, r => r.ConditionJson.Contains("49")); } + // ── PLAN-03 Task 21 (UA4): rehydration restores display metadata ──────── + + [Fact] + public async Task Rehydration_RestoresDisplayMetadata() + { + var instance1 = CreateTestProbe(); + var dcl1 = CreateTestProbe(); + var actor1 = SpawnWithFlush(instance1.Ref, dcl1.Ref, TimeSpan.FromMilliseconds(100)); + dcl1.ExpectMsg(); + + // A live raise carrying full display metadata. + actor1.Tell(new NativeAlarmTransitionUpdate("Opc", new NativeAlarmTransition( + "ref-1", "T01", "HighLevelAlarm", AlarmTransitionKind.Raise, + new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800), + "Process", "desc", "Tank overflow", "", "", null, DateTimeOffset.UtcNow, "92", "90"))); + instance1.ExpectMsg(m => m.State == AlarmState.Active); + + // Wait for the coalesced flush to land the metadata in SQLite. + await AwaitAssertAsync(async () => + { + var rows = await _storage.GetNativeAlarmsAsync("inst", Source().CanonicalName); + Assert.Contains(rows, r => r.MetadataJson != null && r.MetadataJson.Contains("HighLevelAlarm")); + }, TimeSpan.FromSeconds(3)); + + // A fresh actor on the SAME storage rehydrates in PreStart and re-emits the condition + // with the restored metadata instead of empty strings. + var instance2 = CreateTestProbe(); + var dcl2 = CreateTestProbe(); + SpawnWithFlush(instance2.Ref, dcl2.Ref, TimeSpan.FromMilliseconds(100)); + + var emitted = instance2.FishForMessage(m => m.SourceReference == "ref-1", TimeSpan.FromSeconds(5)); + Assert.Equal("HighLevelAlarm", emitted.AlarmTypeName); + Assert.Equal("Process", emitted.Category); + Assert.Equal("Tank overflow", emitted.Message); + } + // ── M1.5: site event log `alarm` category ────────────────────────────── [Fact]