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:
@@ -1107,11 +1107,14 @@ condition event — the old "SDK auto-fires its own event (E2)" comments were wr
|
||||
wraps every method's SDK handler (Ack/Confirm/AddComment/Enable/Disable/OneShotShelve/TimedShelve/Unshelve) and,
|
||||
on Good, reports once via `ReportConditionEvent` — **under `Lock`**, so the engine's/driver's re-projection
|
||||
(routed by a fire-and-forget Tell *before* the SDK applies state) waits and then hits the delta-gate with no
|
||||
delta. Things to keep: the SDK's shelve handlers never apply shelving, so the wrapper does; a **native**
|
||||
condition's Confirmed and Shelving are method-owned (`WriteAlarmCondition` neither projects nor compares them —
|
||||
`NativeAlarmProjector`'s constants would otherwise re-report every ack and undo every shelve); `MaxTimeShelved`
|
||||
is set to `int.MaxValue` ms at materialise because `Create()` left it 0 and every TimedShelve failed. Pinned
|
||||
over the wire by `ConditionMethodEventTests` (exactly-once after a sentinel, folder + `i=2253`).
|
||||
delta. Things to keep: the SDK's shelve handlers never apply shelving, so the wrapper does; only a real
|
||||
before/after change is reported (a re-ack is silent and restores the Confirmed/Retain the SDK flips);
|
||||
`MaxTimeShelved` is set to `int.MaxValue` ms at materialise because `Create()` left it 0 and every TimedShelve
|
||||
failed. ⚠️ **Native alarms support Acknowledge and AddComment only** — Confirm, the shelve methods and
|
||||
Enable/Disable return `BadNotSupported` (no upstream path, no pair replication, and `NativeAlarmProjector`'s
|
||||
Confirmed/Unshelved constants own those fields); a native ack re-asserts `ConfirmedState = true` because the
|
||||
SDK's ack unconfirms any confirm-capable condition and ScadaBridge maps that field. Pinned over the wire by
|
||||
`ConditionMethodEventTests` (exactly-once after a sentinel, folder + `i=2253`).
|
||||
|
||||
## Historian / HistoryRead
|
||||
|
||||
|
||||
+40
-16
@@ -314,9 +314,30 @@ from the scripted `AlarmCommandRouter`:
|
||||
OPC UA client (mirrors the Galaxy write-outcome limitation; the local
|
||||
`AlarmConditionState` SDK update already committed at step 1).
|
||||
|
||||
Only the **Acknowledge** is routed to the driver. `Confirm` / `AddComment`
|
||||
/ `Shelve` operations on a native condition stay on the scripted
|
||||
`AlarmCommandRouter` path (Phase 3 scope is Acknowledge → AVEVA only).
|
||||
Only the **Acknowledge** is routed to the driver. **A native condition
|
||||
supports Acknowledge and AddComment only** (Gitea #587 review): `Confirm`,
|
||||
`OneShotShelve` / `TimedShelve` / `Unshelve` and `Enable` / `Disable` return
|
||||
`BadNotSupported` and report no event. There is no upstream path for them and
|
||||
no pair replication of a local state, and the driver projection
|
||||
(`NativeAlarmProjector`: always `Confirmed`, `Unshelved`) would silently undo
|
||||
one at the next transition. `AddComment` stays on the scripted
|
||||
`AlarmCommandRouter` path (Phase 3 scope is Acknowledge → AVEVA only) and is
|
||||
applied locally.
|
||||
|
||||
A native condition is **never Unconfirmed**: it starts Confirmed, and because
|
||||
the SDK's own Acknowledge sets `ConfirmedState = false` on any condition type
|
||||
that supports Confirm, the node manager re-asserts `Confirmed = true` (and the
|
||||
projection's Retain rule, `Active || !Acked`) after the SDK handler, before the
|
||||
one ack event. ScadaBridge maps `ConfirmedState` into its alarm state, so this
|
||||
is client-visible.
|
||||
|
||||
> **Secondary-node ack flap.** On a node that is not the redundancy Primary,
|
||||
> an OPC UA Acknowledge is applied to the local condition and reported (one
|
||||
> event, Acked), but `DriverHostActor`'s Primary gate drops the route upstream.
|
||||
> The driver never acknowledges, so its next transition for that alarm is
|
||||
> projected with `Acked = false` and the node reverts — with its own event. A
|
||||
> client acking against the Secondary sees acked → unacked. Ack against the
|
||||
> Primary (the higher ServiceLevel) for the ack to stick.
|
||||
|
||||
> **Best-effort during reconnect.** If a native ack arrives while the owning
|
||||
> driver is `Connecting`/`Reconnecting` (or on a non-Primary node), it is
|
||||
@@ -399,19 +420,22 @@ reports nothing.
|
||||
and suppresses. The whole method call runs under the node-manager lock, so
|
||||
a fast re-projection cannot slip in between the SDK applying the state and
|
||||
the report.
|
||||
- **Native conditions: Confirmed and Shelving are method-owned.**
|
||||
`NativeAlarmProjector` models neither (it always says `Confirmed = true`,
|
||||
`Unshelved`), while the SDK's Acknowledge sets `Confirmed = false`. So for a
|
||||
native condition `WriteAlarmCondition` neither projects nor compares those
|
||||
two fields — otherwise the driver's echo of an ack would report a second
|
||||
event, and the next driver transition would undo (and report) a local
|
||||
shelve. A native condition starts Confirmed; Confirm and the shelve methods
|
||||
are still not routed to the driver (local only, as before), but they now
|
||||
stick. Scripted conditions are unchanged: the engine models both fields.
|
||||
- **TimedShelve works.** `Create()` left `MaxTimeShelved` at 0, and the SDK
|
||||
rejects any shelving time above it, so every client TimedShelve failed with
|
||||
`BadShelvingTimeOutOfRange`. It is now `int.MaxValue` ms (≈24.8 days — the
|
||||
longest the SDK's own shelving timer can hold).
|
||||
- **Only a real change is reported.** The condition's state is compared
|
||||
before and after the SDK handler; a call that changed nothing reports
|
||||
nothing. The SDK accepts acknowledging an acknowledged condition and sets
|
||||
`Confirmed = false` again, so a re-ack also restores the pre-call Confirmed
|
||||
and Retain — silent, and a scripted node never drifts from its engine.
|
||||
AddComment always reports (the comment is the change).
|
||||
- **Native conditions: Acknowledge and AddComment only** — see §"Native alarm
|
||||
acknowledge → AVEVA" above. The other methods are refused
|
||||
(`BadNotSupported`, no event), and a native ack keeps the condition
|
||||
Confirmed.
|
||||
- **TimedShelve works (scripted).** `Create()` left `MaxTimeShelved` at 0, and
|
||||
the SDK rejects any shelving time above it, so every client TimedShelve
|
||||
failed with `BadShelvingTimeOutOfRange`. It is now `int.MaxValue` ms (≈24.8
|
||||
days). The node applies the shelve with no SDK timer of its own; the engine
|
||||
owns the expiry and its Unshelved re-projection reports the one expiry
|
||||
event.
|
||||
- Method **audit** events (`AuditConditionAcknowledgeEventType`, …) are still
|
||||
the SDK's, delivered only while the server is auditing over an encrypted
|
||||
channel.
|
||||
|
||||
@@ -352,13 +352,13 @@ On allow, the handler publishes a `Commons.OpcUa.AlarmCommand` (containing comma
|
||||
|
||||
**Scripted vs native conditions — Enable/Disable and Acknowledge:**
|
||||
- **Scripted conditions** — all eight Part 9 operations (including Enable/Disable) route through `AlarmCommandRouter` onto the `alarm-commands` topic, which `ScriptedAlarmHostActor` dispatches to the engine (`EnableAsync` / `DisableAsync` / `AcknowledgeAsync` / …). On enable, `ActiveState` is re-derived from the next predicate evaluation.
|
||||
- **Native (driver-fed) conditions** — `OnAcknowledge` branches to `NativeAlarmAckRouter` and routes the ack to the owning driver rather than the scripted engine (see §[Native-alarm OPC UA operator operations](#native-alarm-opc-ua-operator-operations) above). `OnEnableDisable` returns `BadNotSupported` immediately — native conditions have no engine-side enable/disable surface.
|
||||
- **Native (driver-fed) conditions** — `OnAcknowledge` branches to `NativeAlarmAckRouter` and routes the ack to the owning driver rather than the scripted engine (see §[Native-alarm OPC UA operator operations](#native-alarm-opc-ua-operator-operations) above). `OnEnableDisable`, `OnConfirm` and `OnShelve` return `BadNotSupported` immediately — a native condition supports Acknowledge and AddComment only (no engine-side surface, no upstream path, and the driver projection owns its Confirmed/Shelving; Gitea #587 review).
|
||||
|
||||
`OnTimedUnshelve` (the SDK's internal auto-unshelve timer) bypasses the client gate — it is system-initiated and not subject to operator role checks.
|
||||
|
||||
### Delta-gate de-duplication
|
||||
|
||||
`WriteAlarmCondition` fires a Part 9 condition event only when the incoming state differs from the node's current live state. For an OPC UA method call (Acknowledge, Confirm, AddComment, the shelve methods, Enable/Disable) the SDK applies the new state to the node, and the node manager then reports **the one** condition event for it from the method path (Gitea #587 — the SDK reports none of its own, since `AutoReportStateChanges` stays false; the shelve methods' state is applied by the node manager, because the SDK's never applies it). The engine's later re-projection of the same transition finds the node already in that state and is suppressed, so each accepted call yields exactly one event. See `docs/AlarmTracking.md` §"One condition event per method call".
|
||||
`WriteAlarmCondition` fires a Part 9 condition event only when the incoming state differs from the node's current live state. For an OPC UA method call (Acknowledge, Confirm, AddComment, the shelve methods, Enable/Disable) the SDK applies the new state to the node, and the node manager then reports **the one** condition event for it from the method path (Gitea #587 — the SDK reports none of its own, since `AutoReportStateChanges` stays false; the shelve methods' state is applied by the node manager, because the SDK's never applies it; a call that changed nothing — e.g. a re-ack — reports nothing). Native (driver-fed) conditions support only Acknowledge and AddComment; their Confirm, shelve and Enable/Disable methods return `BadNotSupported`. The engine's later re-projection of the same transition finds the node already in that state and is suppressed, so each accepted call yields exactly one event. See `docs/AlarmTracking.md` §"One condition event per method call".
|
||||
|
||||
### AdminUI path
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+206
-26
@@ -50,16 +50,13 @@ public sealed class ConditionMethodEventTests
|
||||
[InlineData("Unshelve", false, true)]
|
||||
[InlineData("Disable", false, true)]
|
||||
[InlineData("Enable", false, true)]
|
||||
// Native conditions support Acknowledge and AddComment only (the rest: A_refused_native_method_…).
|
||||
[InlineData("Acknowledge", true, true)]
|
||||
[InlineData("Confirm", true, true)]
|
||||
[InlineData("AddComment", true, true)]
|
||||
[InlineData("OneShotShelve", true, true)]
|
||||
[InlineData("TimedShelve", true, true)]
|
||||
[InlineData("Unshelve", true, true)]
|
||||
// Flag off: the server-wide AlarmAck role admits the call; delivery is ungated.
|
||||
[InlineData("Acknowledge", false, false)]
|
||||
[InlineData("Acknowledge", true, false)]
|
||||
[InlineData("OneShotShelve", true, false)]
|
||||
[InlineData("OneShotShelve", false, false)]
|
||||
public async Task An_accepted_method_call_reports_exactly_one_condition_event(string method, bool native, bool aclOn)
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
@@ -97,15 +94,6 @@ public sealed class ConditionMethodEventTests
|
||||
rig.Project(native, current);
|
||||
}
|
||||
|
||||
// A native condition's Confirmed and Shelving are reached only through the methods.
|
||||
ProjectAs("setup");
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(folderItem, "setup"));
|
||||
if (native && method == "Confirm")
|
||||
await CallAsync(session, condition, "Acknowledge", events.LatestEventId(folderItem, "setup"), ct);
|
||||
if (native && method == "Unshelve")
|
||||
await CallAsync(session, condition, "OneShotShelve", events.LatestEventId(folderItem, "setup"), ct);
|
||||
await rig.DrainProjectionsAsync();
|
||||
|
||||
ProjectAs("base");
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(folderItem, "base") && events.Has(serverItem, "base"));
|
||||
|
||||
@@ -121,7 +109,7 @@ public sealed class ConditionMethodEventTests
|
||||
{
|
||||
var baseEvents = events.ConditionEvents(item, "base");
|
||||
baseEvents.Count.ShouldBe(2, $"the base transition plus exactly one {method} event ({item.StartNodeId})");
|
||||
AssertNewState(method, baseEvents[1]);
|
||||
AssertNewState(method, native, baseEvents[1]);
|
||||
}
|
||||
|
||||
if (aclOn)
|
||||
@@ -141,19 +129,29 @@ public sealed class ConditionMethodEventTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A refused call reports nothing: Disable on a native condition is <c>BadNotSupported</c>, so no
|
||||
/// transition happened and no condition event may claim one.
|
||||
/// A native condition supports Acknowledge and AddComment only: Confirm, the shelve methods and
|
||||
/// Disable are refused, report nothing, and leave the condition Confirmed and Unshelved.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_refused_method_call_reports_no_condition_event()
|
||||
/// <remarks>
|
||||
/// Native Confirm/Shelve used to be accepted and applied locally — per node, never upstream, never to the
|
||||
/// redundant peer, and undone by the driver projection (a one-shot shelve silently, a timed one never
|
||||
/// expiring). Unshelve on an unshelved condition is refused by the SDK itself before our veto runs.
|
||||
/// </remarks>
|
||||
[Theory]
|
||||
[InlineData("Confirm")]
|
||||
[InlineData("OneShotShelve")]
|
||||
[InlineData("TimedShelve")]
|
||||
[InlineData("Unshelve")]
|
||||
[InlineData("Disable")]
|
||||
public async Task A_refused_native_method_reports_no_condition_event(string method)
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var rig = await StartAsync("CondMethodRefused", encrypted: false, ct);
|
||||
var rig = await StartAsync($"CondMethodRefused{method}", encrypted: false, ct);
|
||||
try
|
||||
{
|
||||
rig.Enforce(true, [Key()], ClusterGrant(KeyPrincipal, "acl-key", All));
|
||||
rig.MaterialiseNative();
|
||||
var current = PreState("Disable", native: true);
|
||||
var current = PreState(method, native: true);
|
||||
|
||||
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
||||
var events = new RecordedEvents();
|
||||
@@ -166,13 +164,16 @@ public sealed class ConditionMethodEventTests
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(serverItem, "base"));
|
||||
|
||||
var refused = await Should.ThrowAsync<ServiceResultException>(
|
||||
CallAsync(session, ConditionNodeId(session, native: true), "Disable", null, ct));
|
||||
refused.StatusCode.ShouldBe(StatusCodes.BadNotSupported);
|
||||
CallAsync(session, ConditionNodeId(session, native: true), method, events.LatestEventId(serverItem, "base"), ct));
|
||||
if (method != "Unshelve") refused.StatusCode.ShouldBe(StatusCodes.BadNotSupported);
|
||||
|
||||
rig.Project(native: true, current with { Message = "sentinel" });
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(folderItem, "sentinel") && events.Has(serverItem, "sentinel"));
|
||||
events.ConditionEvents(folderItem, "base").Count.ShouldBe(1);
|
||||
events.ConditionEvents(serverItem, "base").Count.ShouldBe(1);
|
||||
var sentinel = events.ConditionEvents(serverItem, "sentinel")[0];
|
||||
sentinel[RecordedEvents.ConfirmedField].Value.ShouldBe(true);
|
||||
sentinel[RecordedEvents.ShelvingField].Value.ShouldBe(ObjectIds.ShelvedStateMachineType_Unshelved);
|
||||
|
||||
await subscription.DeleteAsync(true, ct);
|
||||
}
|
||||
@@ -214,8 +215,184 @@ public sealed class ConditionMethodEventTests
|
||||
finally { await rig.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An OPC UA Acknowledge on a native condition reports exactly one event with <c>ConfirmedState</c> still
|
||||
/// true, and the condition stays Confirmed through the driver's echo, a clear and a re-activation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The SDK's Acknowledge sets Confirmed false on any condition that supports Confirm, and nothing resets
|
||||
/// it for a native alarm — ScadaBridge maps <c>ConfirmedState</c> into its alarm state, so every native
|
||||
/// alarm would read Unconfirmed after its first HMI ack (review of <c>167a48bf</c>).
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task A_native_acknowledge_keeps_the_condition_confirmed_through_clear_and_reactivation()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var rig = await StartAsync("CondNativeAckConfirmed", encrypted: false, ct);
|
||||
try
|
||||
{
|
||||
rig.Enforce(true, [Key()], ClusterGrant(KeyPrincipal, "acl-key", All));
|
||||
rig.MaterialiseNative();
|
||||
rig.Nm.NativeAlarmAckRouter = _ => { };
|
||||
|
||||
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
||||
var events = new RecordedEvents();
|
||||
var subscription = await RecordedEvents.SubscribeAsync(session, events, ct);
|
||||
var item = RecordedEvents.AddEventItem(subscription, ObjectIds.Server);
|
||||
await subscription.ApplyChangesAsync(ct);
|
||||
|
||||
rig.Project(true, NativeState(active: true, acked: false, "base"));
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "base"));
|
||||
|
||||
await CallAsync(session, ConditionNodeId(session, true), "Acknowledge", events.LatestEventId(item, "base"), ct);
|
||||
rig.Project(true, NativeState(active: true, acked: true, "base")); // the driver's echo of the ack
|
||||
rig.Project(true, NativeState(active: false, acked: true, "cleared"));
|
||||
rig.Project(true, NativeState(active: true, acked: false, "reactivated"));
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "reactivated"));
|
||||
|
||||
var baseEvents = events.ConditionEvents(item, "base");
|
||||
baseEvents.Count.ShouldBe(2, "the base transition plus the one ack event; the echo is no delta");
|
||||
baseEvents[1][RecordedEvents.AckedField].Value.ShouldBe(true);
|
||||
foreach (var e in baseEvents.Concat(events.ConditionEvents(item, "cleared")).Concat(events.ConditionEvents(item, "reactivated")))
|
||||
e[RecordedEvents.ConfirmedField].Value.ShouldBe(true);
|
||||
|
||||
await subscription.DeleteAsync(true, ct);
|
||||
}
|
||||
finally { await rig.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A scripted TimedShelve expires: the engine's expiry comes back as an Unshelved re-projection, which
|
||||
/// reports exactly one unshelve event after the one shelve event.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The method path applies the shelve with <c>shelvingTime 0</c>, as the projection does, so the expiry is
|
||||
/// the engine's (computed from the command's <c>UnshelveAtUtc</c>), never the SDK timer's — a node timer
|
||||
/// would only route an Unshelve back to the engine anyway. The stand-in engine here schedules its expiry
|
||||
/// from the real <c>UnshelveAtUtc</c> the method handler computed.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task A_scripted_timed_shelve_expires_with_exactly_one_unshelve_event()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var rig = await StartAsync("CondTimedExpiry", encrypted: false, ct);
|
||||
try
|
||||
{
|
||||
rig.Enforce(true, [Key()], ClusterGrant(KeyPrincipal, "acl-key", All));
|
||||
rig.MaterialiseScripted();
|
||||
var current = PreState("TimedShelve", native: false) with { Message = "base" };
|
||||
Task? expiry = null;
|
||||
rig.Nm.AlarmCommandRouter = cmd =>
|
||||
{
|
||||
if (cmd.Operation != "TimedShelve") return;
|
||||
current = current with { Shelving = AlarmShelvingKind.Timed };
|
||||
rig.ProjectLater(native: false, current);
|
||||
var unshelveAt = cmd.UnshelveAtUtc!.Value;
|
||||
expiry = Task.Run(async () =>
|
||||
{
|
||||
var wait = unshelveAt - DateTime.UtcNow;
|
||||
if (wait > TimeSpan.Zero) await Task.Delay(wait);
|
||||
current = current with { Shelving = AlarmShelvingKind.Unshelved };
|
||||
rig.Project(native: false, current);
|
||||
});
|
||||
};
|
||||
|
||||
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
||||
var events = new RecordedEvents();
|
||||
var subscription = await RecordedEvents.SubscribeAsync(session, events, ct);
|
||||
var item = RecordedEvents.AddEventItem(subscription, ObjectIds.Server);
|
||||
await subscription.ApplyChangesAsync(ct);
|
||||
|
||||
rig.Project(false, current);
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "base"));
|
||||
|
||||
await CallAsync(session, ConditionNodeId(session, false), "TimedShelve", null, ct, shelvingTimeMs: 1000);
|
||||
await rig.DrainProjectionsAsync();
|
||||
(expiry is not null).ShouldBeTrue("the TimedShelve reached the engine");
|
||||
await expiry!;
|
||||
|
||||
rig.Project(false, current = current with { Message = "sentinel" });
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "sentinel"));
|
||||
|
||||
var shelving = events.ConditionEvents(item, "base").Select(e => e[RecordedEvents.ShelvingField].Value).ToList();
|
||||
shelving.ShouldBe(
|
||||
[ObjectIds.ShelvedStateMachineType_Unshelved, ObjectIds.ShelvedStateMachineType_TimedShelved,
|
||||
ObjectIds.ShelvedStateMachineType_Unshelved],
|
||||
"base, then exactly one shelve event, then exactly one expiry event");
|
||||
|
||||
await subscription.DeleteAsync(true, ct);
|
||||
}
|
||||
finally { await rig.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Acknowledging an acknowledged condition reports nothing, and does not flip a scripted condition's
|
||||
/// Confirmed out of step with its engine.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The SDK accepts a re-ack and sets Confirmed false again; the engine ignores it. The scripted leg
|
||||
/// acknowledges and confirms first, so the flip would be visible on the sentinel.
|
||||
/// </remarks>
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task A_re_acknowledge_reports_nothing_and_changes_nothing(bool native)
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var rig = await StartAsync($"CondReAck{native}", encrypted: false, ct);
|
||||
try
|
||||
{
|
||||
rig.Enforce(true, [Key()], ClusterGrant(KeyPrincipal, "acl-key", All));
|
||||
rig.MaterialiseNative();
|
||||
rig.MaterialiseScripted();
|
||||
var current = PreState("Acknowledge", native) with { Message = "base" };
|
||||
WireSimulatedOwners(rig, native, () => current, s => current = s);
|
||||
|
||||
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
||||
var events = new RecordedEvents();
|
||||
var subscription = await RecordedEvents.SubscribeAsync(session, events, ct);
|
||||
var item = RecordedEvents.AddEventItem(subscription, ObjectIds.Server);
|
||||
await subscription.ApplyChangesAsync(ct);
|
||||
|
||||
var condition = ConditionNodeId(session, native);
|
||||
rig.Project(native, current);
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "base"));
|
||||
|
||||
await CallAsync(session, condition, "Acknowledge", events.LatestEventId(item, "base"), ct);
|
||||
await rig.DrainProjectionsAsync();
|
||||
var expected = 2;
|
||||
if (!native)
|
||||
{
|
||||
await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(item, "base").Count == 2);
|
||||
await CallAsync(session, condition, "Confirm", events.LatestEventId(item, "base"), ct);
|
||||
await rig.DrainProjectionsAsync();
|
||||
expected = 3;
|
||||
}
|
||||
|
||||
await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(item, "base").Count == expected);
|
||||
await CallAsync(session, condition, "Acknowledge", events.LatestEventId(item, "base"), ct);
|
||||
await rig.DrainProjectionsAsync();
|
||||
|
||||
rig.Project(native, current = current with { Message = "sentinel" });
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "sentinel"));
|
||||
|
||||
events.ConditionEvents(item, "base").Count.ShouldBe(expected, "the re-ack must report nothing");
|
||||
var sentinel = events.ConditionEvents(item, "sentinel");
|
||||
sentinel.Count.ShouldBe(1);
|
||||
sentinel[0][RecordedEvents.AckedField].Value.ShouldBe(true);
|
||||
sentinel[0][RecordedEvents.ConfirmedField].Value.ShouldBe(true, "the re-ack must not unconfirm the node");
|
||||
|
||||
await subscription.DeleteAsync(true, ct);
|
||||
}
|
||||
finally { await rig.DisposeAsync(); }
|
||||
}
|
||||
|
||||
// ───────────────────────────── helpers ─────────────────────────────
|
||||
|
||||
private static AlarmConditionSnapshot NativeState(bool active, bool acked, string message) => new(
|
||||
Active: active, Acknowledged: acked, Confirmed: true, Enabled: true,
|
||||
Shelving: AlarmShelvingKind.Unshelved, Severity: 700, Message: message);
|
||||
|
||||
/// <summary>The condition state a case starts from — one in which its method is accepted.</summary>
|
||||
private static AlarmConditionSnapshot PreState(string method, bool native)
|
||||
{
|
||||
@@ -267,8 +444,10 @@ public sealed class ConditionMethodEventTests
|
||||
};
|
||||
}
|
||||
|
||||
private static void AssertNewState(string method, VariantCollection e)
|
||||
private static void AssertNewState(string method, bool native, VariantCollection e)
|
||||
{
|
||||
// A native condition is never Unconfirmed, whatever the method (the SDK's ack would make it so).
|
||||
if (native) e[RecordedEvents.ConfirmedField].Value.ShouldBe(true);
|
||||
switch (method)
|
||||
{
|
||||
case "Acknowledge": e[RecordedEvents.AckedField].Value.ShouldBe(true); break;
|
||||
@@ -287,7 +466,8 @@ public sealed class ConditionMethodEventTests
|
||||
/// Calls one Part 9 method on the condition by its type's method id (the SDK resolves it against the
|
||||
/// condition's method declarations — the shelve methods included); throws on a bad result.
|
||||
/// </summary>
|
||||
private static async Task CallAsync(ISession session, NodeId condition, string method, byte[]? eventId, CancellationToken ct)
|
||||
private static async Task CallAsync(
|
||||
ISession session, NodeId condition, string method, byte[]? eventId, CancellationToken ct, double shelvingTimeMs = 60_000)
|
||||
{
|
||||
var (methodId, args) = method switch
|
||||
{
|
||||
@@ -297,7 +477,7 @@ public sealed class ConditionMethodEventTests
|
||||
"Enable" => (MethodIds.ConditionType_Enable, []),
|
||||
"Disable" => (MethodIds.ConditionType_Disable, []),
|
||||
"OneShotShelve" => (MethodIds.AlarmConditionType_ShelvingState_OneShotShelve, []),
|
||||
"TimedShelve" => (MethodIds.AlarmConditionType_ShelvingState_TimedShelve, [60_000.0]),
|
||||
"TimedShelve" => (MethodIds.AlarmConditionType_ShelvingState_TimedShelve, [shelvingTimeMs]),
|
||||
"Unshelve" => (MethodIds.AlarmConditionType_ShelvingState_Unshelve, []),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(method), method, null),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user