Merge pull request 'server: DriverNodeManager prefers IAlarmSource ack over IWritable (PR B.3)' (#414) from track-b3-prefer-driver-native-alarm into master

This commit was merged in pull request #414.
This commit is contained in:
2026-04-30 17:23:09 -04:00
2 changed files with 132 additions and 2 deletions

View File

@@ -221,6 +221,49 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
}
}
/// <summary>
/// PR B.3 — preferred <see cref="IAlarmAcknowledger"/> for drivers that implement
/// <see cref="IAlarmSource"/> (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).
/// </summary>
private sealed class DriverAlarmSourceAcknowledger(
IAlarmSource alarmSource,
string conditionId,
ZB.MOM.WW.OtOpcUa.Core.Resilience.AlarmSurfaceInvoker alarmInvoker) : IAlarmAcknowledger
{
public async Task<bool> 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;
}
}
}
/// <summary>
/// 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);
}

View File

@@ -0,0 +1,72 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Server.Tests.Alarms;
/// <summary>
/// PR B.3 — pins the routing decision DriverNodeManager makes when registering
/// an AlarmConditionState: drivers that implement <see cref="IAlarmSource"/>
/// get an acknowledger that calls AcknowledgeAsync (driver-native path); drivers
/// that don't fall back to the IWritable sub-attribute write.
/// </summary>
[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<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
=> Task.FromResult<IAlarmSubscriptionHandle>(new FakeHandle("h"));
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken)
=> Task.CompletedTask;
public Task AcknowledgeAsync(
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken)
=> Task.CompletedTask;
public event EventHandler<AlarmEventArgs>? 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;
}
}