server: DriverNodeManager prefers IAlarmSource ack over IWritable (PR B.3)

Thirteenth PR of the alarms-over-gateway epic
(docs/plans/alarms-over-gateway.md). Depends on PR B.2 (GalaxyDriver
implements IAlarmSource, merged).

When DriverNodeManager registers an AlarmConditionState with
AlarmConditionService, it now picks the acknowledger:

- Driver implements IAlarmSource → DriverAlarmSourceAcknowledger
  routes the operator comment through IAlarmSource.AcknowledgeAsync
  via the existing AlarmSurfaceInvoker (Phase 6.1 resilience pipeline,
  no-retry per decision #143). Preserves operator-comment fidelity
  end-to-end — the value-driven sub-attribute write collapses the
  comment into a single string write that loses MxAccess metadata.
- Driver does not implement IAlarmSource →
  DriverWritableAcknowledger fallback (existing behaviour for
  AbCip / Modbus / S7 / etc).

The dedup logic that prefers driver-native transitions over
sub-attribute synthesis lives in AlarmConditionService and is
already in place — drivers that surface OnAlarmEvent (B.2) feed
the service directly, while sub-attribute writes still flow
through DriverNodeManager's ConditionSink so a Galaxy template
without $Alarm extensions stays functional.

Tests:
- 2 new routing-decision tests in
  DriverAlarmSourceAcknowledgerRoutingTests pin the
  IAlarmSource detection used at registration time.
- Server build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-04-30 17:20:45 -04:00
parent 6126374594
commit edc984987b
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;
}
}