Files
lmxopcua/src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/Part9StateMachine.cs
T
Joseph Doherty 83e9ffd27a fix(alarms): SDK timed-unshelve timer is a no-op; commented re-ack comment is durable (#597)
Review R1 M2: since #597 the SDK timer fires at real expiry, and OnTimedUnshelve routed an operator-style
Unshelve by 'system' from both pair nodes, pre-empting the engine's AutoUnshelve. The engine owns expiry,
so the callback now routes nothing. Integration test: an engine stand-in that acts on a routed Unshelve and
expires late, plus a wrapped OnTimedUnshelve (positive control: the SDK timer fired).
Review R1 m1: Part9StateMachine.ApplyAcknowledge records a commented re-ack as AddComment (persisted and
replicated) instead of a NoOp; an uncommented re-ack is still a no-op.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 07:47:28 -04:00

396 lines
18 KiB
C#

using System.Collections.Immutable;
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
/// <summary>
/// Pure functions for OPC UA Part 9 alarm-condition state transitions. Input = the
/// current <see cref="AlarmConditionState"/> + the event; output = the new state +
/// optional emission hint. The engine calls these; persistence happens around them.
/// </summary>
/// <remarks>
/// <para>
/// No instance state, no I/O, no mutation of the input record. Every transition
/// returns a fresh record. Makes the state machine trivially unit-testable —
/// tests assert on (input, event) -> (output) without standing anything else up.
/// </para>
/// <para>
/// Two invariants the machine enforces:
/// (1) Disabled alarms never transition ActiveState / AckedState / ConfirmedState
/// — all predicate evaluations while disabled produce a no-op result and a
/// diagnostic log line. Re-enable restores normal flow with ActiveState
/// re-derived from the next predicate evaluation.
/// (2) Shelved alarms (OneShot / Timed) don't fire active transitions to
/// subscribers, but the state record still advances so that when shelving
/// expires the ActiveState reflects current reality. OneShot expires on the
/// next clear; Timed expires at <see cref="ShelvingState.UnshelveAtUtc"/>.
/// </para>
/// </remarks>
public static class Part9StateMachine
{
/// <summary>
/// Apply a predicate re-evaluation result. Handles activation, clearing,
/// branch-stack increment when a new active arrives while prior active is
/// still un-acked, and shelving suppression.
/// </summary>
/// <param name="current">The current alarm condition state.</param>
/// <param name="predicateTrue">Whether the predicate is true.</param>
/// <param name="nowUtc">The current UTC time.</param>
/// <returns>The transition result.</returns>
public static TransitionResult ApplyPredicate(
AlarmConditionState current,
bool predicateTrue,
DateTime nowUtc)
{
if (current.Enabled == AlarmEnabledState.Disabled)
return TransitionResult.NoOp(current, "disabled — predicate result ignored");
// Expire timed shelving if the configured clock has passed.
var shelving = MaybeExpireShelving(current.Shelving, nowUtc);
var stateWithShelving = current with { Shelving = shelving };
// Shelved alarms still update state but skip event emission.
var shelved = shelving.Kind != ShelvingKind.Unshelved;
if (predicateTrue && current.Active == AlarmActiveState.Inactive)
{
// Inactive -> Active transition.
// OneShotShelving is consumed on the NEXT clear, not activation — so we
// still suppress this transition's emission.
var next = stateWithShelving with
{
Active = AlarmActiveState.Active,
Acked = AlarmAckedState.Unacknowledged,
Confirmed = AlarmConfirmedState.Unconfirmed,
LastActiveUtc = nowUtc,
LastTransitionUtc = nowUtc,
};
return new TransitionResult(next, shelved ? EmissionKind.Suppressed : EmissionKind.Activated);
}
if (!predicateTrue && current.Active == AlarmActiveState.Active)
{
// Active -> Inactive transition.
var next = stateWithShelving with
{
Active = AlarmActiveState.Inactive,
LastClearedUtc = nowUtc,
LastTransitionUtc = nowUtc,
// OneShotShelving expires on clear — resetting here so the next
// activation fires normally.
Shelving = shelving.Kind == ShelvingKind.OneShot
? ShelvingState.Unshelved
: shelving,
};
return new TransitionResult(next, shelved ? EmissionKind.Suppressed : EmissionKind.Cleared);
}
// Predicate matches current Active — no state change beyond possible shelving
// expiry.
return new TransitionResult(stateWithShelving, EmissionKind.None);
}
/// <summary>Operator acknowledges the currently-active transition.</summary>
/// <param name="current">The current alarm condition state.</param>
/// <param name="user">The user who is acknowledging.</param>
/// <param name="comment">An optional comment.</param>
/// <param name="nowUtc">The current UTC time.</param>
/// <returns>The transition result.</returns>
public static TransitionResult ApplyAcknowledge(
AlarmConditionState current,
string user,
string? comment,
DateTime nowUtc)
{
if (string.IsNullOrWhiteSpace(user))
throw new ArgumentException("User identity required for audit.", nameof(user));
if (current.Acked == AlarmAckedState.Acknowledged)
{
// A COMMENTED re-ack carries a real comment even though nothing is acknowledged (Gitea #597 review
// m1): the OPC UA node reports it — the SDK writes the comment on a re-ack — so the system of
// record must hold it too, or the event advertises a comment that exists only on the node that
// served the call (never persisted, never replicated to the pair peer). Recorded exactly like
// AddComment; the ack fields (LastAck*) stay those of the real acknowledge. An uncommented re-ack
// is still a no-op.
return string.IsNullOrWhiteSpace(comment)
? TransitionResult.NoOp(current, "already acknowledged")
: ApplyAddComment(current, user, comment, nowUtc);
}
var audit = AppendComment(current.Comments, nowUtc, user, "Acknowledge", comment);
var next = current with
{
Acked = AlarmAckedState.Acknowledged,
LastAckUtc = nowUtc,
LastAckUser = user,
LastAckComment = comment,
LastTransitionUtc = nowUtc,
Comments = audit,
};
return new TransitionResult(next, EmissionKind.Acknowledged);
}
/// <summary>Operator confirms the cleared transition. Part 9 requires confirm after clear for retain-flag alarms.</summary>
/// <param name="current">The current alarm condition state.</param>
/// <param name="user">The user who is confirming.</param>
/// <param name="comment">An optional comment.</param>
/// <param name="nowUtc">The current UTC time.</param>
/// <returns>The transition result.</returns>
public static TransitionResult ApplyConfirm(
AlarmConditionState current,
string user,
string? comment,
DateTime nowUtc)
{
if (string.IsNullOrWhiteSpace(user))
throw new ArgumentException("User identity required for audit.", nameof(user));
if (current.Confirmed == AlarmConfirmedState.Confirmed)
return TransitionResult.NoOp(current, "already confirmed");
var audit = AppendComment(current.Comments, nowUtc, user, "Confirm", comment);
var next = current with
{
Confirmed = AlarmConfirmedState.Confirmed,
LastConfirmUtc = nowUtc,
LastConfirmUser = user,
LastConfirmComment = comment,
LastTransitionUtc = nowUtc,
Comments = audit,
};
return new TransitionResult(next, EmissionKind.Confirmed);
}
/// <summary>
/// Applies a one-shot shelving action.
/// </summary>
/// <param name="current">The current alarm condition state.</param>
/// <param name="user">The user applying the shelving.</param>
/// <param name="nowUtc">The current UTC time.</param>
/// <returns>The transition result.</returns>
public static TransitionResult ApplyOneShotShelve(
AlarmConditionState current, string user, DateTime nowUtc)
{
if (string.IsNullOrWhiteSpace(user)) throw new ArgumentException("User required.", nameof(user));
if (current.Shelving.Kind == ShelvingKind.OneShot)
return TransitionResult.NoOp(current, "already one-shot shelved");
var audit = AppendComment(current.Comments, nowUtc, user, "ShelveOneShot", null);
var next = current with
{
Shelving = new ShelvingState(ShelvingKind.OneShot, null),
LastTransitionUtc = nowUtc,
Comments = audit,
};
return new TransitionResult(next, EmissionKind.Shelved);
}
/// <summary>
/// Applies a timed shelving action.
/// </summary>
/// <param name="current">The current alarm condition state.</param>
/// <param name="user">The user applying the shelving.</param>
/// <param name="unshelveAtUtc">The UTC time at which the alarm should be unshelved.</param>
/// <param name="nowUtc">The current UTC time.</param>
/// <returns>The transition result.</returns>
public static TransitionResult ApplyTimedShelve(
AlarmConditionState current, string user, DateTime unshelveAtUtc, DateTime nowUtc)
{
if (string.IsNullOrWhiteSpace(user)) throw new ArgumentException("User required.", nameof(user));
if (unshelveAtUtc <= nowUtc)
throw new ArgumentOutOfRangeException(nameof(unshelveAtUtc), "Unshelve time must be in the future.");
var audit = AppendComment(current.Comments, nowUtc, user, "ShelveTimed",
$"UnshelveAtUtc={unshelveAtUtc:O}");
var next = current with
{
Shelving = new ShelvingState(ShelvingKind.Timed, unshelveAtUtc),
LastTransitionUtc = nowUtc,
Comments = audit,
};
return new TransitionResult(next, EmissionKind.Shelved);
}
/// <summary>
/// Applies an unshelving action.
/// </summary>
/// <param name="current">The current alarm condition state.</param>
/// <param name="user">The user applying the unshelving.</param>
/// <param name="nowUtc">The current UTC time.</param>
/// <returns>The transition result.</returns>
public static TransitionResult ApplyUnshelve(AlarmConditionState current, string user, DateTime nowUtc)
{
if (string.IsNullOrWhiteSpace(user)) throw new ArgumentException("User required.", nameof(user));
if (current.Shelving.Kind == ShelvingKind.Unshelved)
return TransitionResult.NoOp(current, "not shelved");
var audit = AppendComment(current.Comments, nowUtc, user, "Unshelve", null);
var next = current with
{
Shelving = ShelvingState.Unshelved,
LastTransitionUtc = nowUtc,
Comments = audit,
};
return new TransitionResult(next, EmissionKind.Unshelved);
}
/// <summary>
/// Applies an enable action.
/// </summary>
/// <param name="current">The current alarm condition state.</param>
/// <param name="user">The user enabling the alarm.</param>
/// <param name="nowUtc">The current UTC time.</param>
/// <returns>The transition result.</returns>
public static TransitionResult ApplyEnable(AlarmConditionState current, string user, DateTime nowUtc)
{
if (string.IsNullOrWhiteSpace(user)) throw new ArgumentException("User required.", nameof(user));
if (current.Enabled == AlarmEnabledState.Enabled)
return TransitionResult.NoOp(current, "already enabled");
var audit = AppendComment(current.Comments, nowUtc, user, "Enable", null);
var next = current with
{
Enabled = AlarmEnabledState.Enabled,
LastTransitionUtc = nowUtc,
Comments = audit,
};
return new TransitionResult(next, EmissionKind.Enabled);
}
/// <summary>
/// Applies a disable action.
/// </summary>
/// <param name="current">The current alarm condition state.</param>
/// <param name="user">The user disabling the alarm.</param>
/// <param name="nowUtc">The current UTC time.</param>
/// <returns>The transition result.</returns>
public static TransitionResult ApplyDisable(AlarmConditionState current, string user, DateTime nowUtc)
{
if (string.IsNullOrWhiteSpace(user)) throw new ArgumentException("User required.", nameof(user));
if (current.Enabled == AlarmEnabledState.Disabled)
return TransitionResult.NoOp(current, "already disabled");
var audit = AppendComment(current.Comments, nowUtc, user, "Disable", null);
var next = current with
{
Enabled = AlarmEnabledState.Disabled,
LastTransitionUtc = nowUtc,
Comments = audit,
};
return new TransitionResult(next, EmissionKind.Disabled);
}
/// <summary>
/// Applies an add comment action.
/// </summary>
/// <param name="current">The current alarm condition state.</param>
/// <param name="user">The user adding the comment.</param>
/// <param name="text">The comment text.</param>
/// <param name="nowUtc">The current UTC time.</param>
/// <returns>The transition result.</returns>
public static TransitionResult ApplyAddComment(
AlarmConditionState current, string user, string text, DateTime nowUtc)
{
if (string.IsNullOrWhiteSpace(user)) throw new ArgumentException("User required.", nameof(user));
if (string.IsNullOrWhiteSpace(text)) throw new ArgumentException("Comment text required.", nameof(text));
var audit = AppendComment(current.Comments, nowUtc, user, "AddComment", text);
var next = current with { Comments = audit };
return new TransitionResult(next, EmissionKind.CommentAdded);
}
/// <summary>
/// Re-evaluate whether a currently timed-shelved alarm has expired. Returns
/// the (possibly unshelved) state + emission hint so the engine knows to
/// publish an Unshelved event at the right moment.
/// </summary>
/// <param name="current">The current alarm condition state.</param>
/// <param name="nowUtc">The current UTC time.</param>
/// <returns>The transition result.</returns>
public static TransitionResult ApplyShelvingCheck(AlarmConditionState current, DateTime nowUtc)
{
if (current.Shelving.Kind != ShelvingKind.Timed) return TransitionResult.None(current);
if (current.Shelving.UnshelveAtUtc is DateTime t && nowUtc >= t)
{
var audit = AppendComment(current.Comments, nowUtc, "system", "AutoUnshelve",
$"Timed shelving expired at {nowUtc:O}");
var next = current with
{
Shelving = ShelvingState.Unshelved,
LastTransitionUtc = nowUtc,
Comments = audit,
};
return new TransitionResult(next, EmissionKind.Unshelved);
}
return TransitionResult.None(current);
}
private static ShelvingState MaybeExpireShelving(ShelvingState s, DateTime nowUtc)
{
if (s.Kind != ShelvingKind.Timed) return s;
return s.UnshelveAtUtc is DateTime t && nowUtc >= t ? ShelvingState.Unshelved : s;
}
private static ImmutableList<AlarmComment> AppendComment(
ImmutableList<AlarmComment> existing, DateTime ts, string user, string kind, string? text)
=> existing.Add(new AlarmComment(ts, user, kind, text ?? string.Empty));
}
/// <summary>Result of a state-machine operation — new state + what to emit (if anything).</summary>
/// <remarks>
/// <para>
/// <see cref="NoOpReason"/> carries a short diagnostic string for the
/// <see cref="NoOp(AlarmConditionState, string)"/> case (e.g.
/// "disabled — predicate result ignored", "already acknowledged"). The
/// engine logs this at debug level when a no-op result is observed, so
/// the class-level remarks on <see cref="Part9StateMachine"/> hold:
/// disabled-alarm and idempotent ack/confirm/shelve/unshelve
/// transitions do produce a diagnostic log line. Plain
/// <see cref="None(AlarmConditionState)"/> results (state unchanged,
/// no operator intent recorded — e.g. a predicate re-evaluation that
/// confirms the existing active state) leave <see cref="NoOpReason"/>
/// null because there is nothing to surface to an operator.
/// </para>
/// </remarks>
public sealed record TransitionResult(AlarmConditionState State, EmissionKind Emission, string? NoOpReason = null)
{
/// <summary>
/// Creates a transition result with no change and no emission.
/// </summary>
/// <param name="state">The alarm condition state.</param>
/// <returns>The transition result.</returns>
public static TransitionResult None(AlarmConditionState state) => new(state, EmissionKind.None);
/// <summary>
/// Creates a transition result indicating a no-op operation with a reason.
/// </summary>
/// <param name="state">The alarm condition state.</param>
/// <param name="reason">The reason for the no-op.</param>
/// <returns>The transition result.</returns>
public static TransitionResult NoOp(AlarmConditionState state, string reason)
=> new(state, EmissionKind.None, reason);
}
/// <summary>What kind of event, if any, the engine should emit after a transition.</summary>
public enum EmissionKind
{
/// <summary>State did not change meaningfully — no event to emit.</summary>
None,
/// <summary>Predicate transitioned to true while shelving was suppressing events.</summary>
Suppressed,
Activated,
Cleared,
Acknowledged,
Confirmed,
Shelved,
Unshelved,
Enabled,
Disabled,
CommentAdded,
/// <summary>#478 — the worst-of-input source quality changed bucket (Good/Uncertain/Bad) with no
/// accompanying Part 9 state transition. Delivered out of band via the dedicated
/// <c>WriteAlarmQuality</c> node path, never through the <c>IAlarmSource</c> fan-out (it is not a
/// state change and must not materialize or historize a condition).</summary>
QualityChanged,
}