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
@@ -154,9 +154,13 @@ public sealed class WireBackendCoverageTests
}
}
/// <summary>Verifies that alarm raise then clear emits both events via the wire backend.</summary>
/// <summary>
/// Gitea #647 — CNC alarms injected into the mock drive ONE condition per <c>ALARM:</c> tag, keyed by the tag's
/// RawPath, with a <see cref="AlarmTransitionKind"/>: an Overtravel alarm raises both <c>ALARM:Overtravel</c>
/// and <c>ALARM:*</c>; a second (servo) alarm retriggers only the summary; clearing the list clears both.
/// </summary>
[Fact]
public async Task Alarm_raise_then_clear_emits_both_events_via_wire_backend()
public async Task Alarm_selectors_raise_retrigger_and_clear_via_wire_backend()
{
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
var ct = TestContext.Current.CancellationToken;
@@ -165,16 +169,15 @@ public sealed class WireBackendCoverageTests
// Start with no active alarms.
await _fx.PatchStateAsync(new { alarms = Array.Empty<object>() }, ct);
const string overtravelPath = "Plant/Cnc/m1/overtravel";
const string anyPath = "Plant/Cnc/m1/any";
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(DeviceHost)],
RawTags = [],
RawTags = [AlarmTag(overtravelPath, "ALARM:Overtravel"), AlarmTag(anyPath, "ALARM:*")],
Probe = new FocasProbeOptions { Enabled = false },
AlarmProjection = new FocasAlarmProjectionOptions
{
Enabled = true,
PollInterval = TimeSpan.FromMilliseconds(200),
},
AlarmProjection = new FocasAlarmProjectionOptions { PollInterval = TimeSpan.FromMilliseconds(200) },
}, driverInstanceId: "wire-alarms", clientFactory: new WireFocasClientFactory());
await using (drv)
@@ -183,27 +186,46 @@ public sealed class WireBackendCoverageTests
var events = new List<AlarmEventArgs>();
drv.OnAlarmEvent += (_, e) => { lock (events) events.Add(e); };
var sub = await drv.SubscribeAlarmsAsync([], ct);
var sub = await drv.SubscribeAlarmsAsync([overtravelPath, anyPath], ct);
await _fx.PatchStateAsync(new
{
alarms = new[] { new { alm_no = 506, type = 2, axis = 1, msg = "TEST OVERTRAVEL" } },
}, ct);
await WaitFor(() => Snapshot(events).Count(e => e.Kind == AlarmTransitionKind.Raise) == 2, TimeSpan.FromSeconds(5));
// Raise one alarm.
await _fx.PatchStateAsync(new
{
alarms = new[]
{
new { alm_no = 500, type = 2, axis = 1, msg = "TEST OVERTRAVEL" },
new { alm_no = 506, type = 2, axis = 1, msg = "TEST OVERTRAVEL" },
new { alm_no = 401, type = 4, axis = 2, msg = "TEST SERVO" },
},
}, ct);
await WaitFor(() => events.Any(e => e.Message.Contains("OVERTRAVEL")), TimeSpan.FromSeconds(5));
await WaitFor(() => Snapshot(events).Any(e => e.Kind == AlarmTransitionKind.Retrigger), TimeSpan.FromSeconds(5));
// Clear.
await _fx.PatchStateAsync(new { alarms = Array.Empty<object>() }, ct);
await WaitFor(() => events.Any(e => e.Message.Contains("cleared")), TimeSpan.FromSeconds(5));
await WaitFor(() => Snapshot(events).Count(e => e.Kind == AlarmTransitionKind.Clear) == 2, TimeSpan.FromSeconds(5));
await drv.UnsubscribeAlarmsAsync(sub, ct);
events.ShouldContain(e => e.AlarmType == "Overtravel" && e.Severity == AlarmSeverity.Critical);
events.ShouldContain(e => e.Message.Contains("cleared"));
events[0].SourceNodeId.ShouldBe(DeviceHost);
var all = Snapshot(events);
all.Where(e => e.ConditionId == overtravelPath).Select(e => e.Kind).ToArray()
.ShouldBe(new[] { AlarmTransitionKind.Raise, AlarmTransitionKind.Clear });
all.Where(e => e.ConditionId == anyPath).Select(e => e.Kind).ToArray()
.ShouldBe(new[] { AlarmTransitionKind.Raise, AlarmTransitionKind.Retrigger, AlarmTransitionKind.Clear });
all[0].AlarmType.ShouldBe("Overtravel");
all[0].Severity.ShouldBe(AlarmSeverity.Critical);
all.ShouldAllBe(e => e.SourceNodeId == DeviceHost);
}
RawTagEntry AlarmTag(string rawPath, string address) => new(rawPath,
"{\"address\":\"" + address + "\",\"dataType\":\"Int32\",\"alarm\":{\"alarmType\":\"AlarmCondition\"}}",
WriteIdempotent: false, DeviceName: DeviceHost);
static List<AlarmEventArgs> Snapshot(List<AlarmEventArgs> events)
{
lock (events) return [.. events];
}
}