From 01bcca992cbacb18b0f7918c39fc6e06cd090e34 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 1 Aug 2026 13:12:04 -0400 Subject: [PATCH] feat(alarms): thread an additive AckTime through the native-alarm mirror MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MES alarm-status API §6.4 (docs/plans/2026-06-30-mes-alarm-status-api.md, Phase 1 task 1). MES needs a real AckDT for a triggered alarm, and the mirror carried acked-vs-unacked but never WHEN. AckTime now rides the whole path: DCL transition -> AlarmStateChanged -> gRPC AlarmStateUpdate -> site SQLite. Stamping rule, identical on both protocols: non-null ONLY while the condition is active AND acknowledged. That single predicate yields all three required behaviours -- null while unacked, cleared on re-raise (a re-raise arrives unacknowledged), and no phantom ack on a return-to-normal. The last one is load-bearing for MxGateway, which maps INACTIVE to Acknowledged = true; without the active check every clear would claim an ack the system never observed. Provenance is honest, never fabricated: - OPC UA A&C supplies a TRUE ack instant, so we now select it: AcknowledgeableConditionType/AckedState/TransitionTime at SelectClause index 18, APPENDED so the positional reads at 0-17 keep their meaning. Servers that omit the field fall back to the event's own Time. - MxAccess Gateway supplies none, so the ack transition's own timestamp is used -- accurate to when the system SAW the ack. An ACTIVE_ACKED re-subscribe snapshot restores one from LastTransitionTimestamp rather than dropping it. The decision lives in pure mappers (Opc/Mx AlarmMapper.DeriveAckTime), so it is unit-tested with no live server or gateway. Additive-only throughout: init-only property on AlarmStateChanged, trailing optional positional on NativeAlarmTransition (all 14-arg call sites untouched), proto field 24 (never reusing a number) regenerated via docker/regen-proto.sh sitestream with the csproj diff verified empty. Persistence rides native_alarm_state's existing metadata_json blob rather than a new column -- deliberately. That table is RegisterReplicated in SiteLocalDbSetup and LocalDb builds its CDC triggers from the column list at registration time, so an additive JSON property changes no schema, no triggers and no replication contract; metadata_json is exactly the extension point UA4 introduced for this. Rows written before the field deserialize it as null. Tests: 4 OPC UA + 6 MxGateway mapper cases, 3 NativeAlarmActor (emit, failover rehydrate, pre-AckTime row), 1 proto round-trip incl. the null case, 4 Commons additive/back-compat. The OPC UA SelectClause count lock-in moves 18 -> 19 with an index-18 assertion -- intended, the clause is appended, which is precisely what that guard exists to make visible. --- .../Component-DataConnectionLayer.md | 20 +- .../Messages/Streaming/AlarmStateChanged.cs | 22 +++ .../Types/Alarms/NativeAlarmTransition.cs | 11 +- .../Actors/StreamRelayActor.cs | 7 +- .../Grpc/SiteStreamGrpcClient.cs | 5 +- .../Protos/sitestream.proto | 6 + .../SiteStreamGrpc/Sitestream.cs | 175 ++++++++++++------ .../Adapters/MxGatewayAlarmMapper.cs | 126 +++++++++---- .../Adapters/OpcUaAlarmMapper.cs | 31 ++++ .../Adapters/RealOpcUaClient.cs | 24 ++- .../Actors/NativeAlarmActor.cs | 30 ++- .../AlarmStateChangedEnrichmentTests.cs | 30 +++ .../Messages/CompatibilityTests.cs | 38 ++++ .../Messages/NativeAlarmMessagesTests.cs | 18 ++ .../Grpc/StreamRelayActorTests.cs | 63 +++++++ .../RealOpcUaClientAlarmFilterTests.cs | 22 ++- .../MxGatewayAlarmMapperTests.cs | 119 ++++++++++++ .../OpcUaAlarmMapperTests.cs | 48 +++++ .../Actors/NativeAlarmActorTests.cs | 93 ++++++++++ 19 files changed, 775 insertions(+), 113 deletions(-) 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() {