diff --git a/docs/requirements/Component-DataConnectionLayer.md b/docs/requirements/Component-DataConnectionLayer.md index 8207fd45..80db53e1 100644 --- a/docs/requirements/Component-DataConnectionLayer.md +++ b/docs/requirements/Component-DataConnectionLayer.md @@ -270,12 +270,30 @@ All defined in Commons so the feed is identical across protocols: | Type | Shape | |------|-------| -| `NativeAlarmTransition` | `SourceReference`, `SourceObjectReference`, `AlarmTypeName`, `Kind`, `Condition`, `Category`, `Description`, `Message`, `OperatorUser`, `OperatorComment`, `OriginalRaiseTime?`, `TransitionTime`, `CurrentValue`, `LimitValue` | +| `NativeAlarmTransition` | `SourceReference`, `SourceObjectReference`, `AlarmTypeName`, `Kind`, `Condition`, `Category`, `Description`, `Message`, `OperatorUser`, `OperatorComment`, `OriginalRaiseTime?`, `TransitionTime`, `CurrentValue`, `LimitValue`, `AckTime?` | | `AlarmConditionState` | `Active`, `Acknowledged`, `Confirmed?` (null when not confirmable), `Shelve`, `Suppressed`, `Severity` (0–1000) | | `AlarmTransitionKind` (enum) | `Snapshot`, `SnapshotComplete`, `Raise`, `Acknowledge`, `Clear`, `Retrigger`, `StateChange` | `OperatorUser` / `OperatorComment` and `CurrentValue` / `LimitValue` are display-only mirrors from the source. +### Ack Timestamp (`AckTime`) + +`AckTime` is the instant a condition was acknowledged, mirrored end-to-end (transition → `AlarmStateChanged` → `AlarmStateUpdate` field 24 → site `native_alarm_state`) so MES can report a real `AckDT` and the `Alarms.CurrentAsync()` script accessor can surface it. Added additively as a trailing optional parameter — every pre-existing positional construction still compiles and yields `null`. + +**One rule decides whether it is set: the condition must be BOTH active and acknowledged.** That single predicate gives all three required behaviours — null while unacknowledged, cleared on re-raise (a re-raise transition arrives unacknowledged), and no phantom ack on a return-to-normal (which matters for MxGateway, where `INACTIVE` maps to `Acknowledged = true`). + +**Provenance differs by protocol, and the difference is deliberate — the value is never fabricated:** + +| Source | Ack instant used | Accuracy | +|---|---|---| +| OPC UA A&C | `AcknowledgeableConditionType/AckedState/TransitionTime` — **SelectClause index 18**, appended after the limit fields so indices 0–17 keep their meaning | The source's own ack instant | +| OPC UA A&C, server omits the field | The event's `Time` field | When the DCL observed the acknowledged state | +| MxAccess Gateway | The transition's own timestamp (`TransitionTimestamp`, or the DCL's receipt time when the gateway omits it); on a re-subscribe snapshot, `LastTransitionTimestamp` for an `ACTIVE_ACKED` entry | When the system saw the ack — the gateway feed carries no dedicated ack timestamp | + +The decision lives in the pure mappers (`OpcUaAlarmMapper.DeriveAckTime`, `MxGatewayAlarmMapper.DeriveAckTime`), so it is unit-tested without a live server or gateway. + +Design record: `docs/plans/2026-06-30-mes-alarm-status-api.md` §6.4. + **Messages:** - `SubscribeAlarmsRequest` / `SubscribeAlarmsResponse` — instance (via the DCL manager) subscribes a source binding to native alarms; the response carries success + an optional error message. diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Streaming/AlarmStateChanged.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Streaming/AlarmStateChanged.cs index ec2ddb37..e163a02c 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Streaming/AlarmStateChanged.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Streaming/AlarmStateChanged.cs @@ -66,6 +66,28 @@ public record AlarmStateChanged( /// When the native condition originally became active, if known. public DateTimeOffset? OriginalRaiseTime { get; init; } + /// + /// When the condition was acknowledged, or null while it is unacknowledged. + /// Additive native-mirror enrichment (MES alarm-status API §6.4) — the ack timestamp + /// the MES AlarmInfo.AckDT field reports and the Alarms.CurrentAsync() + /// script accessor surfaces as ScriptAlarm.AckTime. + /// + /// + /// Provenance: the DCL stamps the SOURCE's own ack instant where the protocol supplies + /// one (OPC UA A&C exposes AckedState/TransitionTime); where it does not + /// (MxAccess Gateway alarm events), the DCL stamps the transition time it OBSERVED the + /// ack at. It is therefore never fabricated, but for MxGateway sources it is accurate + /// to "when the system saw the ack", not necessarily to the operator's click. + /// + /// + /// + /// Null for computed alarms (they are auto-acked and have no operator ack event) and + /// null while a native condition is unacknowledged; cleared on re-raise, because a + /// re-raise transition carries Acknowledged = false. + /// + /// + public DateTimeOffset? AckTime { get; init; } + /// Current source value (display-only); empty for computed alarms. public string CurrentValue { get; init; } = string.Empty; diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Alarms/NativeAlarmTransition.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Alarms/NativeAlarmTransition.cs index 02cbeb6e..3d13fe90 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Alarms/NativeAlarmTransition.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Alarms/NativeAlarmTransition.cs @@ -22,6 +22,14 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms; /// When this transition occurred. /// Current source value (display-only). /// Limit/threshold value for limit alarms (display-only). +/// +/// When the condition was acknowledged, or null while it is unacknowledged. +/// Additive trailing parameter (MES alarm-status API §6.4) so every existing positional +/// construction stays valid. Stamped by the adapter: the SOURCE's own ack instant where the +/// protocol supplies one (OPC UA A&C AckedState/TransitionTime), otherwise the +/// transition time the DCL OBSERVED the ack at (MxAccess Gateway). Non-null only while the +/// condition is active AND acknowledged, so a re-raise (which arrives unacknowledged) clears it. +/// public record NativeAlarmTransition( string SourceReference, string SourceObjectReference, @@ -36,4 +44,5 @@ public record NativeAlarmTransition( DateTimeOffset? OriginalRaiseTime, DateTimeOffset TransitionTime, string CurrentValue, - string LimitValue); + string LimitValue, + DateTimeOffset? AckTime = null); diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/StreamRelayActor.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/StreamRelayActor.cs index 7f59aaa2..6046e27e 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/StreamRelayActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/StreamRelayActor.cs @@ -95,7 +95,12 @@ public class StreamRelayActor : ReceiveActor CurrentValue = msg.CurrentValue ?? string.Empty, LimitValue = msg.LimitValue ?? string.Empty, NativeSourceCanonicalName = msg.NativeSourceCanonicalName ?? string.Empty, - IsConfiguredPlaceholder = msg.IsConfiguredPlaceholder + IsConfiguredPlaceholder = msg.IsConfiguredPlaceholder, + // MES alarm-status API §6.4: ack instant, null while unacknowledged and on + // computed alarms — a null proto Timestamp round-trips back to null. + AckTime = msg.AckTime.HasValue + ? Timestamp.FromDateTimeOffset(msg.AckTime.Value) + : null } }; diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/SiteStreamGrpcClient.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/SiteStreamGrpcClient.cs index b35c9ca4..52584b40 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/SiteStreamGrpcClient.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/SiteStreamGrpcClient.cs @@ -344,7 +344,10 @@ public class SiteStreamGrpcClient : IAsyncDisposable, IDisposable CurrentValue = evt.AlarmChanged.CurrentValue ?? string.Empty, LimitValue = evt.AlarmChanged.LimitValue ?? string.Empty, NativeSourceCanonicalName = evt.AlarmChanged.NativeSourceCanonicalName ?? string.Empty, - IsConfiguredPlaceholder = evt.AlarmChanged.IsConfiguredPlaceholder + IsConfiguredPlaceholder = evt.AlarmChanged.IsConfiguredPlaceholder, + // MES alarm-status API §6.4: ack instant; an absent proto Timestamp (unacked + // condition, computed alarm, or a pre-AckTime site) stays null. + AckTime = evt.AlarmChanged.AckTime?.ToDateTimeOffset() }, _ => null }; diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/Protos/sitestream.proto b/src/ZB.MOM.WW.ScadaBridge.Communication/Protos/sitestream.proto index 5876dcbf..27587337 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Communication/Protos/sitestream.proto +++ b/src/ZB.MOM.WW.ScadaBridge.Communication/Protos/sitestream.proto @@ -97,6 +97,12 @@ message AlarmStateUpdate { string limit_value = 21; string native_source_canonical_name = 22; // native binding canonical name; empty for computed bool is_configured_placeholder = 23; // true for a quiet-binding placeholder row + + // Ack timestamp for the condition; null while unacknowledged and on computed alarms + // (MES alarm-status API §6.4). The site stamps the SOURCE's own ack instant where the + // protocol supplies one (OPC UA A&C AckedState/TransitionTime) and the DCL's observation + // time of the ack transition where it does not (MxAccess Gateway). + google.protobuf.Timestamp ack_time = 24; } // Audit Log (#23) telemetry: single lifecycle event ferried from a site SQLite diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/SiteStreamGrpc/Sitestream.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/SiteStreamGrpc/Sitestream.cs index 6cd9eb49..450aacd3 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Communication/SiteStreamGrpc/Sitestream.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Communication/SiteStreamGrpc/Sitestream.cs @@ -37,7 +37,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc { "KAkSFgoOYXR0cmlidXRlX3BhdGgYAiABKAkSFgoOYXR0cmlidXRlX25hbWUY", "AyABKAkSDQoFdmFsdWUYBCABKAkSJAoHcXVhbGl0eRgFIAEoDjITLnNpdGVz", "dHJlYW0uUXVhbGl0eRItCgl0aW1lc3RhbXAYBiABKAsyGi5nb29nbGUucHJv", - "dG9idWYuVGltZXN0YW1wIoEFChBBbGFybVN0YXRlVXBkYXRlEhwKFGluc3Rh", + "dG9idWYuVGltZXN0YW1wIq8FChBBbGFybVN0YXRlVXBkYXRlEhwKFGluc3Rh", "bmNlX3VuaXF1ZV9uYW1lGAEgASgJEhIKCmFsYXJtX25hbWUYAiABKAkSKQoF", "c3RhdGUYAyABKA4yGi5zaXRlc3RyZWFtLkFsYXJtU3RhdGVFbnVtEhAKCHBy", "aW9yaXR5GAQgASgFEi0KCXRpbWVzdGFtcBgFIAEoCzIaLmdvb2dsZS5wcm90", @@ -51,65 +51,66 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc { "YWxfcmFpc2VfdGltZRgTIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3Rh", "bXASFQoNY3VycmVudF92YWx1ZRgUIAEoCRITCgtsaW1pdF92YWx1ZRgVIAEo", "CRIkChxuYXRpdmVfc291cmNlX2Nhbm9uaWNhbF9uYW1lGBYgASgJEiEKGWlz", - "X2NvbmZpZ3VyZWRfcGxhY2Vob2xkZXIYFyABKAgivQQKDUF1ZGl0RXZlbnRE", - "dG8SEAoIZXZlbnRfaWQYASABKAkSMwoPb2NjdXJyZWRfYXRfdXRjGAIgASgL", - "MhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIPCgdjaGFubmVsGAMgASgJ", - "EgwKBGtpbmQYBCABKAkSFgoOY29ycmVsYXRpb25faWQYBSABKAkSFgoOc291", - "cmNlX3NpdGVfaWQYBiABKAkSGgoSc291cmNlX2luc3RhbmNlX2lkGAcgASgJ", - "EhUKDXNvdXJjZV9zY3JpcHQYCCABKAkSDQoFYWN0b3IYCSABKAkSDgoGdGFy", - "Z2V0GAogASgJEg4KBnN0YXR1cxgLIAEoCRIwCgtodHRwX3N0YXR1cxgMIAEo", - "CzIbLmdvb2dsZS5wcm90b2J1Zi5JbnQzMlZhbHVlEjAKC2R1cmF0aW9uX21z", - "GA0gASgLMhsuZ29vZ2xlLnByb3RvYnVmLkludDMyVmFsdWUSFQoNZXJyb3Jf", - "bWVzc2FnZRgOIAEoCRIUCgxlcnJvcl9kZXRhaWwYDyABKAkSFwoPcmVxdWVz", - "dF9zdW1tYXJ5GBAgASgJEhgKEHJlc3BvbnNlX3N1bW1hcnkYESABKAkSGQoR", - "cGF5bG9hZF90cnVuY2F0ZWQYEiABKAgSDQoFZXh0cmEYEyABKAkSFAoMZXhl", - "Y3V0aW9uX2lkGBQgASgJEhsKE3BhcmVudF9leGVjdXRpb25faWQYFSABKAkS", - "EwoLc291cmNlX25vZGUYFiABKAkiPAoPQXVkaXRFdmVudEJhdGNoEikKBmV2", - "ZW50cxgBIAMoCzIZLnNpdGVzdHJlYW0uQXVkaXRFdmVudER0byInCglJbmdl", - "c3RBY2sSGgoSYWNjZXB0ZWRfZXZlbnRfaWRzGAEgAygJIokDChZTaXRlQ2Fs", - "bE9wZXJhdGlvbmFsRHRvEhwKFHRyYWNrZWRfb3BlcmF0aW9uX2lkGAEgASgJ", - "Eg8KB2NoYW5uZWwYAiABKAkSDgoGdGFyZ2V0GAMgASgJEhMKC3NvdXJjZV9z", - "aXRlGAQgASgJEg4KBnN0YXR1cxgFIAEoCRITCgtyZXRyeV9jb3VudBgGIAEo", - "BRISCgpsYXN0X2Vycm9yGAcgASgJEjAKC2h0dHBfc3RhdHVzGAggASgLMhsu", - "Z29vZ2xlLnByb3RvYnVmLkludDMyVmFsdWUSMgoOY3JlYXRlZF9hdF91dGMY", - "CSABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEjIKDnVwZGF0ZWRf", - "YXRfdXRjGAogASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIzCg90", - "ZXJtaW5hbF9hdF91dGMYCyABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0", - "YW1wEhMKC3NvdXJjZV9ub2RlGAwgASgJIoABChVDYWNoZWRUZWxlbWV0cnlQ", - "YWNrZXQSLgoLYXVkaXRfZXZlbnQYASABKAsyGS5zaXRlc3RyZWFtLkF1ZGl0", - "RXZlbnREdG8SNwoLb3BlcmF0aW9uYWwYAiABKAsyIi5zaXRlc3RyZWFtLlNp", - "dGVDYWxsT3BlcmF0aW9uYWxEdG8iSgoUQ2FjaGVkVGVsZW1ldHJ5QmF0Y2gS", - "MgoHcGFja2V0cxgBIAMoCzIhLnNpdGVzdHJlYW0uQ2FjaGVkVGVsZW1ldHJ5", - "UGFja2V0IlsKFlB1bGxBdWRpdEV2ZW50c1JlcXVlc3QSLQoJc2luY2VfdXRj", - "GAEgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBISCgpiYXRjaF9z", - "aXplGAIgASgFIlwKF1B1bGxBdWRpdEV2ZW50c1Jlc3BvbnNlEikKBmV2ZW50", - "cxgBIAMoCzIZLnNpdGVzdHJlYW0uQXVkaXRFdmVudER0bxIWCg5tb3JlX2F2", - "YWlsYWJsZRgCIAEoCCJrChRQdWxsU2l0ZUNhbGxzUmVxdWVzdBItCglzaW5j", - "ZV91dGMYASABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEhIKCmJh", - "dGNoX3NpemUYAiABKAUSEAoIYWZ0ZXJfaWQYAyABKAkiaQoVUHVsbFNpdGVD", - "YWxsc1Jlc3BvbnNlEjgKDG9wZXJhdGlvbmFscxgBIAMoCzIiLnNpdGVzdHJl", - "YW0uU2l0ZUNhbGxPcGVyYXRpb25hbER0bxIWCg5tb3JlX2F2YWlsYWJsZRgC", - "IAEoCCpcCgdRdWFsaXR5EhcKE1FVQUxJVFlfVU5TUEVDSUZJRUQQABIQCgxR", - "VUFMSVRZX0dPT0QQARIVChFRVUFMSVRZX1VOQ0VSVEFJThACEg8KC1FVQUxJ", - "VFlfQkFEEAMqXQoOQWxhcm1TdGF0ZUVudW0SGwoXQUxBUk1fU1RBVEVfVU5T", - "UEVDSUZJRUQQABIWChJBTEFSTV9TVEFURV9OT1JNQUwQARIWChJBTEFSTV9T", - "VEFURV9BQ1RJVkUQAiqFAQoOQWxhcm1MZXZlbEVudW0SFAoQQUxBUk1fTEVW", - "RUxfTk9ORRAAEhMKD0FMQVJNX0xFVkVMX0xPVxABEhcKE0FMQVJNX0xFVkVM", - "X0xPV19MT1cQAhIUChBBTEFSTV9MRVZFTF9ISUdIEAMSGQoVQUxBUk1fTEVW", - "RUxfSElHSF9ISUdIEAQyhgQKEVNpdGVTdHJlYW1TZXJ2aWNlElUKEVN1YnNj", - "cmliZUluc3RhbmNlEiEuc2l0ZXN0cmVhbS5JbnN0YW5jZVN0cmVhbVJlcXVl", - "c3QaGy5zaXRlc3RyZWFtLlNpdGVTdHJlYW1FdmVudDABEk0KDVN1YnNjcmli", - "ZVNpdGUSHS5zaXRlc3RyZWFtLlNpdGVTdHJlYW1SZXF1ZXN0Ghsuc2l0ZXN0", - "cmVhbS5TaXRlU3RyZWFtRXZlbnQwARJHChFJbmdlc3RBdWRpdEV2ZW50cxIb", - "LnNpdGVzdHJlYW0uQXVkaXRFdmVudEJhdGNoGhUuc2l0ZXN0cmVhbS5Jbmdl", - "c3RBY2sSUAoVSW5nZXN0Q2FjaGVkVGVsZW1ldHJ5EiAuc2l0ZXN0cmVhbS5D", - "YWNoZWRUZWxlbWV0cnlCYXRjaBoVLnNpdGVzdHJlYW0uSW5nZXN0QWNrEloK", - "D1B1bGxBdWRpdEV2ZW50cxIiLnNpdGVzdHJlYW0uUHVsbEF1ZGl0RXZlbnRz", - "UmVxdWVzdBojLnNpdGVzdHJlYW0uUHVsbEF1ZGl0RXZlbnRzUmVzcG9uc2US", - "VAoNUHVsbFNpdGVDYWxscxIgLnNpdGVzdHJlYW0uUHVsbFNpdGVDYWxsc1Jl", - "cXVlc3QaIS5zaXRlc3RyZWFtLlB1bGxTaXRlQ2FsbHNSZXNwb25zZUIrqgIo", - "WkIuTU9NLldXLlNjYWRhQnJpZGdlLkNvbW11bmljYXRpb24uR3JwY2IGcHJv", - "dG8z")); + "X2NvbmZpZ3VyZWRfcGxhY2Vob2xkZXIYFyABKAgSLAoIYWNrX3RpbWUYGCAB", + "KAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wIr0ECg1BdWRpdEV2ZW50", + "RHRvEhAKCGV2ZW50X2lkGAEgASgJEjMKD29jY3VycmVkX2F0X3V0YxgCIAEo", + "CzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASDwoHY2hhbm5lbBgDIAEo", + "CRIMCgRraW5kGAQgASgJEhYKDmNvcnJlbGF0aW9uX2lkGAUgASgJEhYKDnNv", + "dXJjZV9zaXRlX2lkGAYgASgJEhoKEnNvdXJjZV9pbnN0YW5jZV9pZBgHIAEo", + "CRIVCg1zb3VyY2Vfc2NyaXB0GAggASgJEg0KBWFjdG9yGAkgASgJEg4KBnRh", + "cmdldBgKIAEoCRIOCgZzdGF0dXMYCyABKAkSMAoLaHR0cF9zdGF0dXMYDCAB", + "KAsyGy5nb29nbGUucHJvdG9idWYuSW50MzJWYWx1ZRIwCgtkdXJhdGlvbl9t", + "cxgNIAEoCzIbLmdvb2dsZS5wcm90b2J1Zi5JbnQzMlZhbHVlEhUKDWVycm9y", + "X21lc3NhZ2UYDiABKAkSFAoMZXJyb3JfZGV0YWlsGA8gASgJEhcKD3JlcXVl", + "c3Rfc3VtbWFyeRgQIAEoCRIYChByZXNwb25zZV9zdW1tYXJ5GBEgASgJEhkK", + "EXBheWxvYWRfdHJ1bmNhdGVkGBIgASgIEg0KBWV4dHJhGBMgASgJEhQKDGV4", + "ZWN1dGlvbl9pZBgUIAEoCRIbChNwYXJlbnRfZXhlY3V0aW9uX2lkGBUgASgJ", + "EhMKC3NvdXJjZV9ub2RlGBYgASgJIjwKD0F1ZGl0RXZlbnRCYXRjaBIpCgZl", + "dmVudHMYASADKAsyGS5zaXRlc3RyZWFtLkF1ZGl0RXZlbnREdG8iJwoJSW5n", + "ZXN0QWNrEhoKEmFjY2VwdGVkX2V2ZW50X2lkcxgBIAMoCSKJAwoWU2l0ZUNh", + "bGxPcGVyYXRpb25hbER0bxIcChR0cmFja2VkX29wZXJhdGlvbl9pZBgBIAEo", + "CRIPCgdjaGFubmVsGAIgASgJEg4KBnRhcmdldBgDIAEoCRITCgtzb3VyY2Vf", + "c2l0ZRgEIAEoCRIOCgZzdGF0dXMYBSABKAkSEwoLcmV0cnlfY291bnQYBiAB", + "KAUSEgoKbGFzdF9lcnJvchgHIAEoCRIwCgtodHRwX3N0YXR1cxgIIAEoCzIb", + "Lmdvb2dsZS5wcm90b2J1Zi5JbnQzMlZhbHVlEjIKDmNyZWF0ZWRfYXRfdXRj", + "GAkgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIyCg51cGRhdGVk", + "X2F0X3V0YxgKIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASMwoP", + "dGVybWluYWxfYXRfdXRjGAsgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVz", + "dGFtcBITCgtzb3VyY2Vfbm9kZRgMIAEoCSKAAQoVQ2FjaGVkVGVsZW1ldHJ5", + "UGFja2V0Ei4KC2F1ZGl0X2V2ZW50GAEgASgLMhkuc2l0ZXN0cmVhbS5BdWRp", + "dEV2ZW50RHRvEjcKC29wZXJhdGlvbmFsGAIgASgLMiIuc2l0ZXN0cmVhbS5T", + "aXRlQ2FsbE9wZXJhdGlvbmFsRHRvIkoKFENhY2hlZFRlbGVtZXRyeUJhdGNo", + "EjIKB3BhY2tldHMYASADKAsyIS5zaXRlc3RyZWFtLkNhY2hlZFRlbGVtZXRy", + "eVBhY2tldCJbChZQdWxsQXVkaXRFdmVudHNSZXF1ZXN0Ei0KCXNpbmNlX3V0", + "YxgBIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASEgoKYmF0Y2hf", + "c2l6ZRgCIAEoBSJcChdQdWxsQXVkaXRFdmVudHNSZXNwb25zZRIpCgZldmVu", + "dHMYASADKAsyGS5zaXRlc3RyZWFtLkF1ZGl0RXZlbnREdG8SFgoObW9yZV9h", + "dmFpbGFibGUYAiABKAgiawoUUHVsbFNpdGVDYWxsc1JlcXVlc3QSLQoJc2lu", + "Y2VfdXRjGAEgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBISCgpi", + "YXRjaF9zaXplGAIgASgFEhAKCGFmdGVyX2lkGAMgASgJImkKFVB1bGxTaXRl", + "Q2FsbHNSZXNwb25zZRI4CgxvcGVyYXRpb25hbHMYASADKAsyIi5zaXRlc3Ry", + "ZWFtLlNpdGVDYWxsT3BlcmF0aW9uYWxEdG8SFgoObW9yZV9hdmFpbGFibGUY", + "AiABKAgqXAoHUXVhbGl0eRIXChNRVUFMSVRZX1VOU1BFQ0lGSUVEEAASEAoM", + "UVVBTElUWV9HT09EEAESFQoRUVVBTElUWV9VTkNFUlRBSU4QAhIPCgtRVUFM", + "SVRZX0JBRBADKl0KDkFsYXJtU3RhdGVFbnVtEhsKF0FMQVJNX1NUQVRFX1VO", + "U1BFQ0lGSUVEEAASFgoSQUxBUk1fU1RBVEVfTk9STUFMEAESFgoSQUxBUk1f", + "U1RBVEVfQUNUSVZFEAIqhQEKDkFsYXJtTGV2ZWxFbnVtEhQKEEFMQVJNX0xF", + "VkVMX05PTkUQABITCg9BTEFSTV9MRVZFTF9MT1cQARIXChNBTEFSTV9MRVZF", + "TF9MT1dfTE9XEAISFAoQQUxBUk1fTEVWRUxfSElHSBADEhkKFUFMQVJNX0xF", + "VkVMX0hJR0hfSElHSBAEMoYEChFTaXRlU3RyZWFtU2VydmljZRJVChFTdWJz", + "Y3JpYmVJbnN0YW5jZRIhLnNpdGVzdHJlYW0uSW5zdGFuY2VTdHJlYW1SZXF1", + "ZXN0Ghsuc2l0ZXN0cmVhbS5TaXRlU3RyZWFtRXZlbnQwARJNCg1TdWJzY3Jp", + "YmVTaXRlEh0uc2l0ZXN0cmVhbS5TaXRlU3RyZWFtUmVxdWVzdBobLnNpdGVz", + "dHJlYW0uU2l0ZVN0cmVhbUV2ZW50MAESRwoRSW5nZXN0QXVkaXRFdmVudHMS", + "Gy5zaXRlc3RyZWFtLkF1ZGl0RXZlbnRCYXRjaBoVLnNpdGVzdHJlYW0uSW5n", + "ZXN0QWNrElAKFUluZ2VzdENhY2hlZFRlbGVtZXRyeRIgLnNpdGVzdHJlYW0u", + "Q2FjaGVkVGVsZW1ldHJ5QmF0Y2gaFS5zaXRlc3RyZWFtLkluZ2VzdEFjaxJa", + "Cg9QdWxsQXVkaXRFdmVudHMSIi5zaXRlc3RyZWFtLlB1bGxBdWRpdEV2ZW50", + "c1JlcXVlc3QaIy5zaXRlc3RyZWFtLlB1bGxBdWRpdEV2ZW50c1Jlc3BvbnNl", + "ElQKDVB1bGxTaXRlQ2FsbHMSIC5zaXRlc3RyZWFtLlB1bGxTaXRlQ2FsbHNS", + "ZXF1ZXN0GiEuc2l0ZXN0cmVhbS5QdWxsU2l0ZUNhbGxzUmVzcG9uc2VCK6oC", + "KFpCLk1PTS5XVy5TY2FkYUJyaWRnZS5Db21tdW5pY2F0aW9uLkdycGNiBnBy", + "b3RvMw==")); descriptor = pbr::FileDescriptor.FromGeneratedCode(descriptorData, new pbr::FileDescriptor[] { global::Google.Protobuf.WellKnownTypes.TimestampReflection.Descriptor, global::Google.Protobuf.WellKnownTypes.WrappersReflection.Descriptor, }, new pbr::GeneratedClrTypeInfo(new[] {typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.Quality), typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AlarmStateEnum), typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AlarmLevelEnum), }, null, new pbr::GeneratedClrTypeInfo[] { @@ -117,7 +118,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc { new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamRequest), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamRequest.Parser, new[]{ "CorrelationId" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamEvent), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamEvent.Parser, new[]{ "CorrelationId", "AttributeChanged", "AlarmChanged" }, new[]{ "Event" }, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AttributeValueUpdate), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AttributeValueUpdate.Parser, new[]{ "InstanceUniqueName", "AttributePath", "AttributeName", "Value", "Quality", "Timestamp" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AlarmStateUpdate), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AlarmStateUpdate.Parser, new[]{ "InstanceUniqueName", "AlarmName", "State", "Priority", "Timestamp", "Level", "Message", "Kind", "Active", "Acknowledged", "Confirmed", "ShelveState", "Suppressed", "SourceReference", "AlarmTypeName", "Category", "OperatorUser", "OperatorComment", "OriginalRaiseTime", "CurrentValue", "LimitValue", "NativeSourceCanonicalName", "IsConfiguredPlaceholder" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AlarmStateUpdate), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AlarmStateUpdate.Parser, new[]{ "InstanceUniqueName", "AlarmName", "State", "Priority", "Timestamp", "Level", "Message", "Kind", "Active", "Acknowledged", "Confirmed", "ShelveState", "Suppressed", "SourceReference", "AlarmTypeName", "Category", "OperatorUser", "OperatorComment", "OriginalRaiseTime", "CurrentValue", "LimitValue", "NativeSourceCanonicalName", "IsConfiguredPlaceholder", "AckTime" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AuditEventDto), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AuditEventDto.Parser, new[]{ "EventId", "OccurredAtUtc", "Channel", "Kind", "CorrelationId", "SourceSiteId", "SourceInstanceId", "SourceScript", "Actor", "Target", "Status", "HttpStatus", "DurationMs", "ErrorMessage", "ErrorDetail", "RequestSummary", "ResponseSummary", "PayloadTruncated", "Extra", "ExecutionId", "ParentExecutionId", "SourceNode" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AuditEventBatch), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AuditEventBatch.Parser, new[]{ "Events" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.IngestAck), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.IngestAck.Parser, new[]{ "AcceptedEventIds" }, null, null, null, null), @@ -1382,6 +1383,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc { limitValue_ = other.limitValue_; nativeSourceCanonicalName_ = other.nativeSourceCanonicalName_; isConfiguredPlaceholder_ = other.isConfiguredPlaceholder_; + ackTime_ = other.ackTime_ != null ? other.ackTime_.Clone() : null; _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); } @@ -1701,6 +1703,24 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc { } } + /// Field number for the "ack_time" field. + public const int AckTimeFieldNumber = 24; + private global::Google.Protobuf.WellKnownTypes.Timestamp ackTime_; + /// + /// Ack timestamp for the condition; null while unacknowledged and on computed alarms + /// (MES alarm-status API §6.4). The site stamps the SOURCE's own ack instant where the + /// protocol supplies one (OPC UA A&C AckedState/TransitionTime) and the DCL's observation + /// time of the ack transition where it does not (MxAccess Gateway). + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public global::Google.Protobuf.WellKnownTypes.Timestamp AckTime { + get { return ackTime_; } + set { + ackTime_ = value; + } + } + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public override bool Equals(object other) { @@ -1739,6 +1759,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc { if (LimitValue != other.LimitValue) return false; if (NativeSourceCanonicalName != other.NativeSourceCanonicalName) return false; if (IsConfiguredPlaceholder != other.IsConfiguredPlaceholder) return false; + if (!object.Equals(AckTime, other.AckTime)) return false; return Equals(_unknownFields, other._unknownFields); } @@ -1769,6 +1790,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc { if (LimitValue.Length != 0) hash ^= LimitValue.GetHashCode(); if (NativeSourceCanonicalName.Length != 0) hash ^= NativeSourceCanonicalName.GetHashCode(); if (IsConfiguredPlaceholder != false) hash ^= IsConfiguredPlaceholder.GetHashCode(); + if (ackTime_ != null) hash ^= AckTime.GetHashCode(); if (_unknownFields != null) { hash ^= _unknownFields.GetHashCode(); } @@ -1879,6 +1901,10 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc { output.WriteRawTag(184, 1); output.WriteBool(IsConfiguredPlaceholder); } + if (ackTime_ != null) { + output.WriteRawTag(194, 1); + output.WriteMessage(AckTime); + } if (_unknownFields != null) { _unknownFields.WriteTo(output); } @@ -1981,6 +2007,10 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc { output.WriteRawTag(184, 1); output.WriteBool(IsConfiguredPlaceholder); } + if (ackTime_ != null) { + output.WriteRawTag(194, 1); + output.WriteMessage(AckTime); + } if (_unknownFields != null) { _unknownFields.WriteTo(ref output); } @@ -2060,6 +2090,9 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc { if (IsConfiguredPlaceholder != false) { size += 2 + 1; } + if (ackTime_ != null) { + size += 2 + pb::CodedOutputStream.ComputeMessageSize(AckTime); + } if (_unknownFields != null) { size += _unknownFields.CalculateSize(); } @@ -2147,6 +2180,12 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc { if (other.IsConfiguredPlaceholder != false) { IsConfiguredPlaceholder = other.IsConfiguredPlaceholder; } + if (other.ackTime_ != null) { + if (ackTime_ == null) { + AckTime = new global::Google.Protobuf.WellKnownTypes.Timestamp(); + } + AckTime.MergeFrom(other.AckTime); + } _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); } @@ -2264,6 +2303,13 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc { IsConfiguredPlaceholder = input.ReadBool(); break; } + case 194: { + if (ackTime_ == null) { + AckTime = new global::Google.Protobuf.WellKnownTypes.Timestamp(); + } + input.ReadMessage(AckTime); + break; + } } } #endif @@ -2381,6 +2427,13 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc { IsConfiguredPlaceholder = input.ReadBool(); break; } + case 194: { + if (ackTime_ == null) { + AckTime = new global::Google.Protobuf.WellKnownTypes.Timestamp(); + } + input.ReadMessage(AckTime); + break; + } } } } diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/MxGatewayAlarmMapper.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/MxGatewayAlarmMapper.cs index 822336ed..3801bb0d 100644 --- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/MxGatewayAlarmMapper.cs +++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/MxGatewayAlarmMapper.cs @@ -69,6 +69,33 @@ public static class MxGatewayAlarmMapper Shelve: AlarmShelveState.Unshelved, Suppressed: false, Severity: NormalizeSeverity(severity)); } + /// + /// Derives the ack timestamp mirrored onto NativeAlarmTransition.AckTime + /// (MES alarm-status API §6.4). + /// + /// + /// Unlike OPC UA A&C, the MxAccess Gateway alarm feed carries NO dedicated ack + /// timestamp — an acknowledgement arrives as an ACTIVE_ACKED condition state on a + /// transition whose only time is (the gateway's + /// transition timestamp, or the DCL's own receipt time when the gateway omits it). That + /// observation time is what gets stamped: accurate to when the system saw the ack, never + /// fabricated, but not necessarily the operator's click. + /// + /// + /// + /// Returns null unless the condition is active AND acknowledged — matching + /// OpcUaAlarmMapper.DeriveAckTime, so both native protocols agree on "null while + /// unacked, cleared on re-raise". This matters especially here because the gateway maps + /// INACTIVE to acked = true: without the active check, every return-to-normal + /// would report an ack it never observed. + /// + /// + /// The mirrored condition state for the transition. + /// The transition's timestamp — the DCL's observation instant for the ack. + /// The observed ack timestamp, or null when the condition is not an outstanding acknowledged alarm. + public static DateTimeOffset? DeriveAckTime(AlarmConditionState condition, DateTimeOffset transitionTime) => + condition is { Active: true, Acknowledged: true } ? transitionTime : null; + /// /// Converts an union to a display-only string using /// and invariant culture formatting, @@ -87,28 +114,37 @@ public static class MxGatewayAlarmMapper /// Maps a live to a transition. /// The gateway alarm transition event proto message to map. /// The protocol-neutral . - public static NativeAlarmTransition MapTransition(OnAlarmTransitionEvent body) => new( - // Identify the condition by the object-relative reference (e.g. - // "Z28061.HeartbeatTimeoutAlarm") rather than the gateway's full provider - // reference ("Galaxy!.."). The area is preserved in - // Category; the object reference is globally unique within the galaxy and - // is the form operators expect. Falls back to the full reference only if - // the gateway omits the object reference. - SourceReference: string.IsNullOrEmpty(body.SourceObjectReference) - ? body.AlarmFullReference : body.SourceObjectReference, - SourceObjectReference: body.SourceObjectReference, - AlarmTypeName: body.AlarmTypeName, - Kind: MapKind(body.TransitionKind), - Condition: ConditionFromKind(body.TransitionKind, body.Severity), - Category: body.Category, - Description: body.Description, - Message: body.Description, - OperatorUser: body.OperatorUser, - OperatorComment: body.OperatorComment, - OriginalRaiseTime: body.OriginalRaiseTimestamp?.ToDateTimeOffset(), - TransitionTime: body.TransitionTimestamp?.ToDateTimeOffset() ?? DateTimeOffset.UtcNow, - CurrentValue: MxValueToString(body.CurrentValue), - LimitValue: MxValueToString(body.LimitValue)); + public static NativeAlarmTransition MapTransition(OnAlarmTransitionEvent body) + { + var condition = ConditionFromKind(body.TransitionKind, body.Severity); + var transitionTime = body.TransitionTimestamp?.ToDateTimeOffset() ?? DateTimeOffset.UtcNow; + + return new NativeAlarmTransition( + // Identify the condition by the object-relative reference (e.g. + // "Z28061.HeartbeatTimeoutAlarm") rather than the gateway's full provider + // reference ("Galaxy!.."). The area is preserved in + // Category; the object reference is globally unique within the galaxy and + // is the form operators expect. Falls back to the full reference only if + // the gateway omits the object reference. + SourceReference: string.IsNullOrEmpty(body.SourceObjectReference) + ? body.AlarmFullReference : body.SourceObjectReference, + SourceObjectReference: body.SourceObjectReference, + AlarmTypeName: body.AlarmTypeName, + Kind: MapKind(body.TransitionKind), + Condition: condition, + Category: body.Category, + Description: body.Description, + Message: body.Description, + OperatorUser: body.OperatorUser, + OperatorComment: body.OperatorComment, + OriginalRaiseTime: body.OriginalRaiseTimestamp?.ToDateTimeOffset(), + TransitionTime: transitionTime, + CurrentValue: MxValueToString(body.CurrentValue), + LimitValue: MxValueToString(body.LimitValue), + // MES alarm-status API §6.4: the gateway supplies no ack timestamp, so an ack + // is stamped with the transition time it was observed at. + AckTime: DeriveAckTime(condition, transitionTime)); + } /// The end-of-snapshot sentinel transition (no condition payload). /// A with AlarmTransitionKind.SnapshotComplete. @@ -120,22 +156,32 @@ public static class MxGatewayAlarmMapper /// Maps one initial-snapshot entry to a Snapshot transition. /// The active alarm snapshot proto message to map. /// A with AlarmTransitionKind.Snapshot. - public static NativeAlarmTransition MapSnapshot(ActiveAlarmSnapshot snapshot) => new( - // See MapTransition: identify by the object-relative reference, not the - // full "Galaxy!.." provider reference. - SourceReference: string.IsNullOrEmpty(snapshot.SourceObjectReference) - ? snapshot.AlarmFullReference : snapshot.SourceObjectReference, - SourceObjectReference: snapshot.SourceObjectReference, - AlarmTypeName: snapshot.AlarmTypeName, - Kind: AlarmTransitionKind.Snapshot, - Condition: MapConditionState(snapshot.CurrentState, snapshot.Severity), - Category: snapshot.Category, - Description: snapshot.Description, - Message: snapshot.Description, - OperatorUser: snapshot.OperatorUser, - OperatorComment: snapshot.OperatorComment, - OriginalRaiseTime: snapshot.OriginalRaiseTimestamp?.ToDateTimeOffset(), - TransitionTime: snapshot.LastTransitionTimestamp?.ToDateTimeOffset() ?? DateTimeOffset.UtcNow, - CurrentValue: MxValueToString(snapshot.CurrentValue), - LimitValue: MxValueToString(snapshot.LimitValue)); + public static NativeAlarmTransition MapSnapshot(ActiveAlarmSnapshot snapshot) + { + var condition = MapConditionState(snapshot.CurrentState, snapshot.Severity); + var transitionTime = snapshot.LastTransitionTimestamp?.ToDateTimeOffset() ?? DateTimeOffset.UtcNow; + + return new NativeAlarmTransition( + // See MapTransition: identify by the object-relative reference, not the + // full "Galaxy!.." provider reference. + SourceReference: string.IsNullOrEmpty(snapshot.SourceObjectReference) + ? snapshot.AlarmFullReference : snapshot.SourceObjectReference, + SourceObjectReference: snapshot.SourceObjectReference, + AlarmTypeName: snapshot.AlarmTypeName, + Kind: AlarmTransitionKind.Snapshot, + Condition: condition, + Category: snapshot.Category, + Description: snapshot.Description, + Message: snapshot.Description, + OperatorUser: snapshot.OperatorUser, + OperatorComment: snapshot.OperatorComment, + OriginalRaiseTime: snapshot.OriginalRaiseTimestamp?.ToDateTimeOffset(), + TransitionTime: transitionTime, + CurrentValue: MxValueToString(snapshot.CurrentValue), + LimitValue: MxValueToString(snapshot.LimitValue), + // MES alarm-status API §6.4: an ACTIVE_ACKED snapshot entry reports the last + // transition time as its ack instant — the best the gateway feed supplies. A + // (re)subscribe snapshot therefore restores an ack time rather than losing it. + AckTime: DeriveAckTime(condition, transitionTime)); + } } diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaAlarmMapper.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaAlarmMapper.cs index 5b06a419..0c874144 100644 --- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaAlarmMapper.cs +++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaAlarmMapper.cs @@ -92,6 +92,37 @@ public static class OpcUaAlarmMapper return AlarmTransitionKind.StateChange; } + /// + /// Derives the ack timestamp mirrored onto NativeAlarmTransition.AckTime + /// (MES alarm-status API §6.4). + /// + /// + /// OPC UA A&C DOES supply a true ack instant: AckedState/TransitionTime is the + /// UTC time the AckedState last flipped, so when the condition is acknowledged that field + /// IS the moment of acknowledgement. It is an optional event field (absent on base + /// ConditionType events and on servers that do not expose it), hence + /// is nullable and falls back to the event's own + /// time — the instant the DCL observed the acknowledged + /// state. Never fabricated: both values are real timestamps, the fallback is simply + /// coarser. + /// + /// + /// + /// Returns null unless the condition is active AND acknowledged. That single rule + /// gives the two behaviours the design calls for: null while unacked, and cleared on + /// re-raise (a re-raise arrives with acked == false). A cleared condition + /// (inactive) is on its way out of the mirror and reports no ack time either. + /// + /// + /// Whether the condition is currently active. + /// Whether the condition is currently acknowledged. + /// The source's AckedState/TransitionTime, or null when the server omits it. + /// The event's own transition time — the DCL's observation instant. + /// The ack timestamp, or null when the condition is not an outstanding acknowledged alarm. + public static DateTimeOffset? DeriveAckTime( + bool active, bool acked, DateTimeOffset? sourceAckTime, DateTimeOffset observedAt) => + active && acked ? sourceAckTime ?? observedAt : null; + /// Maps the OPC UA ShelvingState current-state node name to the shelve enum. /// The OPC UA ShelvingState node name, or null when unshelved. /// The corresponding ; defaults to . diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs index 8b511f26..0fdf8db6 100644 --- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs +++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs @@ -699,6 +699,16 @@ public class RealOpcUaClient : IOpcUaClient filter.SelectClauses.Add(SelectField(ObjectTypeIds.LimitAlarmType, "LowLimit")); // 16 filter.SelectClauses.Add(SelectField(ObjectTypeIds.LimitAlarmType, "LowLowLimit")); // 17 + // 18: AcknowledgeableConditionType/AckedState/TransitionTime — the UTC instant the + // acked-state last flipped. When the condition is currently ACKED that instant IS + // the moment of acknowledgement, so it is the true source ack time mapped to + // NativeAlarmTransition.AckTime (MES alarm-status API §6.4). Optional — absent on + // base ConditionType events and on servers that do not expose it, in which case + // OpcUaAlarmMapper.DeriveAckTime falls back to the event's own Time field. Same + // ConditionRefresh caveat as index 13: a replayed snapshot may re-stamp it. + filter.SelectClauses.Add( + SelectField(ObjectTypeIds.AcknowledgeableConditionType, "AckedState", "TransitionTime")); // 18 + // UNAVAILABLE via standard OPC UA A&C event fields (documented here so future // maintainers know these were considered, not overlooked): // Category — not a standard event field; server-specific extensions only. @@ -848,6 +858,15 @@ public class RealOpcUaClient : IOpcUaClient fields.Count > 16 ? fields[16].Value : null, fields.Count > 17 ? fields[17].Value : null); + // Index 18: AckedState/TransitionTime → the true source ack instant when the condition + // is currently acked. Absent on non-acknowledgeable events / servers that omit it → + // guard + null fallback, which DeriveAckTime resolves to the event time below. + DateTimeOffset? sourceAckTime = null; + if (fields.Count > 18 && fields[18].Value is DateTime ackTransitionTime) + // OPC UA mandates UTC for DateTime fields; TimeSpan.Zero treats an Unspecified + // Kind as UTC (consistent with the Time and ActiveState/TransitionTime mappings). + sourceAckTime = new DateTimeOffset(ackTransitionTime, TimeSpan.Zero); + var inRefresh = _alarmInRefresh.GetValueOrDefault(handle); var lastState = _alarmLastState.GetValueOrDefault(handle); var (prevActive, prevAcked) = lastState != null && lastState.TryGetValue(sourceRef, out var prev) ? prev : (false, true); @@ -876,7 +895,10 @@ public class RealOpcUaClient : IOpcUaClient TransitionTime: time, // UNAVAILABLE: CurrentValue not a standard A&C event field — see BuildAlarmEventFilter. CurrentValue: "", - LimitValue: limitValue)); + LimitValue: limitValue, + // MES alarm-status API §6.4: true source ack instant (index 18) when supplied, + // else the observed event time; null unless the condition is active AND acked. + AckTime: OpcUaAlarmMapper.DeriveAckTime(active, acked, sourceAckTime, time))); } private static NativeAlarmTransition SnapshotComplete() => new( diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/NativeAlarmActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/NativeAlarmActor.cs index 7a543c1b..4577ce2c 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/NativeAlarmActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/NativeAlarmActor.cs @@ -210,7 +210,12 @@ public class NativeAlarmActor : ReceiveActor var t = new NativeAlarmTransition( 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); + null, row.LastTransitionAt, meta.CurrentValue, meta.LimitValue, + // MES alarm-status API §6.4: restore the persisted ack instant so an + // acknowledged condition keeps its AckTime across a restart/failover + // instead of reappearing as if it had never been acknowledged. Null on + // pre-AckTime metadata rows (absent JSON property deserializes to null). + AckTime: meta.AckTime); _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 @@ -417,6 +422,12 @@ public class NativeAlarmActor : ReceiveActor CurrentValue = t.CurrentValue, LimitValue = t.LimitValue, NativeSourceCanonicalName = _source.CanonicalName, + // MES alarm-status API §6.4: carried verbatim from the transition — the DCL + // adapter already decided whether an ack instant applies (null while unacked, + // cleared on re-raise), so the mirror never invents or suppresses one. A + // synthesised return-to-normal keeps the last known ack time so the final + // event still reports how the condition ended. + AckTime = t.AckTime, }; _instanceActor.Tell(change); @@ -496,7 +507,7 @@ public class NativeAlarmActor : ReceiveActor t.SourceReference, JsonSerializer.Serialize(t.Condition), (string?)JsonSerializer.Serialize(new NativeAlarmMetadata( - t.AlarmTypeName, t.Category, t.Message, t.CurrentValue, t.LimitValue)), + t.AlarmTypeName, t.Category, t.Message, t.CurrentValue, t.LimitValue, t.AckTime)), t.TransitionTime)) .ToList(); _dirtyUpserts.Clear(); @@ -546,7 +557,20 @@ public class NativeAlarmActor : ReceiveActor /// 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. + /// + /// + /// Why AckTime rides here rather than in a new column (MES alarm-status API §6.4). + /// This JSON blob is the established extension point for per-condition fields that must + /// survive a restart — it is exactly what UA4 added metadata_json for. Adding a + /// physical column to native_alarm_state instead would mean altering a table that + /// is RegisterReplicated in SiteLocalDbSetup, whose CDC triggers are built + /// from the column list at registration time; an additive JSON property changes no + /// schema, no triggers and no replication contract. Absent on rows written before this + /// change — System.Text.Json leaves the missing property at null, which is + /// the correct "ack time unknown" value. + /// /// private sealed record NativeAlarmMetadata( - string AlarmTypeName, string Category, string Message, string CurrentValue, string LimitValue); + string AlarmTypeName, string Category, string Message, string CurrentValue, string LimitValue, + DateTimeOffset? AckTime = null); } diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/AlarmStateChangedEnrichmentTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/AlarmStateChangedEnrichmentTests.cs index bb9ccf0c..56987d01 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/AlarmStateChangedEnrichmentTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/AlarmStateChangedEnrichmentTests.cs @@ -25,4 +25,34 @@ public class AlarmStateChangedEnrichmentTests Assert.True(c.Acknowledged); Assert.Equal(250, c.Severity); } + + // ── MES alarm-status API §6.4: additive AckTime ── + + [Fact] + public void AckTime_DefaultsToNull_OnThePositionalConstructor() + { + // Additive-only evolution: every existing positional construction stays valid and + // reports no ack time. A computed alarm is auto-acked but has no operator ack + // event, so null (not the timestamp) is the honest value. + var m = new AlarmStateChanged("inst", "HiAlarm", AlarmState.Active, 700, DateTimeOffset.UnixEpoch); + + Assert.True(m.Condition.Acknowledged); // computed = auto-acked… + Assert.Null(m.AckTime); // …yet still no ack instant + } + + [Fact] + public void AckTime_RoundTripsThroughTheInitProperty() + { + var ackedAt = new DateTimeOffset(2026, 8, 1, 12, 0, 0, TimeSpan.Zero); + + var m = new AlarmStateChanged("inst", "Tank01.Level.HiHi", AlarmState.Active, 900, DateTimeOffset.UnixEpoch) + { + Kind = AlarmKind.NativeOpcUa, + AckTime = ackedAt + }; + + Assert.Equal(ackedAt, m.AckTime); + // `with` (used by the mirror to synthesise a return-to-normal) preserves it. + Assert.Equal(ackedAt, (m with { State = AlarmState.Normal }).AckTime); + } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/CompatibilityTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/CompatibilityTests.cs index 1fc796d5..b9508d20 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/CompatibilityTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/CompatibilityTests.cs @@ -247,6 +247,44 @@ public class CompatibilityTests Assert.Equal("HighTemp", deserialized.AlarmName); } + [Fact] + public void RoundTrip_AlarmStateChanged_PreservesAckTime() + { + // MES alarm-status API §6.4: the additive AckTime must survive the wire, or the + // central live view would show every mirrored alarm as never acknowledged. + var ackedAt = new DateTimeOffset(2026, 8, 1, 12, 0, 0, TimeSpan.Zero); + var msg = new AlarmStateChanged("inst-1", "Tank01.Level.HiHi", AlarmState.Active, 900, DateTimeOffset.UtcNow) + { + AckTime = ackedAt + }; + + var deserialized = JsonSerializer.Deserialize( + JsonSerializer.Serialize(msg), Options); + + Assert.Equal(ackedAt, deserialized!.AckTime); + } + + [Fact] + public void BackwardCompat_AlarmStateChanged_MissingAckTime_DefaultsToNull() + { + // A frame minted by a node predating the AckTime enrichment omits the property + // entirely; it must deserialize to "no ack time", not fail. + var json = """ + { + "InstanceUniqueName": "inst-1", + "AlarmName": "Tank01.Level.HiHi", + "State": 1, + "Priority": 900, + "Timestamp": "2026-08-01T00:00:00+00:00" + } + """; + + var deserialized = JsonSerializer.Deserialize(json, Options); + + Assert.NotNull(deserialized); + Assert.Null(deserialized!.AckTime); + } + [Fact] public void RoundTrip_HeartbeatMessage_Succeeds() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/NativeAlarmMessagesTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/NativeAlarmMessagesTests.cs index a6100c56..cca82270 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/NativeAlarmMessagesTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/NativeAlarmMessagesTests.cs @@ -24,4 +24,22 @@ public class NativeAlarmMessagesTests Assert.Equal("PlantOpcUa", u.ConnectionName); Assert.Equal("Tank01", u.Transition.SourceObjectReference); } + + [Fact] + public void NativeAlarmTransition_AckTime_IsAnAdditiveTrailingParameter() + { + // MES alarm-status API §6.4. The 14-argument positional form (every pre-existing + // call site) must still compile and report no ack time; the 15th argument is the + // only way to set one. + var withoutAck = new NativeAlarmTransition("Tank01.Hi", "Tank01", "x", AlarmTransitionKind.Raise, + new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 500), + "", "", "", "", "", null, DateTimeOffset.UnixEpoch, "", ""); + Assert.Null(withoutAck.AckTime); + + var ackedAt = DateTimeOffset.UnixEpoch.AddMinutes(5); + var withAck = new NativeAlarmTransition("Tank01.Hi", "Tank01", "x", AlarmTransitionKind.Acknowledge, + new AlarmConditionState(true, true, null, AlarmShelveState.Unshelved, false, 500), + "", "", "", "", "", null, DateTimeOffset.UnixEpoch, "", "", ackedAt); + Assert.Equal(ackedAt, withAck.AckTime); + } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/StreamRelayActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/StreamRelayActorTests.cs index 637af4d4..97fbcc36 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/StreamRelayActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/StreamRelayActorTests.cs @@ -159,6 +159,69 @@ public class StreamRelayActorTests : TestKit Assert.False(roundTripped.IsConfiguredPlaceholder); } + [Fact] + public void RelaysAlarmStateChanged_AckTime_SurvivesFullRoundTrip() + { + // MES alarm-status API §6.4: AlarmStateUpdate field 24. An acknowledged mirrored + // condition must reach central with its ack instant intact, and an unacknowledged + // one must arrive as null — an absent proto Timestamp, not the epoch. + var channel = Channel.CreateUnbounded(); + var actor = Sys.ActorOf(Props.Create(() => + new StreamRelayActor("corr-acktime", channel.Writer))); + + var ackedAt = new DateTimeOffset(2026, 8, 1, 12, 0, 0, TimeSpan.Zero); + var acked = new AlarmStateChanged( + "Site1.Motor01", "Motor1.MotorAlarms.Hi", AlarmState.Active, 900, + new DateTimeOffset(2026, 8, 1, 12, 5, 0, TimeSpan.Zero)) + { + Kind = AlarmKind.NativeOpcUa, + SourceReference = "Motor1.MotorAlarms.Hi", + NativeSourceCanonicalName = "Motor1.MotorAlarms", + AckTime = ackedAt, + Condition = new AlarmConditionState( + Active: true, Acknowledged: true, Confirmed: null, + Shelve: AlarmShelveState.Unshelved, Suppressed: false, Severity: 900) + }; + + actor.Tell(acked); + var ackedProto = ReadProtoEvent(channel); + Assert.Equal(Timestamp.FromDateTimeOffset(ackedAt), ackedProto.AlarmChanged.AckTime); + Assert.Equal( + ackedAt, + Assert.IsType( + SiteStreamGrpcClient.ConvertToDomainEvent(ackedProto)).AckTime); + + actor.Tell(acked with + { + AckTime = null, + Condition = new AlarmConditionState( + Active: true, Acknowledged: false, Confirmed: null, + Shelve: AlarmShelveState.Unshelved, Suppressed: false, Severity: 900) + }); + var unackedProto = ReadProtoEvent(channel); + Assert.Null(unackedProto.AlarmChanged.AckTime); + Assert.Null( + Assert.IsType( + SiteStreamGrpcClient.ConvertToDomainEvent(unackedProto)).AckTime); + } + + /// + /// Reads the next relayed proto event, retrying once after a short pause because the + /// relay actor writes to the channel asynchronously (mirrors the existing round-trip + /// tests' read pattern). + /// + private static SiteStreamEvent ReadProtoEvent(Channel channel) + { + if (!channel.Reader.TryRead(out var protoEvent)) + { + Thread.Sleep(500); + Assert.True(channel.Reader.TryRead(out protoEvent), "Expected a proto event on the channel"); + } + + Assert.NotNull(protoEvent); + return protoEvent!; + } + [Fact] public void DropsAlarmStateChanged_WhenIsConfiguredPlaceholder() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientAlarmFilterTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientAlarmFilterTests.cs index db747f45..efe80704 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientAlarmFilterTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientAlarmFilterTests.cs @@ -76,15 +76,29 @@ public class RealOpcUaClientAlarmFilterTests // ── SelectClause index alignment (M2.13 / #27) ─────────────────────────── // CRITICAL: HandleAlarmEvent reads fields[N] by position. Verify new clauses - // are APPENDED at indices 13–17 so existing mappings (0–12) are undisturbed. + // are APPENDED at indices 13–18 so existing mappings (0–12) are undisturbed. [Fact] - public void BuildAlarmEventFilter_HasExactly18SelectClauses() + public void BuildAlarmEventFilter_HasExactly19SelectClauses() { - // Baseline: 6 base fields + 7 A&C sub-state fields + 5 new appended fields = 18. + // Baseline: 6 base fields + 7 A&C sub-state fields + 5 appended fields (13–17) + // + AckedState/TransitionTime at 18 (MES alarm-status API §6.4) = 19. // If this count changes, review HandleAlarmEvent index mappings immediately. var filter = RealOpcUaClient.BuildAlarmEventFilter(AlarmConditionFilter.AllowAll); - Assert.Equal(18, filter.SelectClauses.Count); + Assert.Equal(19, filter.SelectClauses.Count); + } + + [Fact] + public void BuildAlarmEventFilter_Index18_IsAcknowledgeableConditionType_AckedState_TransitionTime() + { + // MES alarm-status API §6.4: index 18 must be AckedState/TransitionTime → AckTime. + // APPENDED after the limit fields, so indices 0–17 keep their existing meaning. + var filter = RealOpcUaClient.BuildAlarmEventFilter(AlarmConditionFilter.AllowAll); + var clause = filter.SelectClauses[18]; + Assert.Equal(ObjectTypeIds.AcknowledgeableConditionType, clause.TypeDefinitionId); + Assert.Equal(2, clause.BrowsePath.Count); + Assert.Equal("AckedState", clause.BrowsePath[0].Name); + Assert.Equal("TransitionTime", clause.BrowsePath[1].Name); } [Fact] diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/MxGatewayAlarmMapperTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/MxGatewayAlarmMapperTests.cs index 438ffbdc..dc7ab88a 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/MxGatewayAlarmMapperTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/MxGatewayAlarmMapperTests.cs @@ -1,3 +1,4 @@ +using Google.Protobuf.WellKnownTypes; using ZB.MOM.WW.MxGateway.Client; using ZB.MOM.WW.MxGateway.Contracts.Proto; using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters; @@ -199,4 +200,122 @@ public class MxGatewayAlarmMapperTests Assert.Equal("FAULT", t.CurrentValue); Assert.Equal("", t.LimitValue); // not set } + + // ── MES alarm-status API §6.4: AckTime stamping ── + + private static readonly DateTimeOffset GatewayTime = + new(2026, 8, 1, 9, 30, 0, TimeSpan.Zero); + + [Fact] + public void MapTransition_Acknowledge_StampsTheObservedTransitionTimeAsAckTime() + { + // The gateway feed carries NO dedicated ack timestamp, so the ack transition's own + // time is what gets stamped — accurate to when the system saw the ack. + var ev = new OnAlarmTransitionEvent + { + AlarmFullReference = "Tank01.Level.HiHi", + SourceObjectReference = "Tank01", + AlarmTypeName = "AnalogLimitAlarm.HiHi", + TransitionKind = ProtoTransitionKind.Acknowledge, + Severity = 600, + TransitionTimestamp = Timestamp.FromDateTimeOffset(GatewayTime) + }; + + var t = MxGatewayAlarmMapper.MapTransition(ev); + + Assert.Equal(GatewayTime, t.AckTime); + Assert.Equal(t.TransitionTime, t.AckTime); + } + + [Fact] + public void MapTransition_Raise_HasNoAckTime() + { + // Null while unacknowledged. + var ev = new OnAlarmTransitionEvent + { + AlarmFullReference = "Tank01.Level.HiHi", + SourceObjectReference = "Tank01", + TransitionKind = ProtoTransitionKind.Raise, + Severity = 600, + TransitionTimestamp = Timestamp.FromDateTimeOffset(GatewayTime) + }; + + Assert.Null(MxGatewayAlarmMapper.MapTransition(ev).AckTime); + } + + [Fact] + public void MapTransition_Retrigger_ClearsTheAckTime() + { + // "Cleared on re-raise": a Retrigger arrives unacknowledged, so no ack time is + // reported even though the condition was acked a moment earlier. + var ev = new OnAlarmTransitionEvent + { + AlarmFullReference = "Tank01.Level.HiHi", + SourceObjectReference = "Tank01", + TransitionKind = ProtoTransitionKind.Retrigger, + Severity = 600, + TransitionTimestamp = Timestamp.FromDateTimeOffset(GatewayTime) + }; + + Assert.Null(MxGatewayAlarmMapper.MapTransition(ev).AckTime); + } + + [Fact] + public void MapTransition_Clear_HasNoAckTime_DespiteInactiveMappingToAcked() + { + // The gateway maps INACTIVE to acked = true. Without the active check every + // return-to-normal would claim an ack the system never observed. + var ev = new OnAlarmTransitionEvent + { + AlarmFullReference = "Tank01.Level.HiHi", + SourceObjectReference = "Tank01", + TransitionKind = ProtoTransitionKind.Clear, + Severity = 600, + TransitionTimestamp = Timestamp.FromDateTimeOffset(GatewayTime) + }; + + var t = MxGatewayAlarmMapper.MapTransition(ev); + + Assert.True(t.Condition.Acknowledged); // the gateway's own mapping + Assert.Null(t.AckTime); // …but no ack was observed + } + + [Fact] + public void MapSnapshot_ActiveAcked_RestoresAnAckTimeFromTheLastTransition() + { + // A (re)subscribe snapshot must not silently drop the ack instant of an already + // acknowledged alarm — the last transition time is the best the feed supplies. + var snap = new ActiveAlarmSnapshot + { + AlarmFullReference = "Tank01.Level.HiHi", + SourceObjectReference = "Tank01", + CurrentState = ProtoConditionState.ActiveAcked, + Severity = 600, + LastTransitionTimestamp = Timestamp.FromDateTimeOffset(GatewayTime) + }; + + Assert.Equal(GatewayTime, MxGatewayAlarmMapper.MapSnapshot(snap).AckTime); + } + + [Fact] + public void MapSnapshot_ActiveUnacked_HasNoAckTime() + { + var snap = new ActiveAlarmSnapshot + { + AlarmFullReference = "Tank01.Level.HiHi", + SourceObjectReference = "Tank01", + CurrentState = ProtoConditionState.Active, + Severity = 600, + LastTransitionTimestamp = Timestamp.FromDateTimeOffset(GatewayTime) + }; + + Assert.Null(MxGatewayAlarmMapper.MapSnapshot(snap).AckTime); + } + + [Fact] + public void SnapshotComplete_Sentinel_CarriesNoAckTime() + { + // The end-of-snapshot sentinel has no condition payload at all. + Assert.Null(MxGatewayAlarmMapper.SnapshotComplete().AckTime); + } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/OpcUaAlarmMapperTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/OpcUaAlarmMapperTests.cs index 2fb4c422..b3bfb42b 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/OpcUaAlarmMapperTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/OpcUaAlarmMapperTests.cs @@ -176,4 +176,52 @@ public class OpcUaAlarmMapperTests Assert.Contains('.', result); // invariant culture: '.' not ',' Assert.Equal("1.5", result); } + + // ── MES alarm-status API §6.4: AckTime stamping ── + + private static readonly DateTimeOffset SourceAck = + new(2026, 8, 1, 10, 0, 0, TimeSpan.Zero); + private static readonly DateTimeOffset Observed = + new(2026, 8, 1, 10, 5, 0, TimeSpan.Zero); + + [Fact] + public void DeriveAckTime_ActiveAcked_PrefersTheSourcesOwnAckInstant() + { + // OPC UA A&C DOES supply a true ack time (AckedState/TransitionTime). When the + // server sends it, it must win over the coarser observation time — that is the + // whole point of selecting field 18. + Assert.Equal( + SourceAck, + OpcUaAlarmMapper.DeriveAckTime(active: true, acked: true, SourceAck, Observed)); + } + + [Fact] + public void DeriveAckTime_ActiveAcked_WithoutSourceTime_FallsBackToObservationTime() + { + // Servers may omit AckedState/TransitionTime (base ConditionType events, or a + // server that does not expose it). The fallback is the event's own time — real, + // just coarser. Never fabricated, never null-when-acked. + Assert.Equal( + Observed, + OpcUaAlarmMapper.DeriveAckTime(active: true, acked: true, sourceAckTime: null, Observed)); + } + + [Fact] + public void DeriveAckTime_Unacked_IsNull_EvenWhenSourceReportsAnAckTransition() + { + // AckedState/TransitionTime also stamps the flip BACK to unacked on a re-raise, + // so it is present-but-meaningless there. The acked check is what makes "null + // while unacked" and "cleared on re-raise" hold. + Assert.Null(OpcUaAlarmMapper.DeriveAckTime( + active: true, acked: false, sourceAckTime: SourceAck, Observed)); + } + + [Fact] + public void DeriveAckTime_Inactive_IsNull() + { + // A cleared condition is on its way out of the mirror and reports no ack time, + // matching the MxGateway mapper (where INACTIVE maps to acked = true). + Assert.Null(OpcUaAlarmMapper.DeriveAckTime( + active: false, acked: true, sourceAckTime: SourceAck, Observed)); + } } 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 c8874722..0a4b6e07 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/NativeAlarmActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/NativeAlarmActorTests.cs @@ -455,6 +455,99 @@ public class NativeAlarmActorTests : TestKit, IDisposable TestLocalDb.DeleteFiles(path); } + // ── MES alarm-status API §6.4: AckTime through the mirror ────────────── + + [Fact] + public void Emit_CarriesTheAdaptersAckTimeVerbatim() + { + // The DCL adapter already decided whether an ack instant applies; the mirror must + // neither invent one for an unacked raise nor drop the one on an ack transition. + var instance = CreateTestProbe(); + var dcl = CreateTestProbe(); + var actor = Spawn(instance.Ref, dcl.Ref); + dcl.ExpectMsg(); + + var raisedAt = DateTimeOffset.UtcNow; + actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition( + "T01.Hi", AlarmTransitionKind.Raise, + new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800), raisedAt))); + Assert.Null(instance.ExpectMsg().AckTime); + + var ackedAt = raisedAt.AddMinutes(2); + actor.Tell(new NativeAlarmTransitionUpdate("Opc", new NativeAlarmTransition( + "T01.Hi", "T01", "AnalogLimit.Hi", AlarmTransitionKind.Acknowledge, + new AlarmConditionState(true, true, null, AlarmShelveState.Unshelved, false, 800), + "Process", "hi", "hi", "op1", "ack", null, ackedAt, "92", "90", AckTime: ackedAt))); + + var acked = instance.ExpectMsg(); + Assert.Equal(ackedAt, acked.AckTime); + Assert.True(acked.Condition.Acknowledged); + } + + [Fact] + public async Task Rehydration_RestoresAckTime_SoItSurvivesFailover() + { + // A failover must not make an acknowledged alarm look freshly unacknowledged: the + // ack instant rides metadata_json (see NativeAlarmMetadata) and comes back on the + // rehydration emit of a fresh actor over the SAME site database. + var instance1 = CreateTestProbe(); + var dcl1 = CreateTestProbe(); + var actor1 = SpawnWithFlush(instance1.Ref, dcl1.Ref, TimeSpan.FromMilliseconds(100)); + dcl1.ExpectMsg(); + + var ackedAt = new DateTimeOffset(2026, 8, 1, 12, 0, 0, TimeSpan.Zero); + actor1.Tell(new NativeAlarmTransitionUpdate("Opc", new NativeAlarmTransition( + "ref-ack", "T01", "HighLevelAlarm", AlarmTransitionKind.Acknowledge, + new AlarmConditionState(true, true, null, AlarmShelveState.Unshelved, false, 800), + "Process", "desc", "Tank overflow", "op1", "ack", null, DateTimeOffset.UtcNow, "92", "90", + AckTime: ackedAt))); + instance1.ExpectMsg(m => m.AckTime == ackedAt); + + await AwaitAssertAsync(async () => + { + var rows = await _storage.GetNativeAlarmsAsync("inst", Source().CanonicalName); + Assert.Contains(rows, r => r.MetadataJson != null && r.MetadataJson.Contains("AckTime")); + }, TimeSpan.FromSeconds(3)); + + var instance2 = CreateTestProbe(); + var dcl2 = CreateTestProbe(); + SpawnWithFlush(instance2.Ref, dcl2.Ref, TimeSpan.FromMilliseconds(100)); + + var emitted = instance2.FishForMessage( + m => m.SourceReference == "ref-ack", TimeSpan.FromSeconds(5)); + Assert.Equal(ackedAt, emitted.AckTime); + } + + [Fact] + public async Task Rehydration_OfAPreAckTimeRow_LeavesAckTimeNull() + { + // Rows written before this change carry metadata_json without an AckTime property. + // System.Text.Json leaves the missing property at null — "ack time unknown" — rather + // than failing the rehydration and discarding the condition. + var legacyMetadata = JsonSerializer.Serialize(new + { + AlarmTypeName = "HighLevelAlarm", + Category = "Process", + Message = "Tank overflow", + CurrentValue = "92", + LimitValue = "90" + }); + await _storage.UpsertNativeAlarmAsync( + "inst", Source().CanonicalName, "ref-legacy", + JsonSerializer.Serialize( + new AlarmConditionState(true, true, null, AlarmShelveState.Unshelved, false, 800)), + DateTimeOffset.UtcNow, legacyMetadata); + + var instance = CreateTestProbe(); + var dcl = CreateTestProbe(); + Spawn(instance.Ref, dcl.Ref); + + var emitted = instance.FishForMessage( + m => m.SourceReference == "ref-legacy", TimeSpan.FromSeconds(5)); + Assert.Equal("HighLevelAlarm", emitted.AlarmTypeName); // metadata still restored… + Assert.Null(emitted.AckTime); // …with no ack time invented + } + [Fact] public void LostSubscribeResponse_ResendsSubscribe() {