fix(alarms): native conditions support Acknowledge/AddComment only; report only real method changes (#587 review)

Review of 167a48bf (FIX-FIRST): making native Confirmed/Shelving method-owned produced over-the-wire
regressions — a native TimedShelve never expired, a native OneShotShelve was silently undone by
SetActiveState, an OPC UA ack left a native alarm Unconfirmed forever (ScadaBridge maps ConfirmedState),
and any local state was per-node, never reaching the redundant peer.

- Reverted: the driver projection owns native Confirmed/Shelving again.
- Native Confirm / OneShotShelve / TimedShelve / Unshelve return BadNotSupported with no event, as native
  Enable/Disable already did. Scripted alarms keep every method.
- A native Acknowledge re-asserts ConfirmedState=true and the projection's Retain rule (Active || !Acked)
  before its single report. The native condition starts Confirmed.
- The method path reports only when the state actually moved (before/after). A re-ack is silent and
  restores the Confirmed/Retain that the SDK flips, so a scripted node never drifts from its engine.
  AddComment always reports.
- Docs: native alarms support Acknowledge/AddComment only; the Secondary-node native-ack flap is documented.

Tests: refused native methods (5), native ack stays Confirmed through echo/clear/reactivate, scripted
TimedShelve expires with exactly one unshelve event, re-ack silent (native + scripted). Each fix falsified.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 01:19:38 -04:00
parent 167a48bffa
commit e2663643dd
5 changed files with 334 additions and 93 deletions
@@ -829,11 +829,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// Gitea #587) NEVER passed through this method, so a last-written cache would be stale and
// wrongly report a delta. By the time the engine re-projects that ack here, the node already
// holds the acked state → no delta → suppress.
// Gitea #587: a NATIVE condition's Confirmed and Shelving belong to the Part 9 methods, not
// to this projection — see WireMethodTransitionReporting.
var methodOwned = _nativeAlarmNodeIds.Contains(key);
var current = ReadConditionDelta(condition);
var incoming = ToConditionDelta(state, condition, methodOwned);
var incoming = ToConditionDelta(state, condition);
bool fire = ShouldFireConditionEvent(current, incoming);
// EnabledState / AckedState / ActiveState are mandatory children — always present after
@@ -845,11 +842,11 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
condition.SetEnableState(SystemContext, state.Enabled);
condition.SetActiveState(SystemContext, state.Active);
condition.SetAcknowledgedState(SystemContext, state.Acknowledged);
if (condition.ConfirmedState is not null && !methodOwned)
if (condition.ConfirmedState is not null)
{
condition.SetConfirmedState(SystemContext, state.Confirmed);
}
if (condition.ShelvingState is not null && !methodOwned)
if (condition.ShelvingState is not null)
{
// SetShelvingState(shelved, oneShot, shelvingTime): map our 3-way kind onto the SDK's
// (shelved, oneShot) flag pair. Timed shelving's expiry is owned by the engine, not the
@@ -1117,24 +1114,16 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// the bucket the node holds (the projection calls <c>SetSeverity(MapSeverity(...))</c>), and an
/// optional Confirmed/Shelving that the node can't actually hold (missing child) is folded to the
/// node's read-back default so it never spuriously registers as a delta.</summary>
/// <param name="methodOwned">True for a native condition, whose Confirmed and Shelving the projection does
/// not apply (see <see cref="WireMethodTransitionReporting"/>); they are folded to the node's current value so the
/// snapshot's constants can never register as a delta.</param>
private static AlarmConditionDelta ToConditionDelta(
AlarmConditionSnapshot state, AlarmConditionState condition, bool methodOwned = false) => new(
private static AlarmConditionDelta ToConditionDelta(AlarmConditionSnapshot state, AlarmConditionState condition) => new(
Active: state.Active,
Acknowledged: state.Acknowledged,
// If the node has no ConfirmedState child, the projection is a no-op there; mirror the node's
// read-back default (true) so a snapshot Confirmed value can't create a phantom delta.
Confirmed: condition.ConfirmedState is null ? true
: methodOwned ? condition.ConfirmedState.Id?.Value ?? true
: state.Confirmed,
Confirmed: condition.ConfirmedState is not null ? state.Confirmed : true,
Enabled: state.Enabled,
// Likewise for shelving: without a ShelvingState child the projection can't apply, so fold to the
// node's read-back default (Unshelved).
Shelving: condition.ShelvingState is null ? AlarmShelvingKind.Unshelved
: methodOwned ? ReadShelvingKind(condition)
: state.Shelving,
Shelving: condition.ShelvingState is not null ? state.Shelving : AlarmShelvingKind.Unshelved,
MappedSeverity: (ushort)MapSeverity(state.Severity),
Message: state.Message ?? string.Empty,
// If the node has no Quality child, WriteAlarmCondition's projection is a no-op there; fold to the
@@ -1294,8 +1283,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
alarm.SetEnableState(SystemContext, true);
alarm.SetActiveState(SystemContext, false);
alarm.SetAcknowledgedState(SystemContext, true);
// A native condition's Confirmed is method-owned (Gitea #587 — see WireMethodTransitionReporting), so
// no projection will ever set it: start it confirmed, the value the driver projection used to supply.
// A native condition is never Unconfirmed (Gitea #587 review): it has no confirm workflow — Confirm is
// refused and Acknowledge re-asserts Confirmed — so start it Confirmed, the value NativeAlarmProjector
// supplies on every transition, rather than the SDK's default false until the first projection.
if (isNative && alarm.ConfirmedState is not null) alarm.SetConfirmedState(SystemContext, true);
alarm.SetSeverity(SystemContext, MapSeverity(severity));
// Gitea #587 (found by its TimedShelve test): Create() leaves MaxTimeShelved at 0, and the SDK's
@@ -1320,18 +1310,26 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// delta-gate, which finds the node already in that state and suppresses a second event.
// H6c — a NATIVE (driver-fed) condition's Acknowledge belongs to the backing driver, NOT the
// scripted engine, so branch on native-ness (via the lock-guarded accessor) and route a native
// ack to NativeAlarmAckRouter instead of the scripted AlarmCommandRouter. Confirm/AddComment/
// Shelve stay on the scripted path even for native conditions (H6c scope is Acknowledge only).
// ack to NativeAlarmAckRouter instead of the scripted AlarmCommandRouter. AddComment stays on the
// scripted path even for native conditions (H6c scope is Acknowledge only).
// Gitea #587 review: a NATIVE condition supports Acknowledge and AddComment only. Confirm and the
// shelve methods return BadNotSupported (like Enable/Disable below) — there is no upstream path for
// them, no pair replication of a local state, and the driver projection (Confirmed, Unshelved) would
// silently undo one at the next transition.
alarm.OnAcknowledge = (context, condition, _, comment) =>
IsNativeAlarmNode(alarmNodeId, realm)
? HandleNativeAlarmAck(context, condition, comment)
: HandleAlarmCommand(context, condition, "Acknowledge", comment, unshelveAt: null);
alarm.OnConfirm = (context, condition, _, comment) =>
HandleAlarmCommand(context, condition, "Confirm", comment, unshelveAt: null);
IsNativeAlarmNode(alarmNodeId, realm)
? new ServiceResult(StatusCodes.BadNotSupported)
: HandleAlarmCommand(context, condition, "Confirm", comment, unshelveAt: null);
alarm.OnAddComment = (context, condition, _, comment) =>
HandleAlarmCommand(context, condition, "AddComment", comment, unshelveAt: null);
alarm.OnShelve = (context, condition, shelving, oneShot, shelvingTime) =>
{
if (IsNativeAlarmNode(alarmNodeId, realm)) return new ServiceResult(StatusCodes.BadNotSupported);
// SDK invocation shapes (verified against the decompiled AlarmConditionState):
// OneShotShelve → (shelving:true, oneShot:true, 0.0) ⇒ OneShotShelve, no expiry
// TimedShelve → (shelving:true, oneShot:false, ms) ⇒ TimedShelve, expiry = UtcNow + ms
@@ -1558,28 +1556,38 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// events. Holding it makes the re-projection wait until the node holds the new state.
/// </para>
/// <para>
/// <b>Native conditions: Confirmed and Shelving are the methods'.</b> A native condition's state is
/// re-projected from each driver transition by <c>NativeAlarmProjector</c>, which models neither
/// confirmation nor shelving — it always says <c>Confirmed = true</c>, <c>Unshelved</c>. The SDK's
/// Acknowledge sets <c>Confirmed = false</c> (the condition supports Confirm), so the driver's own
/// echo of that ack would otherwise flip it back and report a second event for one ack, and a
/// local shelve would be undone (and reported) by the next driver transition. So for a native
/// condition <see cref="WriteAlarmCondition"/> neither projects nor compares those two fields:
/// they change only through these methods, each reported once here. A scripted condition's engine
/// models both, so its projection still owns them.
/// <b>Native conditions support Acknowledge and AddComment only</b> (Confirm and the shelve methods
/// are refused with <c>BadNotSupported</c> before they get here). Their Confirmed and Shelving stay
/// owned by the driver projection (<c>NativeAlarmProjector</c>: always Confirmed, Unshelved). The SDK's
/// Acknowledge sets <c>Confirmed = false</c> because the condition type supports Confirm, which would
/// leave every native alarm Unconfirmed after its first OPC UA ack — ScadaBridge maps
/// <c>ConfirmedState</c> straight into its alarm state — so a native ack re-asserts Confirmed and the
/// projection's Retain rule (<c>Active || !Acked</c>) before the one report.
/// </para>
/// <para>
/// A rejected call (bad <see cref="ServiceResult"/>, e.g. acknowledging an acknowledged condition, a
/// denied gate, or Enable/Disable on a native condition) reports nothing: there was no transition.
/// <b>Only a real change is reported.</b> The condition's gate-relevant state is read before and after
/// the SDK handler, and nothing is reported when it did not move. The SDK accepts acknowledging an
/// acknowledged condition and sets <c>Confirmed = false</c> again; a re-ack therefore also restores the
/// pre-call Confirmed and Retain, so a scripted node never drifts from its engine (which ignores a
/// re-ack) and the call is silent. AddComment always reports: adding a comment is itself the change.
/// </para>
/// <para>
/// A rejected call (bad <see cref="ServiceResult"/>, e.g. a denied gate, or a refused method on a native
/// condition) reports nothing: there was no transition.
/// </para>
/// </remarks>
private void WireMethodTransitionReporting(AlarmConditionState alarm, RealmKey conditionKey)
{
alarm.AutoReportStateChanges = false; // the SDK's own report stays off; ours replaces it (see remarks)
WrapComment(alarm.Acknowledge);
WrapComment(alarm.Confirm);
WrapComment(alarm.AddComment);
if (alarm.Acknowledge?.OnCall is { } acknowledge)
{
alarm.Acknowledge.OnCall = (context, m, objectId, eventId, comment) =>
ReportAfterMethod(() => acknowledge(context, m, objectId, eventId, comment), apply: null, isAcknowledge: true);
}
WrapComment(alarm.Confirm, alwaysReport: false);
WrapComment(alarm.AddComment, alwaysReport: true);
WrapGeneric(alarm.Enable, apply: null);
WrapGeneric(alarm.Disable, apply: null);
@@ -1589,17 +1597,19 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
WrapGeneric(shelving.Unshelve, () => alarm.SetShelvingState(SystemContext, shelved: false, oneShot: false, shelvingTime: 0));
if (shelving.TimedShelve is { OnCall: { } timed } timedShelve)
{
// shelvingTime 0, as WriteAlarmCondition projects it: the scripted engine owns the expiry and
// re-projects Unshelved when it passes (the SDK's own timer only routes an Unshelve back to it).
timedShelve.OnCall = (context, method, objectId, shelvingTime) => ReportAfterMethod(
() => timed(context, method, objectId, shelvingTime),
() => alarm.SetShelvingState(SystemContext, shelved: true, oneShot: false, shelvingTime: 0));
}
}
void WrapComment(AddCommentMethodState? method)
void WrapComment(AddCommentMethodState? method, bool alwaysReport)
{
if (method?.OnCall is not { } sdk) return;
method.OnCall = (context, m, objectId, eventId, comment) =>
ReportAfterMethod(() => sdk(context, m, objectId, eventId, comment), apply: null);
ReportAfterMethod(() => sdk(context, m, objectId, eventId, comment), apply: null, alwaysReport: alwaysReport);
}
void WrapGeneric(MethodState? method, Action? apply)
@@ -1609,20 +1619,44 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
ReportAfterMethod(() => sdk(context, m, inputs, outputs), apply);
}
ServiceResult ReportAfterMethod(Func<ServiceResult> sdkHandler, Action? apply)
ServiceResult ReportAfterMethod(
Func<ServiceResult> sdkHandler, Action? apply, bool isAcknowledge = false, bool alwaysReport = false)
{
lock (Lock)
{
var before = ReadConditionDelta(alarm);
var retainBefore = alarm.Retain.Value;
var result = sdkHandler();
// A rebuild may have discarded this instance mid-call; its replacement is not what was called.
if (ServiceResult.IsGood(result)
&& _alarmConditions.TryGetValue(conditionKey, out var live)
&& ReferenceEquals(live, alarm))
if (!ServiceResult.IsGood(result)
|| !_alarmConditions.TryGetValue(conditionKey, out var live)
|| !ReferenceEquals(live, alarm))
{
apply?.Invoke();
ReportConditionEvent(alarm, DateTime.UtcNow);
return result;
}
apply?.Invoke();
if (isAcknowledge && alarm.ConfirmedState is not null)
{
if (_nativeAlarmNodeIds.Contains(conditionKey))
{
// Native: no confirm workflow — keep what the driver projection says (see remarks).
alarm.SetConfirmedState(SystemContext, true);
alarm.Retain.Value = (alarm.ActiveState?.Id?.Value ?? false) || !(alarm.AckedState?.Id?.Value ?? true);
}
else if (before.Acknowledged)
{
// Re-ack: the SDK flipped Confirmed though nothing was acknowledged; put it back.
alarm.SetConfirmedState(SystemContext, before.Confirmed);
alarm.Retain.Value = retainBefore;
}
}
if (alwaysReport || ReadConditionDelta(alarm) != before)
ReportConditionEvent(alarm, DateTime.UtcNow);
return result;
}
}