server: DriverNodeManager prefers IAlarmSource ack over IWritable (PR B.3) #414
@@ -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>
|
/// <summary>
|
||||||
/// Detach from the alarm service before the base disposes. The service is shared across
|
/// 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
|
/// 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))
|
if (_owner._alarmService is not null && !string.IsNullOrEmpty(info.InAlarmRef))
|
||||||
{
|
{
|
||||||
_owner._conditionSinks[FullReference] = sink;
|
_owner._conditionSinks[FullReference] = sink;
|
||||||
var acker = new DriverWritableAcknowledger(
|
// PR B.3 — prefer IAlarmSource.AcknowledgeAsync (driver-native path)
|
||||||
_owner._writable, _owner._invoker, _owner._driver.DriverInstanceId);
|
// 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);
|
_owner._alarmService.Track(FullReference, info, acker);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user