Merge b647-focas-alarms: FOCAS native alarms reach Part 9 conditions (ALARM: address) + respawn keeps acks host-side (#647)
v2-ci / build (push) Successful in 4m57s
v2-ci / unit-tests (push) Failing after 21m6s

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 20:46:13 -04:00
36 changed files with 2847 additions and 551 deletions
@@ -1942,12 +1942,11 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
// Fresh prior = the host projector's own default (inactive, acked).
var told = new AlarmToldState(target.Active, target.Acked, e.Message, e.OpcUaSeverity);
var prior = _alarmStateByRawPath.TryGetValue(e.ConditionId, out var p)
? p
: new AlarmToldState(false, true, null, null);
var priorKnown = _alarmStateByRawPath.TryGetValue(e.ConditionId, out var p);
var prior = priorKnown ? p! : new AlarmToldState(false, true, null, null);
_alarmStateByRawPath[e.ConditionId] = told;
var kinds = AlarmTransitions((prior.Active, prior.Acked), target);
var kinds = AlarmTransitions((prior.Active, prior.Acked), target, priorKnown);
if (kinds.Count == 0)
{
// Same state: a message/severity change still reaches the host; an identical copy does not.
@@ -2066,18 +2065,34 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
/// <summary>
/// #619 review — the <see cref="AlarmTransitionKind"/> sequence that moves the host's
/// <c>NativeAlarmProjector</c> (which knows only kinds: Raise ⇒ active+unacked, Acknowledge ⇒ acked,
/// <c>NativeAlarmProjector</c> (which knows only kinds: Raise ⇒ active+unacked — or, on an already-active condition, a re-statement keeping the ack (#647) — Acknowledge ⇒ acked,
/// Clear ⇒ inactive keeping acked) from <paramref name="prior"/> to <paramref name="target"/>. A single
/// upstream event can need two (an alarm that went active and was acknowledged between two notifications).
/// No state change ⇒ empty (the caller decides whether a message/severity change warrants an Unspecified).
/// </summary>
/// <param name="prior">The state last told to the host.</param>
/// <param name="target">The upstream event's ActiveState/Id + AckedState/Id.</param>
/// <param name="priorKnown">
/// False when this driver instance has told the host nothing about the condition yet (first sight after a
/// start or a deploy respawn), so <paramref name="prior"/> is only the projector's default. Gitea #647 review:
/// the host now treats a Raise on an already-active condition as a re-statement that KEEPS its ack, so an
/// upstream that says <i>unacknowledged</i> must then be told with <see cref="AlarmTransitionKind.Retrigger"/>
/// — the upstream's ack state is authoritative, and the host may be holding an ack from before the respawn.
/// </param>
/// <returns>The kinds to emit, in order.</returns>
internal static IReadOnlyList<AlarmTransitionKind> AlarmTransitions(
(bool Active, bool Acked) prior, (bool Active, bool Acked) target)
(bool Active, bool Acked) prior, (bool Active, bool Acked) target, bool priorKnown = true)
{
if (prior == target) return [];
var kinds = Transitions(prior, target);
return priorKnown || target.Acked
? kinds
: [.. kinds.Select(k => k == AlarmTransitionKind.Raise ? AlarmTransitionKind.Retrigger : k)];
}
private static IReadOnlyList<AlarmTransitionKind> Transitions(
(bool Active, bool Acked) prior, (bool Active, bool Acked) target)
{
return target switch
{
// Active+unacked: a fresh raise, or a re-arm of an active-acked condition.