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
@@ -63,4 +63,37 @@ public sealed class FocasAddressBuilderTests
}
}
}
// ---- Gitea #647: ALARM: selectors ----
[Theory]
[InlineData("*", null, null, "ALARM:*")]
[InlineData("*", 401, null, "ALARM:*")] // numbers apply to a type scope only
[InlineData("OTHER", null, null, "ALARM:OTHER")]
[InlineData("Servo", null, null, "ALARM:Servo")]
[InlineData("Servo", 401, null, "ALARM:Servo/401")]
[InlineData("Servo", 401, 453, "ALARM:Servo/401-453")]
[InlineData("Servo", 401, 401, "ALARM:Servo/401")]
[InlineData("T9", 750, 758, "ALARM:T9/750-758")]
public void BuildAlarm_produces_a_selector_the_driver_can_parse(string scope, int? lo, int? hi, string expected)
{
var built = FocasAddressBuilder.BuildAlarm(scope, lo, hi);
built.ShouldBe(expected);
FocasAddress.TryParse(built)!.Kind.ShouldBe(FocasAreaKind.Alarm);
}
[Theory]
[InlineData("Servo", 453, 401)] // reversed range
[InlineData("Bogus", null, null)] // not a type
[InlineData("", null, null)]
public void BuildAlarm_returns_empty_rather_than_an_unusable_selector(string scope, int? lo, int? hi)
=> FocasAddressBuilder.BuildAlarm(scope, lo, hi).ShouldBeEmpty();
[Fact]
public void Every_offered_alarm_scope_composes_a_parseable_selector()
{
foreach (var scope in FocasAddressBuilder.AlarmScopes)
FocasAddress.TryParse(FocasAddressBuilder.BuildAlarm(scope, 1, 2)).ShouldNotBeNull(scope);
}
}