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!.
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()
{