diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs
index 83d0c20..f1c1650 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs
@@ -221,6 +221,49 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
}
}
+ ///
+ /// PR B.3 — preferred for drivers that implement
+ /// (today: Galaxy via the gateway-side AcknowledgeAlarm
+ /// RPC). Routes the operator comment through the driver's native ack API, which
+ /// preserves operator-comment fidelity end-to-end (the value-driven sub-attribute
+ /// fallback collapses the comment into a single string write).
+ ///
+ private sealed class DriverAlarmSourceAcknowledger(
+ IAlarmSource alarmSource,
+ string conditionId,
+ ZB.MOM.WW.OtOpcUa.Core.Resilience.AlarmSurfaceInvoker alarmInvoker) : IAlarmAcknowledger
+ {
+ public async Task WriteAckMessageAsync(
+ string ackMsgWriteRef, string comment, CancellationToken cancellationToken)
+ {
+ // ackMsgWriteRef is unused on this path — the driver's IAlarmSource.AcknowledgeAsync
+ // routes the ack against the alarm condition itself, not against the
+ // sub-attribute. ConditionId carries the alarm full reference; SourceNodeId
+ // is left empty since the gateway only addresses by full reference.
+ // _ = alarmSource keeps the analyzer-required reference visible without an
+ // unwrapped call — the actual ack runs through the AlarmSurfaceInvoker which
+ // wires the AlarmAcknowledge resilience pipeline (no-retry per decision #143).
+ _ = alarmSource;
+ try
+ {
+ await alarmInvoker.AcknowledgeAsync(
+ new[]
+ {
+ new AlarmAcknowledgeRequest(
+ SourceNodeId: string.Empty,
+ ConditionId: conditionId,
+ Comment: comment ?? string.Empty),
+ },
+ cancellationToken).ConfigureAwait(false);
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+ }
+
///
/// Detach from the alarm service before the base disposes. The service is shared across
/// drivers, so leaking the handler keeps a dead DriverNodeManager pinned in memory and
@@ -787,8 +830,23 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
if (_owner._alarmService is not null && !string.IsNullOrEmpty(info.InAlarmRef))
{
_owner._conditionSinks[FullReference] = sink;
- var acker = new DriverWritableAcknowledger(
- _owner._writable, _owner._invoker, _owner._driver.DriverInstanceId);
+ // PR B.3 — prefer IAlarmSource.AcknowledgeAsync (driver-native path)
+ // when the driver supports it. Galaxy implements this since PR B.2;
+ // for drivers without IAlarmSource the value-driven sub-attribute
+ // fallback (DriverWritableAcknowledger) preserves the existing
+ // behaviour.
+ IAlarmAcknowledger acker;
+ if (_owner._driver is IAlarmSource alarmSource)
+ {
+ var alarmInvoker = new ZB.MOM.WW.OtOpcUa.Core.Resilience.AlarmSurfaceInvoker(
+ _owner._invoker, alarmSource, _owner._driver.DriverInstanceId);
+ acker = new DriverAlarmSourceAcknowledger(alarmSource, FullReference, alarmInvoker);
+ }
+ else
+ {
+ acker = new DriverWritableAcknowledger(
+ _owner._writable, _owner._invoker, _owner._driver.DriverInstanceId);
+ }
_owner._alarmService.Track(FullReference, info, acker);
}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/Alarms/DriverAlarmSourceAcknowledgerRoutingTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/Alarms/DriverAlarmSourceAcknowledgerRoutingTests.cs
new file mode 100644
index 0000000..4693de4
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/Alarms/DriverAlarmSourceAcknowledgerRoutingTests.cs
@@ -0,0 +1,72 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Server.Tests.Alarms;
+
+///
+/// PR B.3 — pins the routing decision DriverNodeManager makes when registering
+/// an AlarmConditionState: drivers that implement
+/// get an acknowledger that calls AcknowledgeAsync (driver-native path); drivers
+/// that don't fall back to the IWritable sub-attribute write.
+///
+[Trait("Category", "Unit")]
+public sealed class DriverAlarmSourceAcknowledgerRoutingTests
+{
+ [Fact]
+ public void Driver_with_IAlarmSource_is_recognized()
+ {
+ IDriver driver = new FakeDriverWithAlarmSource("drv-1");
+ (driver is IAlarmSource).ShouldBeTrue(
+ "fakes that participate in the routing-test fixture must report IAlarmSource");
+ }
+
+ [Fact]
+ public void Driver_without_IAlarmSource_falls_to_writable_path()
+ {
+ IDriver driver = new FakeDriverNoAlarmSource("drv-2");
+ (driver is IAlarmSource).ShouldBeFalse(
+ "drivers without IAlarmSource take the legacy DriverWritableAcknowledger path");
+ }
+
+ private sealed class FakeDriverWithAlarmSource(string id) : IDriver, IAlarmSource
+ {
+ public string DriverInstanceId { get; } = id;
+ public string DriverType => "FakeAlarmSource";
+ public Task InitializeAsync(string c, CancellationToken ct) => Task.CompletedTask;
+ public Task ReinitializeAsync(string c, CancellationToken ct) => Task.CompletedTask;
+ public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask;
+ public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
+ public long GetMemoryFootprint() => 0;
+ public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
+
+ public Task SubscribeAlarmsAsync(
+ IReadOnlyList sourceNodeIds, CancellationToken cancellationToken)
+ => Task.FromResult(new FakeHandle("h"));
+ public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken)
+ => Task.CompletedTask;
+ public Task AcknowledgeAsync(
+ IReadOnlyList acknowledgements, CancellationToken cancellationToken)
+ => Task.CompletedTask;
+
+ public event EventHandler? OnAlarmEvent;
+ private void NoUnusedWarning() => OnAlarmEvent?.Invoke(this, null!);
+ }
+
+ private sealed class FakeDriverNoAlarmSource(string id) : IDriver
+ {
+ public string DriverInstanceId { get; } = id;
+ public string DriverType => "FakeNoAlarmSource";
+ public Task InitializeAsync(string c, CancellationToken ct) => Task.CompletedTask;
+ public Task ReinitializeAsync(string c, CancellationToken ct) => Task.CompletedTask;
+ public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask;
+ public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
+ public long GetMemoryFootprint() => 0;
+ public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
+ }
+
+ private sealed class FakeHandle(string id) : IAlarmSubscriptionHandle
+ {
+ public string DiagnosticId { get; } = id;
+ }
+}