From 1d627090605b5a9888e9c6d41ebca5845f954967 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 30 Apr 2026 17:46:47 -0400 Subject: [PATCH] abstractions+driver+client.shared: extend AlarmEventArgs with rich payload (PR E.7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fourteenth PR of the alarms-over-gateway epic (docs/plans/alarms-over-gateway.md). Depends on PR B.2 (GalaxyDriver implements IAlarmSource, merged) and B.3 (DriverNodeManager prefers driver-native ack, merged). Three new optional fields on Core.Abstractions.AlarmEventArgs: - OperatorComment — populated by the driver-native gateway path on Acknowledge transitions. Null on raise / clear, and null on the sub-attribute fallback path where the comment collapses into a single string write. - OriginalRaiseTimestampUtc — preserved across Acknowledge so OPC UA Part 9 conditions keep the original raise time. - AlarmCategory — taxonomy bucket from the upstream alarm system. Maps to ConditionClassName downstream when a class mapping is configured. GalaxyDriver.OnPumpAlarmTransition populates the new fields from GalaxyAlarmTransition (PR B.1). Empty strings collapse to null so consumers can use is-null rather than is-null-or-empty checks. Client.Shared mirror DTO (Client.Shared/Models/AlarmEventArgs) gains the same three properties so the Client.UI / Client.CLI surfaces can reflect the rich payload — the actual UI/CLI verbose-output and Show-Details rendering ship as a follow-up PR; this PR locks in the payload contract. Tests: - 2 new tests in Driver.Galaxy.Tests pin the populated-vs-null behaviour for full-payload Acknowledge and bare-bones Raise transitions respectively. - Solution build clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Models/AlarmEventArgs.cs | 29 +++- .../IAlarmSource.cs | 29 +++- .../GalaxyDriver.cs | 5 +- ...alaxyDriverAlarmEventArgsExtensionTests.cs | 138 ++++++++++++++++++ 4 files changed, 198 insertions(+), 3 deletions(-) create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverAlarmEventArgsExtensionTests.cs diff --git a/src/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/AlarmEventArgs.cs b/src/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/AlarmEventArgs.cs index c068c07..069fa89 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/AlarmEventArgs.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/AlarmEventArgs.cs @@ -15,7 +15,10 @@ public sealed class AlarmEventArgs : EventArgs bool ackedState, DateTime time, byte[]? eventId = null, - string? conditionNodeId = null) + string? conditionNodeId = null, + string? operatorComment = null, + DateTime? originalRaiseTimestampUtc = null, + string? alarmCategory = null) { SourceName = sourceName; ConditionName = conditionName; @@ -27,6 +30,9 @@ public sealed class AlarmEventArgs : EventArgs Time = time; EventId = eventId; ConditionNodeId = conditionNodeId; + OperatorComment = operatorComment; + OriginalRaiseTimestampUtc = originalRaiseTimestampUtc; + AlarmCategory = alarmCategory; } /// The name of the source object that raised the alarm. @@ -58,4 +64,25 @@ public sealed class AlarmEventArgs : EventArgs /// The NodeId of the condition instance (SourceNode), used for acknowledgment. public string? ConditionNodeId { get; } + + /// + /// PR E.7 — Operator-supplied comment recorded by the upstream alarm system on + /// Acknowledge transitions. Null on raise / clear, or when the upstream path + /// can't surface the comment (sub-attribute fallback path collapses comments + /// into a single string write). + /// + public string? OperatorComment { get; } + + /// + /// PR E.7 — When the alarm originally entered the active state. Preserved + /// across Acknowledge transitions so OPC UA Part 9 conditions keep the + /// original raise time. Null when the upstream path doesn't surface it. + /// + public DateTime? OriginalRaiseTimestampUtc { get; } + + /// + /// PR E.7 — Upstream alarm taxonomy bucket (e.g. Process / + /// Safety / Diagnostics). Null when not surfaced. + /// + public string? AlarmCategory { get; } } \ No newline at end of file diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs b/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs index 2282d0f..4f261c5 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs @@ -41,6 +41,30 @@ public sealed record AlarmAcknowledgeRequest( string? Comment); /// Event payload for . +/// Subscription this event belongs to. +/// Driver-side identifier for the alarm source. +/// Stable id correlating raise / ack / clear of the same condition. +/// Driver-defined alarm type name (e.g. AnalogLimitAlarm.HiHi). +/// Human-readable alarm description. +/// Four-bucket severity ladder. +/// When this transition occurred. +/// +/// Operator-supplied comment recorded by the upstream alarm system on Acknowledge +/// transitions. Null on raise / clear, or when the upstream path can't surface +/// the comment (the Galaxy sub-attribute fallback path collapses comments into a +/// single string write — null on that path; the driver-native gateway path +/// populates this). +/// +/// +/// When the alarm originally entered the active state. Preserved across +/// Acknowledge transitions so OPC UA Part 9 conditions keep the original raise +/// time in Time. Null when the upstream path doesn't surface it. +/// +/// +/// Upstream alarm taxonomy bucket (e.g. Process / Safety / +/// Diagnostics). Maps to OPC UA ConditionClassName downstream when +/// a class mapping is configured. Null when the upstream path doesn't carry it. +/// public sealed record AlarmEventArgs( IAlarmSubscriptionHandle SubscriptionHandle, string SourceNodeId, @@ -48,7 +72,10 @@ public sealed record AlarmEventArgs( string AlarmType, string Message, AlarmSeverity Severity, - DateTime SourceTimestampUtc); + DateTime SourceTimestampUtc, + string? OperatorComment = null, + DateTime? OriginalRaiseTimestampUtc = null, + string? AlarmCategory = null); /// Mirrors the NodePermissions alarm-severity enum in docs/v2/acl-design.md. public enum AlarmSeverity { Low, Medium, High, Critical } diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs index 672fedc..e06d976 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs @@ -837,7 +837,10 @@ public sealed class GalaxyDriver AlarmType: transition.AlarmTypeName, Message: transition.Description, Severity: transition.SeverityBucket, - SourceTimestampUtc: transition.TransitionTimestampUtc); + SourceTimestampUtc: transition.TransitionTimestampUtc, + OperatorComment: string.IsNullOrEmpty(transition.OperatorComment) ? null : transition.OperatorComment, + OriginalRaiseTimestampUtc: transition.OriginalRaiseTimestampUtc, + AlarmCategory: string.IsNullOrEmpty(transition.Category) ? null : transition.Category); try { OnAlarmEvent?.Invoke(this, args); diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverAlarmEventArgsExtensionTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverAlarmEventArgsExtensionTests.cs new file mode 100644 index 0000000..ce451c3 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverAlarmEventArgsExtensionTests.cs @@ -0,0 +1,138 @@ +using System.Threading.Channels; +using Google.Protobuf.WellKnownTypes; +using MxGateway.Contracts.Proto; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests; + +/// +/// PR E.7 — pins that the GalaxyDriver populates the extended AlarmEventArgs +/// fields (OperatorComment, OriginalRaiseTimestampUtc, AlarmCategory) when the +/// gateway emits a transition with the rich payload, and leaves them null on +/// events that don't carry them. +/// +public sealed class GalaxyDriverAlarmEventArgsExtensionTests +{ + [Fact] + public async Task Acknowledge_transition_with_full_payload_populates_extended_fields() + { + var subscriber = new ManualSubscriber(); + using var driver = NewDriver(subscriber); + + await driver.SubscribeAlarmsAsync(["Tank01"], CancellationToken.None); + var observed = new List(); + driver.OnAlarmEvent += (_, args) => observed.Add(args); + await driver.SubscribeAsync(["Tank01.Level"], TimeSpan.Zero, CancellationToken.None); + + var raise = new DateTime(2026, 5, 1, 12, 0, 0, DateTimeKind.Utc); + var ack = raise.AddSeconds(45); + await subscriber.EmitAlarmAsync(new MxEvent + { + Family = MxEventFamily.OnAlarmTransition, + OnAlarmTransition = new OnAlarmTransitionEvent + { + AlarmFullReference = "Tank01.Level.HiHi", + SourceObjectReference = "Tank01", + AlarmTypeName = "AnalogLimitAlarm.HiHi", + TransitionKind = AlarmTransitionKind.Acknowledge, + Severity = 750, + OriginalRaiseTimestamp = Timestamp.FromDateTime(raise), + TransitionTimestamp = Timestamp.FromDateTime(ack), + OperatorUser = "alice", + OperatorComment = "investigating", + Category = "Process", + Description = "Tank 01 high-high level", + }, + }); + + for (var i = 0; i < 20 && observed.Count == 0; i++) + { + await Task.Delay(50); + } + observed.ShouldHaveSingleItem(); + observed[0].OperatorComment.ShouldBe("investigating"); + observed[0].OriginalRaiseTimestampUtc.ShouldBe(raise); + observed[0].AlarmCategory.ShouldBe("Process"); + } + + [Fact] + public async Task Raise_transition_without_optional_fields_leaves_them_null() + { + var subscriber = new ManualSubscriber(); + using var driver = NewDriver(subscriber); + + await driver.SubscribeAlarmsAsync(["Tank01"], CancellationToken.None); + var observed = new List(); + driver.OnAlarmEvent += (_, args) => observed.Add(args); + await driver.SubscribeAsync(["Tank01.Level"], TimeSpan.Zero, CancellationToken.None); + + await subscriber.EmitAlarmAsync(new MxEvent + { + Family = MxEventFamily.OnAlarmTransition, + OnAlarmTransition = new OnAlarmTransitionEvent + { + AlarmFullReference = "Tank01.Level.HiHi", + AlarmTypeName = "AnalogLimitAlarm.HiHi", + TransitionKind = AlarmTransitionKind.Raise, + Severity = 750, + TransitionTimestamp = Timestamp.FromDateTime(DateTime.UtcNow), + }, + }); + + for (var i = 0; i < 20 && observed.Count == 0; i++) + { + await Task.Delay(50); + } + observed.ShouldHaveSingleItem(); + observed[0].OperatorComment.ShouldBeNull(); + observed[0].OriginalRaiseTimestampUtc.ShouldBeNull(); + observed[0].AlarmCategory.ShouldBeNull(); + } + + private static GalaxyDriver NewDriver(ManualSubscriber subscriber) + { + var options = new GalaxyDriverOptions( + new GalaxyGatewayOptions("http://localhost:5000", "literal-api-key"), + new GalaxyMxAccessOptions("AlarmExtensionTest"), + new GalaxyRepositoryOptions(), + new GalaxyReconnectOptions()); + return new GalaxyDriver( + driverInstanceId: "drv-1", + options: options, + hierarchySource: null, + dataReader: null, + dataWriter: null, + subscriber: subscriber, + alarmAcknowledger: null); + } + + private sealed class ManualSubscriber : IGalaxySubscriber + { + private readonly Channel _stream = + Channel.CreateUnbounded(new UnboundedChannelOptions { SingleReader = true }); + + public Task> SubscribeBulkAsync( + IReadOnlyList fullReferences, int bufferedUpdateIntervalMs, CancellationToken cancellationToken) + { + var results = new List(); + var nextHandle = 100; + foreach (var r in fullReferences) + { + results.Add(new SubscribeResult { TagAddress = r, ItemHandle = nextHandle++, WasSuccessful = true }); + } + return Task.FromResult>(results); + } + + public Task UnsubscribeBulkAsync(IReadOnlyList itemHandles, CancellationToken cancellationToken) + => Task.CompletedTask; + + public IAsyncEnumerable StreamEventsAsync(CancellationToken cancellationToken) + => _stream.Reader.ReadAllAsync(cancellationToken); + + public ValueTask EmitAlarmAsync(MxEvent ev) => _stream.Writer.WriteAsync(ev); + } +}