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