fix(alarms): surface a refused native alarm ack synchronously, revert one that fails later (lmxopcua#760)
The native-ack path is fire-and-forget after the Part 9 Acknowledge returns, so a Galaxy ack refused for lack of a credential (#749), or rejected by MXAccess, left the client with Good and the condition showing Acknowledged while Galaxy was never acked. Both options from the issue, layered: 1. Synchronous refusal for what the driver can decide without I/O. New optional IAlarmAcknowledgePreflight (Core.Abstractions); GalaxyDriver refuses an anonymous session when the driver has no AlarmAck fallback account. DriverHostActor publishes a condition NodeId -> owning-driver snapshot into NativeAlarmAckPreflightView (lock-free, volatile); the node manager's new NativeAlarmAckPreflight seam, consulted after the authz and Primary gates, returns BadUserAccessDenied with the reason - nothing routed, applied or reported. A keyed session is not pre-checked (its identity lives in the Secrets store, which must not be read under the SDK lock). 2. Revert for everything else. RouteAlarmAck carries the condition NodeId; DriverInstanceActor replies AlarmAckFailed on a refusal, a backend failure, a driver without an alarm source, or an ack dropped while connecting / reconnecting. DriverHostActor re-projects the condition unacknowledged via NativeAlarmProjector.TryRevertAcknowledge, but only while the local ack is still pending - any driver transition projected since then wins. A disabled or unspawned driver is not reverted (unchanged). GatewayGalaxyAlarmAcknowledger now throws DriverOperationRefusedException on a non-zero native hresult (wrong password, no ack permission) instead of only warning, so that case reverts too; as a refusal it neither retries nor trips the breaker. Tests (each fails with its fix mutated out): projector revert/supersede, DriverHostActor refused + failed ack -> unacked AlarmStateUpdate (successful ack control), preflight view snapshot, node-manager preflight unit test, over-the-wire ConditionMethodEventTests (BadUserAccessDenied, no route, no event; positive control), Galaxy preflight + hresult refusal. Docs: Galaxy.md, AlarmTracking.md, ScriptedAlarms.md, .claude/rules/alarms.md. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
@@ -12,9 +12,16 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||
/// </summary>
|
||||
public sealed class NativeAlarmProjector
|
||||
{
|
||||
private readonly Dictionary<string, (bool Active, bool Acked, string? Message, ushort Severity)> _prior =
|
||||
private readonly Dictionary<string, (bool Active, bool Acked, string? Message, ushort Severity, uint StatusCode)> _prior =
|
||||
new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Conditions a client acknowledged locally (<see cref="MarkAcknowledged"/>) whose acknowledge has not yet
|
||||
/// been confirmed or superseded by a driver transition — the only ones <see cref="TryRevertAcknowledge"/>
|
||||
/// may un-acknowledge (lmxopcua#760).
|
||||
/// </summary>
|
||||
private readonly HashSet<string> _pendingLocalAck = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Project an alarm transition onto the full condition snapshot for <paramref name="nodeId"/>.</summary>
|
||||
/// <param name="nodeId">The materialised condition node's id (the projection's state key).</param>
|
||||
/// <param name="e">The native alarm transition.</param>
|
||||
@@ -71,8 +78,11 @@ public sealed class NativeAlarmProjector
|
||||
/// <returns>The full Part 9 condition snapshot to write to the node.</returns>
|
||||
public AlarmConditionSnapshot Project(string nodeId, AlarmEventArgs e, out IReadOnlyList<AlarmTransitionKind> historyKinds)
|
||||
{
|
||||
// Any driver transition supersedes a pending local acknowledge: from here the projected state is the
|
||||
// driver's word, so a late ack failure must not rewrite it.
|
||||
_pendingLocalAck.Remove(nodeId);
|
||||
var prev = _prior.TryGetValue(nodeId, out var p)
|
||||
? p : (Active: false, Acked: true, Message: (string?)null, Severity: (ushort)0);
|
||||
? p : (Active: false, Acked: true, Message: (string?)null, Severity: (ushort)0, StatusCode: OpcUaStatus.Good);
|
||||
// A source with a finer scale than the four buckets supplies the exact Part 9 severity.
|
||||
var sev = e.OpcUaSeverity is { } exact ? (ushort)Math.Clamp(exact, 1, 1000) : MapSeverity(e.Severity);
|
||||
// Only a statement saying the SAME thing the condition already shows is a re-statement.
|
||||
@@ -143,14 +153,15 @@ public sealed class NativeAlarmProjector
|
||||
break;
|
||||
}
|
||||
|
||||
_prior[nodeId] = (active, acked, e.Message, sev);
|
||||
var statusCode = e.QualityStatusCode ?? OpcUaStatus.Good;
|
||||
_prior[nodeId] = (active, acked, e.Message, sev, statusCode);
|
||||
return new AlarmConditionSnapshot(
|
||||
Active: active, Acknowledged: acked, Confirmed: true, Enabled: true,
|
||||
Shelving: AlarmShelvingKind.Unshelved, Severity: sev, Message: e.Message,
|
||||
// A per-condition source quality (MTConnect Unavailable) when the driver reports one. Absent
|
||||
// (every earlier source) ⇒ Good, as before — driver-wide comms loss stays the host's
|
||||
// out-of-band WriteAlarmQuality annotation, not something the projector infers.
|
||||
StatusCode: e.QualityStatusCode ?? OpcUaStatus.Good);
|
||||
StatusCode: statusCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -161,11 +172,39 @@ public sealed class NativeAlarmProjector
|
||||
/// <param name="nodeId">The acknowledged condition node id.</param>
|
||||
public void MarkAcknowledged(string nodeId)
|
||||
{
|
||||
if (_prior.TryGetValue(nodeId, out var p)) _prior[nodeId] = p with { Acked = true };
|
||||
if (!_prior.TryGetValue(nodeId, out var p)) return;
|
||||
// Only an ack that CHANGED the projected state is pending; re-acking an acked condition has nothing to undo.
|
||||
if (!p.Acked) _pendingLocalAck.Add(nodeId);
|
||||
_prior[nodeId] = p with { Acked = true };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Undo a client acknowledgement the driver refused or failed to deliver upstream (lmxopcua#760), returning
|
||||
/// the unacknowledged snapshot to re-project onto the node. Only an acknowledge still pending since
|
||||
/// <see cref="MarkAcknowledged"/> is reverted: once any driver transition for the condition has been
|
||||
/// projected, that transition is authoritative and a late failure changes nothing.
|
||||
/// </summary>
|
||||
/// <param name="nodeId">The condition node id whose acknowledge failed.</param>
|
||||
/// <param name="snapshot">The unacknowledged snapshot to project; null when nothing was reverted.</param>
|
||||
/// <returns>True when the condition was reverted to unacknowledged.</returns>
|
||||
public bool TryRevertAcknowledge(string nodeId, out AlarmConditionSnapshot? snapshot)
|
||||
{
|
||||
snapshot = null;
|
||||
if (!_pendingLocalAck.Remove(nodeId) || !_prior.TryGetValue(nodeId, out var p)) return false;
|
||||
_prior[nodeId] = p with { Acked = false };
|
||||
snapshot = new AlarmConditionSnapshot(
|
||||
Active: p.Active, Acknowledged: false, Confirmed: true, Enabled: true,
|
||||
Shelving: AlarmShelvingKind.Unshelved, Severity: p.Severity, Message: p.Message ?? string.Empty,
|
||||
StatusCode: p.StatusCode);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Clears all tracked per-node state.</summary>
|
||||
public void Clear() => _prior.Clear();
|
||||
public void Clear()
|
||||
{
|
||||
_prior.Clear();
|
||||
_pendingLocalAck.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drop the tracked state of every condition NOT in <paramref name="deployedNodeIds"/>, keeping
|
||||
@@ -180,7 +219,10 @@ public sealed class NativeAlarmProjector
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(deployedNodeIds);
|
||||
foreach (var nodeId in _prior.Keys.Where(k => !deployedNodeIds.Contains(k)).ToList())
|
||||
{
|
||||
_prior.Remove(nodeId);
|
||||
_pendingLocalAck.Remove(nodeId);
|
||||
}
|
||||
}
|
||||
|
||||
private static ushort MapSeverity(AlarmSeverity s) => s switch
|
||||
|
||||
Reference in New Issue
Block a user