feat(focas): CNC alarms reach Part 9 conditions via ALARM: tags (#647)

An ALARM: address (*, <Type>, <Type>/<n>, <Type>/<lo>-<hi>, T<code>, OTHER) on a raw tag that
also carries the cross-driver alarm object is ONE condition with ConditionId == RawPath and a
Kind (Raise / Retrigger / Clear), so DriverHostActor.ForwardNativeAlarm routes it and
NativeAlarmProjector activates it. Fixes all seven triage defects:

1. SubscribeAlarmsAsync treated the host's RawPaths as device hosts -> nothing polled.
2. Decorated "{host}#{Type}:{Number}" ConditionId -> every event dropped.
3. No Kind -> projector kept prior state.
4. WireFocasClient.ReadAlarmsAsync answered [] on failure -> mass clear + un-acked re-raise.
   It now throws; a failed device read keeps every condition's state.
5. Acks lost on reconnect -> told-record dedup (MTConnect #626 pattern) held for the driver's
   lifetime; same-set re-subscribe / re-init re-raise nothing; changed set prunes leavers only.
6. Subscribe threw while AlarmProjection.Enabled=false -> the tag is the opt-in; flag is a no-op.
7. Alarm tags could not be authored -> ALARM: grammar (FocasAlarmSelector, Contracts) backs the
   editor, the address picker, the deploy-time inspector and driver init.

Severity: authored alarm.severity -> exact OpcUaSeverity; else the 16i-era type bucket. The type
table stays the only one (moved to Contracts, documented 16i-era; 30i codes not invented; raw
T<code> selector + raw code in every message). Read-only: ack is server-side. Also clears the
value table on a re-initialise (#630 gap that let a removed/moved tag keep reading).

E2E: Runtime.Tests FocasNativeAlarmEndToEndTests drives the real driver through the real
DriverHostActor and asserts the condition goes Active then inactive.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 19:46:48 -04:00
parent 8801631739
commit 482bafdfac
27 changed files with 2110 additions and 515 deletions
@@ -65,12 +65,18 @@ public interface IFocasClient : IDisposable
/// <summary>
/// Read active alarm messages from the CNC via <c>cnc_rdalmmsg2</c>. Returns
/// zero-or-more active alarms. Null / empty list means "no alarms currently
/// active". IAlarmSource projection polls this at a configurable interval +
/// emits transitions (raise / clear) on the driver's <c>OnAlarmEvent</c>.
/// zero-or-more active alarms; an empty list means "the CNC answered, and no alarm is
/// active". The alarm projection polls this per device and diffs it into Part 9
/// condition transitions on the driver's <c>OnAlarmEvent</c>.
/// <para>
/// <b>A failed read MUST throw</b>, never answer empty (Gitea #647): the projection treats an
/// empty answer as "every alarm cleared", so a failure reported as <c>[]</c> becomes a mass
/// clear followed by an un-acknowledged re-raise on recovery. A throw keeps the last state.
/// </para>
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The list of currently active alarms; empty when none are active.</returns>
/// <exception cref="Exception">Any failure to obtain the list — the session is down, the CNC refused, the wire faulted.</exception>
Task<IReadOnlyList<FocasActiveAlarm>> ReadAlarmsAsync(CancellationToken cancellationToken);
/// <summary>
@@ -446,27 +452,3 @@ public sealed class UnimplementedFocasClientFactory : IFocasClientFactory
/// <inheritdoc />
public IFocasClient Create() => throw new NotSupportedException(Message);
}
/// <summary>
/// Well-known FOCAS alarm types from <c>fwlib32.h</c> <c>ALM_TYPE_*</c>. Narrow subset —
/// the full list is ~15 types per model; these cover the universally-present categories.
/// </summary>
/// <remarks>
/// Constants are typed <see cref="short"/> so they match the wire field width on
/// <c>cnc_rdalmmsg2</c> (and so <see cref="FocasAlarmProjection"/>'s <c>switch (short)</c>
/// statements compile against a matching type rather than relying on implicit int→short
/// narrowing on the constants).
/// </remarks>
public static class FocasAlarmType
{
/// <summary>Pass to <see cref="IFocasClient.ReadAlarmsAsync"/>-equivalent to mean "any type".</summary>
public const short All = -1;
public const short Parameter = 0; // ALM_P
public const short PulseCode = 1; // ALM_Y (servo)
public const short Overtravel = 2; // ALM_O
public const short Overheat = 3; // ALM_H
public const short Servo = 4; // ALM_S
public const short DataIo = 5; // ALM_T
public const short MemoryCheck = 6; // ALM_M
public const short MacroAlarm = 13; // ALM_MC
}