feat(alarms): thread an additive AckTime through the native-alarm mirror

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.
This commit is contained in:
Joseph Doherty
2026-08-01 13:12:04 -04:00
parent 6dc5d94cb9
commit 01bcca992c
19 changed files with 775 additions and 113 deletions
@@ -66,6 +66,28 @@ public record AlarmStateChanged(
/// <summary>When the native condition originally became active, if known.</summary>
public DateTimeOffset? OriginalRaiseTime { get; init; }
/// <summary>
/// When the condition was acknowledged, or <c>null</c> while it is unacknowledged.
/// Additive native-mirror enrichment (MES alarm-status API §6.4) — the ack timestamp
/// the MES <c>AlarmInfo.AckDT</c> field reports and the <c>Alarms.CurrentAsync()</c>
/// script accessor surfaces as <c>ScriptAlarm.AckTime</c>.
///
/// <para>
/// Provenance: the DCL stamps the SOURCE's own ack instant where the protocol supplies
/// one (OPC UA A&amp;C exposes <c>AckedState/TransitionTime</c>); 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.
/// </para>
///
/// <para>
/// 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 <c>Acknowledged = false</c>.
/// </para>
/// </summary>
public DateTimeOffset? AckTime { get; init; }
/// <summary>Current source value (display-only); empty for computed alarms.</summary>
public string CurrentValue { get; init; } = string.Empty;
@@ -22,6 +22,14 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
/// <param name="TransitionTime">When this transition occurred.</param>
/// <param name="CurrentValue">Current source value (display-only).</param>
/// <param name="LimitValue">Limit/threshold value for limit alarms (display-only).</param>
/// <param name="AckTime">
/// When the condition was acknowledged, or <c>null</c> 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&amp;C <c>AckedState/TransitionTime</c>), 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.
/// </param>
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);
@@ -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
}
};
@@ -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
};
@@ -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
@@ -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 {
}
}
/// <summary>Field number for the "ack_time" field.</summary>
public const int AckTimeFieldNumber = 24;
private global::Google.Protobuf.WellKnownTypes.Timestamp ackTime_;
/// <summary>
/// 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&amp;C AckedState/TransitionTime) and the DCL's observation
/// time of the ack transition where it does not (MxAccess Gateway).
/// </summary>
[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;
}
}
}
}
@@ -69,6 +69,33 @@ public static class MxGatewayAlarmMapper
Shelve: AlarmShelveState.Unshelved, Suppressed: false, Severity: NormalizeSeverity(severity));
}
/// <summary>
/// Derives the ack timestamp mirrored onto <c>NativeAlarmTransition.AckTime</c>
/// (MES alarm-status API §6.4).
///
/// <para>
/// Unlike OPC UA A&amp;C, the MxAccess Gateway alarm feed carries NO dedicated ack
/// timestamp — an acknowledgement arrives as an <c>ACTIVE_ACKED</c> condition state on a
/// transition whose only time is <paramref name="transitionTime"/> (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.
/// </para>
///
/// <para>
/// Returns <c>null</c> unless the condition is active AND acknowledged — matching
/// <c>OpcUaAlarmMapper.DeriveAckTime</c>, so both native protocols agree on "null while
/// unacked, cleared on re-raise". This matters especially here because the gateway maps
/// INACTIVE to <c>acked = true</c>: without the active check, every return-to-normal
/// would report an ack it never observed.
/// </para>
/// </summary>
/// <param name="condition">The mirrored condition state for the transition.</param>
/// <param name="transitionTime">The transition's timestamp — the DCL's observation instant for the ack.</param>
/// <returns>The observed ack timestamp, or <c>null</c> when the condition is not an outstanding acknowledged alarm.</returns>
public static DateTimeOffset? DeriveAckTime(AlarmConditionState condition, DateTimeOffset transitionTime) =>
condition is { Active: true, Acknowledged: true } ? transitionTime : null;
/// <summary>
/// Converts an <see cref="MxValue"/> union to a display-only string using
/// <see cref="MxValueExtensions.ToClrValue"/> and invariant culture formatting,
@@ -87,28 +114,37 @@ public static class MxGatewayAlarmMapper
/// <summary>Maps a live <see cref="OnAlarmTransitionEvent"/> to a transition.</summary>
/// <param name="body">The gateway alarm transition event proto message to map.</param>
/// <returns>The protocol-neutral <see cref="NativeAlarmTransition"/>.</returns>
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!<area>.<object>.<alarm>"). 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!<area>.<object>.<alarm>"). 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));
}
/// <summary>The end-of-snapshot sentinel transition (no condition payload).</summary>
/// <returns>A <see cref="NativeAlarmTransition"/> with <c>AlarmTransitionKind.SnapshotComplete</c>.</returns>
@@ -120,22 +156,32 @@ public static class MxGatewayAlarmMapper
/// <summary>Maps one initial-snapshot <see cref="ActiveAlarmSnapshot"/> entry to a Snapshot transition.</summary>
/// <param name="snapshot">The active alarm snapshot proto message to map.</param>
/// <returns>A <see cref="NativeAlarmTransition"/> with <c>AlarmTransitionKind.Snapshot</c>.</returns>
public static NativeAlarmTransition MapSnapshot(ActiveAlarmSnapshot snapshot) => new(
// See MapTransition: identify by the object-relative reference, not the
// full "Galaxy!<area>.<object>.<alarm>" 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!<area>.<object>.<alarm>" 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));
}
}
@@ -92,6 +92,37 @@ public static class OpcUaAlarmMapper
return AlarmTransitionKind.StateChange;
}
/// <summary>
/// Derives the ack timestamp mirrored onto <c>NativeAlarmTransition.AckTime</c>
/// (MES alarm-status API §6.4).
///
/// <para>
/// OPC UA A&amp;C DOES supply a true ack instant: <c>AckedState/TransitionTime</c> 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
/// <paramref name="sourceAckTime"/> is nullable and falls back to the event's own
/// <paramref name="observedAt"/> time — the instant the DCL observed the acknowledged
/// state. Never fabricated: both values are real timestamps, the fallback is simply
/// coarser.
/// </para>
///
/// <para>
/// Returns <c>null</c> 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 <c>acked == false</c>). A cleared condition
/// (inactive) is on its way out of the mirror and reports no ack time either.
/// </para>
/// </summary>
/// <param name="active">Whether the condition is currently active.</param>
/// <param name="acked">Whether the condition is currently acknowledged.</param>
/// <param name="sourceAckTime">The source's <c>AckedState/TransitionTime</c>, or null when the server omits it.</param>
/// <param name="observedAt">The event's own transition time — the DCL's observation instant.</param>
/// <returns>The ack timestamp, or <c>null</c> when the condition is not an outstanding acknowledged alarm.</returns>
public static DateTimeOffset? DeriveAckTime(
bool active, bool acked, DateTimeOffset? sourceAckTime, DateTimeOffset observedAt) =>
active && acked ? sourceAckTime ?? observedAt : null;
/// <summary>Maps the OPC UA ShelvingState current-state node name to the shelve enum.</summary>
/// <param name="shelvingStateName">The OPC UA ShelvingState node name, or null when unshelved.</param>
/// <returns>The corresponding <see cref="AlarmShelveState"/>; defaults to <see cref="AlarmShelveState.Unshelved"/>.</returns>
@@ -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(
@@ -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
/// <c>metadata_json</c> column so a rehydrated condition renders fully (type/category/message/
/// current+limit values) before the first source snapshot re-supplies it.
///
/// <para>
/// <b>Why AckTime rides here rather than in a new column (MES alarm-status API §6.4).</b>
/// This JSON blob is the established extension point for per-condition fields that must
/// survive a restart — it is exactly what UA4 added <c>metadata_json</c> for. Adding a
/// physical column to <c>native_alarm_state</c> instead would mean altering a table that
/// is <c>RegisterReplicated</c> in <c>SiteLocalDbSetup</c>, 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 — <c>System.Text.Json</c> leaves the missing property at <c>null</c>, which is
/// the correct "ack time unknown" value.
/// </para>
/// </summary>
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);
}