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);
+ }
+}