diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/AlarmEventArgs.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/AlarmEventArgs.cs
index b47e0426..e73e7337 100644
--- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/AlarmEventArgs.cs
+++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/AlarmEventArgs.cs
@@ -81,9 +81,13 @@ public sealed class AlarmEventArgs : EventArgs
///
/// 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).
+ /// Acknowledge transitions. Null on raise / clear events, and intentionally null
+ /// on the Galaxy OPC UA sub-attribute fallback path for two unrecoverable reasons:
+ /// (a) Galaxy's AckMsg attribute is WRITE-ONLY — no readable attribute
+ /// exposes the last operator comment — and (b) the OPC UA event SelectClause
+ /// carries no comment field. The operator comment is only available via the
+ /// gateway's native alarm feed (GalaxyAlarmTransition.OperatorComment
+ /// in the Galaxy driver path).
///
public string? OperatorComment { get; }
diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs
index 28d9870c..255ce078 100644
--- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs
+++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs
@@ -814,6 +814,15 @@ public sealed class OpcUaClientService : IOpcUaClientService
// Other supplemental read failure; deliver event with defaults
}
+ // OperatorComment is intentionally left null (default) here.
+ // Two concrete reasons make it unrecoverable in this Galaxy sub-attribute fallback path:
+ // (a) Galaxy's AckMsg attribute is WRITE-ONLY — there is no readable attribute that
+ // exposes the last operator comment, so no supplemental read can recover it.
+ // (b) The OPC UA event SelectClause (CreateAlarmEventFilter) carries no comment field;
+ // the OPC UA event notification itself never delivers a comment string.
+ // The operator comment is only available via the gateway's native alarm feed
+ // (the Galaxy driver path, GalaxyAlarmTransition.OperatorComment), not this
+ // OpcUaClient sub-attribute fallback.
AlarmEvent?.Invoke(this, new AlarmEventArgs(
sourceName, conditionName, severity, capturedMessage, retain, activeState, ackedState, time,
eventId, capturedConditionNodeId));
diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/OpcUaClientServiceTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/OpcUaClientServiceTests.cs
index 5f4d3d07..8c0fc1f8 100644
--- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/OpcUaClientServiceTests.cs
+++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/OpcUaClientServiceTests.cs
@@ -1251,6 +1251,82 @@ public class OpcUaClientServiceTests : IDisposable
received.Message.ShouldBe("Fallback message"); // from DescAttrName read
}
+ ///
+ /// Pins the accepted limitation that OperatorComment is always null on the Galaxy
+ /// sub-attribute fallback path. Two concrete reasons make it unrecoverable:
+ /// (a) Galaxy's AckMsg attribute is WRITE-ONLY — no readable attribute exposes the
+ /// last operator comment — and (b) the OPC UA event SelectClause carries no comment
+ /// field. The operator comment is only available via the gateway's native alarm feed
+ /// (GalaxyAlarmTransition.OperatorComment in the Galaxy driver path, not here).
+ ///
+ [Fact]
+ public async Task OnAlarmEvent_GalaxyFallback_LeavesOperatorCommentNull()
+ {
+ var fakeSub = new FakeSubscriptionAdapter();
+ var session = new FakeSessionAdapter
+ {
+ NextSubscription = fakeSub,
+ ReadResponseFunc = nodeId =>
+ {
+ var key = nodeId.ToString();
+ if (key.EndsWith(".InAlarm"))
+ return new DataValue(new Variant(true), StatusCodes.Good);
+ if (key.EndsWith(".Acked"))
+ return new DataValue(new Variant(true), StatusCodes.Good);
+ if (key.EndsWith(".TimeAlarmOn"))
+ return new DataValue(new Variant(new DateTime(2026, 1, 1, 12, 0, 0)), StatusCodes.Good);
+ if (key.EndsWith(".DescAttrName"))
+ return new DataValue(new Variant("Ack message"), StatusCodes.Good);
+ return new DataValue(StatusCodes.BadNodeIdUnknown);
+ }
+ };
+ _sessionFactory.EnqueueSession(session);
+ await _service.ConnectAsync(ValidSettings());
+
+ AlarmEventArgs? received = null;
+ var raised = new TaskCompletionSource();
+ _service.AlarmEvent += (_, e) =>
+ {
+ received = e;
+ raised.TrySetResult();
+ };
+
+ await _service.SubscribeAlarmsAsync();
+
+ var handle = fakeSub.ActiveHandles.First();
+ // AckedState/Id (8) and ActiveState/Id (9) are null, triggering the Galaxy
+ // sub-attribute fallback; SourceNode (12) is set so conditionNodeId is populated.
+ var fields = new EventFieldList
+ {
+ EventFields =
+ [
+ new Variant(new byte[] { 4, 5, 6 }), // 0: EventId
+ new Variant(ObjectTypeIds.AlarmConditionType), // 1: EventType
+ new Variant("Source2"), // 2: SourceName
+ new Variant(DateTime.MinValue), // 3: Time
+ new Variant(new LocalizedText("Ack")), // 4: Message
+ new Variant((ushort)200), // 5: Severity
+ new Variant("CondName2"), // 6: ConditionName
+ new Variant(true), // 7: Retain
+ Variant.Null, // 8: AckedState/Id — missing
+ Variant.Null, // 9: ActiveState/Id — missing
+ new Variant(true), // 10: EnabledState/Id
+ new Variant(false), // 11: SuppressedOrShelved
+ new Variant("ns=2;s=ConditionId2") // 12: SourceNode
+ ]
+ };
+ fakeSub.SimulateEvent(handle, fields);
+
+ // The fallback runs on a background Task.Run continuation — wait briefly for it.
+ await Task.WhenAny(raised.Task, Task.Delay(500));
+
+ received.ShouldNotBeNull();
+ // OperatorComment must be null: AckMsg is WRITE-ONLY in Galaxy and the OPC UA
+ // event SelectClause carries no comment field — both reasons are unrecoverable
+ // on this client sub-attribute fallback path.
+ received!.OperatorComment.ShouldBeNull();
+ }
+
// --- Failover tests ---
///