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:
+39
-17
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user