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];
}
}
@@ -123,10 +123,33 @@ internal class FakeFocasClient : IFocasClient
/// <summary>Gets the list of active alarms.</summary>
public List<FocasActiveAlarm> Alarms { get; } = [];
/// <summary>Gets or sets a value indicating whether the alarm read throws (Gitea #647: a failed read).</summary>
public bool ThrowOnReadAlarms { get; set; }
/// <summary>Gets or sets an exception the alarm read throws (e.g. a CNC refusal carrying an rc).</summary>
public Exception? AlarmReadException { get; set; }
/// <summary>Gets the number of alarm reads served (successful or not).</summary>
public int AlarmReadCount => Volatile.Read(ref _alarmReadCount);
private int _alarmReadCount;
/// <summary>Replaces the active alarm list atomically with respect to the poll thread.</summary>
/// <param name="alarms">The new active alarms.</param>
public void SetAlarms(params FocasActiveAlarm[] alarms)
{
lock (Alarms) { Alarms.Clear(); Alarms.AddRange(alarms); }
}
/// <summary>Reads active alarms asynchronously.</summary>
/// <param name="ct">The cancellation token.</param>
public virtual Task<IReadOnlyList<FocasActiveAlarm>> ReadAlarmsAsync(CancellationToken ct) =>
Task.FromResult<IReadOnlyList<FocasActiveAlarm>>([.. Alarms]);
public virtual Task<IReadOnlyList<FocasActiveAlarm>> ReadAlarmsAsync(CancellationToken ct)
{
Interlocked.Increment(ref _alarmReadCount);
if (AlarmReadException is { } refusal) throw refusal;
if (ThrowOnReadAlarms) throw new InvalidOperationException("cnc_rdalmmsg2 failed (fake)");
lock (Alarms) return Task.FromResult<IReadOnlyList<FocasActiveAlarm>>([.. Alarms]);
}
// ---- Fixed-tree T1 ----
/// <summary>Gets or sets the system information.</summary>
@@ -1,196 +1,668 @@
using System.Text.Json;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// Gitea #647 — FOCAS CNC alarms as Part 9 conditions at <c>ALARM:</c> raw tags. Every event must carry
/// <c>ConditionId == RawPath</c> and a <see cref="AlarmTransitionKind"/>, or the host drops / ignores it.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasAlarmProjectionTests
{
private const string Host = "focas://10.0.0.5:8193";
private const string Host2 = "focas://10.0.0.6:8193";
private const string ServoPath = "Plant/Cnc/m1/servo";
private const string AnyPath = "Plant/Cnc/m1/any";
private const string OtherPath = "Plant/Cnc/m1/other";
private static (FocasDriver drv, FakeFocasClientFactory factory) NewDriver(bool alarmsEnabled)
private static FocasActiveAlarm Servo401(short axis = 1) =>
new(401, FocasAlarmType.Servo, axis, "SERVO V-READY OFF");
private static FocasActiveAlarm Overtravel506 => new(506, FocasAlarmType.Overtravel, 2, "OVER TRAVEL +2");
private static FocasActiveAlarm Overheat700 => new(700, FocasAlarmType.Overheat, 0, "OVERHEAT: CONTROL UNIT");
private static string AlarmTagConfig(string address, int? severity = null) =>
JsonSerializer.Serialize(new Dictionary<string, object>
{
["address"] = address,
["dataType"] = "Int32",
["alarm"] = severity is { } s
? new Dictionary<string, object> { ["alarmType"] = "AlarmCondition", ["severity"] = s }
: new Dictionary<string, object> { ["alarmType"] = "AlarmCondition" },
});
private static (FocasDriver Driver, FakeFocasClientFactory Factory) NewDriver(
params RawTagEntry[] tags) => NewDriver([new FocasDeviceOptions(Host, DeviceName: "m1")], tags);
private static (FocasDriver Driver, FakeFocasClientFactory Factory) NewDriver(
IReadOnlyList<FocasDeviceOptions> devices, params RawTagEntry[] tags)
{
var factory = new FakeFocasClientFactory();
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
RawTags = [],
Devices = [.. devices],
RawTags = [.. tags],
Probe = new FocasProbeOptions { Enabled = false },
AlarmProjection = new FocasAlarmProjectionOptions
{
Enabled = alarmsEnabled,
PollInterval = TimeSpan.FromMilliseconds(30),
},
// Enabled is left at its default (false) on purpose: authoring an ALARM: tag is the opt-in (#647).
AlarmProjection = new FocasAlarmProjectionOptions { PollInterval = TimeSpan.FromMilliseconds(20) },
}, "drv-1", factory);
return (drv, factory);
}
/// <summary>Verifies that subscribe without enable throws NotSupported.</summary>
[Fact]
public async Task Subscribe_without_Enable_throws_NotSupported()
private static RawTagEntry Tag(string rawPath, string address, int? severity = null, string device = "m1") =>
new(rawPath, AlarmTagConfig(address, severity), WriteIdempotent: false, DeviceName: device);
private static FakeFocasClient ClientOf(FakeFocasClientFactory factory) => factory.Clients.Single();
/// <summary>Drives one tick for the driver's live subscription, the way the poll loop would.</summary>
private static void Tick(FocasDriver drv, params FocasActiveAlarm[] alarms) =>
drv.AlarmProjection.Tick(drv.AlarmProjection.Current!, Host, alarms, drv.AlarmTags);
private static List<AlarmEventArgs> Capture(FocasDriver drv, bool transitionsOnly = false)
{
var (drv, _) = NewDriver(alarmsEnabled: false);
await drv.InitializeAsync("{}", CancellationToken.None);
await Should.ThrowAsync<NotSupportedException>(() =>
drv.SubscribeAlarmsAsync([], CancellationToken.None));
}
/// <summary>Verifies that raise then clear emits both events.</summary>
[Fact]
public async Task Raise_then_clear_emits_both_events()
{
var (drv, factory) = NewDriver(alarmsEnabled: true);
factory.Customise = () => new FakeFocasClient();
await drv.InitializeAsync("{}", CancellationToken.None);
var events = new List<AlarmEventArgs>();
drv.OnAlarmEvent += (_, e) => { lock (events) events.Add(e); };
var sub = await drv.SubscribeAlarmsAsync([], CancellationToken.None);
// First tick creates the client via EnsureConnectedAsync — wait for it before we
// poke the alarm list so we don't race the poll loop.
await WaitFor(() => factory.Clients.Count > 0, TimeSpan.FromSeconds(3));
var client = factory.Clients[0];
client.Alarms.Add(new FocasActiveAlarm(500, FocasAlarmType.Overtravel, 1, "Axis 1 overtravel"));
await WaitFor(() => events.Any(e => e.Message.Contains("overtravel")), TimeSpan.FromSeconds(3));
// Clear — the clear event wraps the original message with "(cleared)".
client.Alarms.Clear();
await WaitFor(() => events.Any(e => e.Message.Contains("cleared")), TimeSpan.FromSeconds(3));
await drv.UnsubscribeAlarmsAsync(sub, CancellationToken.None);
await drv.ShutdownAsync(CancellationToken.None);
events.ShouldContain(e => e.AlarmType == "Overtravel" && e.Severity == AlarmSeverity.Critical);
events.ShouldContain(e => e.Message.Contains("cleared"));
events[0].SourceNodeId.ShouldBe(Host);
drv.OnAlarmEvent += (_, e) =>
{
if (transitionsOnly && e.IsQualityOnly) return;
lock (events) events.Add(e);
};
return events;
}
/// <summary>
/// Regression for the reconnect poll-loop leak (#399 sibling): the owning
/// DriverInstanceActor re-subscribes alarms on every Connected entry without first
/// calling Unsubscribe, and the driver object (and its projection) survives the in-place
/// reconnect. Each SubscribeAlarmsAsync used to start a fresh, never-cancelled poll loop —
/// so after N reconnects there were N live loops all polling the device and all firing the
/// same raise/clear transition, producing DUPLICATE alarm events.
///
/// This test simulates two re-subscribes (one reconnect) against the same source, then
/// drives ONE device raise. After the collapse-to-single-loop fix exactly one loop is alive
/// so the raise must fire exactly once. Before the fix both loops fire it ⇒ two events.
/// A driver whose loop is not polling, so ticks are driven by the test only. The subscription is real; the
/// loop is paused right after so its first read cannot race the hand-driven ticks.
/// </summary>
private static async Task<FocasDriver> InitAndSubscribeForTicksAsync(
FocasDriver drv, FakeFocasClientFactory factory, params string[] refs)
{
// The real loop still starts on every subscribe; a CNC whose alarm read always fails makes its ticks
// no-ops (a failed read keeps state), so only the hand-driven ticks below move anything.
factory.Customise = () => new FakeFocasClient { ThrowOnReadAlarms = true };
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.SubscribeAlarmsAsync(refs, CancellationToken.None);
await drv.AlarmProjection.PauseAsync();
return drv;
}
// ---- the transition contract ------------------------------------------------------------------------------
[Fact]
public async Task Raise_then_unchanged_then_clear_carries_RawPath_and_Kind()
{
var (drv, factory) = NewDriver(Tag(ServoPath, "ALARM:Servo"));
await InitAndSubscribeForTicksAsync(drv, factory, ServoPath);
var events = Capture(drv);
Tick(drv, Servo401());
Tick(drv, Servo401());
Tick(drv);
events.Select(e => e.Kind).ShouldBe([AlarmTransitionKind.Raise, AlarmTransitionKind.Clear]);
events.ShouldAllBe(e => e.ConditionId == ServoPath && e.SourceNodeId == Host);
events[0].AlarmType.ShouldBe("Servo");
events[0].Message.ShouldBe("401 SERVO V-READY OFF (type 4 Servo, axis 1)");
}
[Fact]
public async Task Overlapping_selectors_all_fire_and_OTHER_only_takes_what_no_type_selector_claims()
{
var (drv, factory) = NewDriver(Tag(ServoPath, "ALARM:Servo"), Tag(AnyPath, "ALARM:*"), Tag(OtherPath, "ALARM:OTHER"));
await InitAndSubscribeForTicksAsync(drv, factory, ServoPath, AnyPath, OtherPath);
var events = Capture(drv);
Tick(drv, Servo401());
events.Select(e => e.ConditionId).Order().ToArray().ShouldBe(new[] { AnyPath, ServoPath },
customMessage: "the servo alarm drives both ALARM:Servo and ALARM:*; ALARM:* does not claim it away from OTHER, but ALARM:Servo does");
events.Clear();
Tick(drv, Servo401(), Overheat700);
events.ShouldContain(e => e.ConditionId == OtherPath && e.Kind == AlarmTransitionKind.Raise,
"an overheat alarm no type selector claims lands in OTHER");
events.ShouldContain(e => e.ConditionId == AnyPath && e.Kind == AlarmTransitionKind.Retrigger);
events.ShouldNotContain(e => e.ConditionId == ServoPath, "ALARM:Servo's matched set did not change");
}
[Fact]
public async Task A_new_key_retriggers_while_a_non_displayed_member_leaving_emits_nothing()
{
var (drv, factory) = NewDriver(Tag(AnyPath, "ALARM:*"));
await InitAndSubscribeForTicksAsync(drv, factory, AnyPath);
var events = Capture(drv);
Tick(drv, Servo401()); // Raise — Servo is the winner (Critical)
Tick(drv, Servo401(), Overheat700); // Retrigger — a key the operator has not seen
Tick(drv, Servo401()); // the non-displayed Overheat left: nothing
events.Select(e => e.Kind).ShouldBe([AlarmTransitionKind.Raise, AlarmTransitionKind.Retrigger]);
events[1].Message.ShouldEndWith("[+1 more]");
events.Clear();
Tick(drv, Servo401(), Overheat700); // Overheat back: new to the set again ⇒ Retrigger
Tick(drv, Overheat700); // the DISPLAYED alarm left: retrigger with the next one down
events.Select(e => e.Kind).ShouldBe([AlarmTransitionKind.Retrigger, AlarmTransitionKind.Retrigger]);
events[1].AlarmType.ShouldBe("Overheat");
}
[Fact]
public async Task A_reordered_list_never_re_raises_and_the_displayed_alarm_is_the_most_severe()
{
var (drv, factory) = NewDriver(Tag(AnyPath, "ALARM:*"));
await InitAndSubscribeForTicksAsync(drv, factory, AnyPath);
var events = Capture(drv);
Tick(drv, Overheat700, Servo401(), Overtravel506);
Tick(drv, Overtravel506, Servo401(), Overheat700);
Tick(drv, Servo401(), Overheat700, Overtravel506);
events.Count.ShouldBe(1);
// Overtravel and Servo are both Critical; the tie goes to the lower type code (Overtravel = 2).
events[0].AlarmType.ShouldBe("Overtravel");
events[0].Message.ShouldStartWith("506 OVER TRAVEL +2 (type 2 Overtravel, axis 2)");
events[0].Message.ShouldEndWith("[+2 more]");
}
[Fact]
public async Task One_alarm_number_on_two_axes_is_one_alarm_listing_both_axes()
{
var (drv, factory) = NewDriver(Tag(ServoPath, "ALARM:Servo/401-453"));
await InitAndSubscribeForTicksAsync(drv, factory, ServoPath);
var events = Capture(drv);
Tick(drv, Servo401(axis: 2), Servo401(axis: 1));
Tick(drv, Servo401(axis: 1)); // the same key on fewer axes is the same alarm: nothing
events.Count.ShouldBe(1);
events[0].Message.ShouldBe("401 SERVO V-READY OFF (type 4 Servo, axes 1, 2)");
}
[Fact]
public async Task Severity_is_the_authored_value_when_present_else_the_type_bucket()
{
var (drv, factory) = NewDriver(Tag(ServoPath, "ALARM:Servo", severity: 350), Tag(AnyPath, "ALARM:*"));
await InitAndSubscribeForTicksAsync(drv, factory, ServoPath, AnyPath);
var events = Capture(drv);
Tick(drv, Servo401());
var authored = events.Single(e => e.ConditionId == ServoPath);
authored.OpcUaSeverity.ShouldBe(350);
authored.Severity.ShouldBe(AlarmSeverity.Low);
var derived = events.Single(e => e.ConditionId == AnyPath);
derived.OpcUaSeverity.ShouldBeNull();
derived.Severity.ShouldBe(AlarmSeverity.Critical);
FocasAlarmConditionMapper.TypeSeverity(FocasAlarmType.Parameter).ShouldBe(AlarmSeverity.Medium);
FocasAlarmConditionMapper.TypeSeverity(FocasAlarmType.MacroAlarm).ShouldBe(AlarmSeverity.Medium);
FocasAlarmConditionMapper.TypeSeverity(FocasAlarmType.Overheat).ShouldBe(AlarmSeverity.High);
FocasAlarmConditionMapper.TypeSeverity(42).ShouldBe(AlarmSeverity.High, "an unknown code is High, not ignored");
}
[Fact]
public async Task An_unnamed_type_code_is_shown_raw_so_it_can_be_selected()
{
var (drv, factory) = NewDriver(Tag(AnyPath, "ALARM:*"), Tag(ServoPath, "ALARM:T9"));
await InitAndSubscribeForTicksAsync(drv, factory, AnyPath, ServoPath);
var events = Capture(drv);
Tick(drv, new FocasActiveAlarm(750, 9, 0, "SPINDLE ALARM"));
events.Count.ShouldBe(2);
events.ShouldAllBe(e => e.AlarmType == "Type9" && e.Message == "750 SPINDLE ALARM (type 9)");
}
[Fact]
public async Task A_superseded_subscription_emits_nothing()
{
var (drv, factory) = NewDriver(Tag(ServoPath, "ALARM:Servo"));
await InitAndSubscribeForTicksAsync(drv, factory, ServoPath);
var stale = drv.AlarmProjection.Current!;
await drv.SubscribeAlarmsAsync([ServoPath], CancellationToken.None);
await drv.AlarmProjection.PauseAsync();
var events = Capture(drv);
drv.AlarmProjection.Tick(stale, Host, [Servo401()], drv.AlarmTags);
events.ShouldBeEmpty("a retired loop finishing its last read must not deliver");
}
// ---- the told record --------------------------------------------------------------------------------------
[Fact]
public async Task Nothing_is_recorded_while_no_handler_is_attached()
{
var (drv, factory) = NewDriver(Tag(ServoPath, "ALARM:Servo"));
await InitAndSubscribeForTicksAsync(drv, factory, ServoPath);
Tick(drv, Servo401()); // nobody listening (the host detached while reconnecting)
var events = Capture(drv);
Tick(drv, Servo401());
events.Select(e => e.Kind).ShouldBe([AlarmTransitionKind.Raise],
"the raise that happened while detached is delivered once a handler is back");
}
[Fact]
public async Task A_same_set_resubscribe_re_raises_nothing_and_a_changed_set_prunes_only_the_leavers()
{
var (drv, factory) = NewDriver(Tag(ServoPath, "ALARM:Servo"), Tag(AnyPath, "ALARM:*"));
await InitAndSubscribeForTicksAsync(drv, factory, ServoPath, AnyPath);
var events = Capture(drv);
Tick(drv, Servo401());
events.Count.ShouldBe(2);
// A reconnect: the actor re-subscribes the SAME set.
events.Clear();
await drv.SubscribeAlarmsAsync([ServoPath, AnyPath], CancellationToken.None);
await drv.AlarmProjection.PauseAsync();
Tick(drv, Servo401());
events.ShouldBeEmpty("re-raising an unchanged alarm would un-acknowledge it");
// A deploy drops AnyPath, then a later one brings it back: only AnyPath is told again.
await drv.SubscribeAlarmsAsync([ServoPath], CancellationToken.None);
await drv.SubscribeAlarmsAsync([ServoPath, AnyPath], CancellationToken.None);
await drv.AlarmProjection.PauseAsync();
Tick(drv, Servo401());
events.Select(e => e.ConditionId).ShouldBe([AnyPath]);
events[0].Kind.ShouldBe(AlarmTransitionKind.Raise);
}
// ---- the poll loop, end to end over the fake client -------------------------------------------------------
/// <summary>
/// Gitea #647 defect 4 — a failed read keeps state. Pincer: a throwing read must NOT clear, and (the
/// falsification control) a genuinely empty read MUST — so the first half cannot pass vacuously.
/// </summary>
[Fact]
public async Task Resubscribe_collapses_to_single_loop_no_duplicate_raise()
public async Task A_failed_read_keeps_the_condition_active_while_an_empty_read_clears_it()
{
var (drv, factory) = NewDriver(alarmsEnabled: true);
factory.Customise = () => new FakeFocasClient();
var (drv, factory) = NewDriver(Tag(ServoPath, "ALARM:Servo"));
await drv.InitializeAsync("{}", CancellationToken.None);
// Transitions only: the outage's quality-only BadNoCommunication annotation is pinned separately.
var events = Capture(drv, transitionsOnly: true);
await drv.SubscribeAlarmsAsync([ServoPath], CancellationToken.None);
await WaitFor(() => factory.Clients.Count > 0);
var client = ClientOf(factory);
client.SetAlarms(Servo401());
await WaitFor(() => Count(events) == 1);
client.ThrowOnReadAlarms = true;
var readsAtFailure = client.AlarmReadCount;
await WaitFor(() => client.AlarmReadCount >= readsAtFailure + 5);
Count(events).ShouldBe(1, "a failed read is not an empty list");
client.ThrowOnReadAlarms = false;
client.SetAlarms();
await WaitFor(() => Count(events) == 2);
lock (events) events[1].Kind.ShouldBe(AlarmTransitionKind.Clear);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task A_reinitialise_neither_re_raises_nor_loses_the_subscription()
{
var (drv, factory) = NewDriver(Tag(ServoPath, "ALARM:Servo"));
await drv.InitializeAsync("{}", CancellationToken.None);
var events = Capture(drv);
await drv.SubscribeAlarmsAsync([ServoPath], CancellationToken.None);
await WaitFor(() => factory.Clients.Count > 0);
factory.Clients[0].SetAlarms(Servo401());
await WaitFor(() => Count(events) == 1);
// A re-initialise without teardown (#630) builds a fresh client; the CNC still reports the alarm.
factory.Customise = () =>
{
var c = new FakeFocasClient();
c.SetAlarms(Servo401());
return c;
};
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitFor(() => factory.Clients.Count > 1 && factory.Clients[^1].AlarmReadCount >= 3);
Count(events).ShouldBe(1, "the server was already told; a re-raise would un-acknowledge it");
factory.Clients[^1].SetAlarms();
await WaitFor(() => Count(events) == 2);
lock (events) events[1].Kind.ShouldBe(AlarmTransitionKind.Clear, "the poll resumed after the re-initialise");
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Only_devices_that_own_a_subscribed_alarm_tag_are_polled()
{
var (drv, factory) = NewDriver(
[new FocasDeviceOptions(Host, DeviceName: "m1"), new FocasDeviceOptions(Host2, DeviceName: "m2")],
Tag(ServoPath, "ALARM:Servo"));
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.SubscribeAlarmsAsync([ServoPath], CancellationToken.None);
await WaitFor(() => factory.Clients.Count > 0 && factory.Clients[0].AlarmReadCount >= 3);
factory.Clients.Count.ShouldBe(1, "the second device owns no alarm tag, so it is never connected for alarms");
drv.GetDeviceState(Host2)!.Client.ShouldBeNull();
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Subscribing_with_the_projection_flag_off_does_not_throw()
{
var (drv, _) = NewDriver(Tag(ServoPath, "ALARM:Servo"));
await drv.InitializeAsync("{}", CancellationToken.None);
var raises = new List<AlarmEventArgs>();
drv.OnAlarmEvent += (_, e) =>
{
// Count only the raise (the initial 0->1) — clears wrap the message in "(cleared)".
if (!e.Message.Contains("cleared")) lock (raises) raises.Add(e);
};
var handle = await drv.SubscribeAlarmsAsync([ServoPath], CancellationToken.None);
// First subscribe creates + connects the per-device client and starts polling. Wait for
// it + a couple empty-list ticks so the loop's LastByDevice baseline is seeded empty.
var sub1 = await drv.SubscribeAlarmsAsync([], CancellationToken.None);
await WaitFor(() => factory.Clients.Count > 0, TimeSpan.FromSeconds(3));
await Task.Delay(120);
// Second subscribe = the actor re-subscribing across a reconnect. Its loop reuses the same
// connected DeviceState.Client (EnsureConnectedAsync), so it polls the same alarm list.
var sub2 = await drv.SubscribeAlarmsAsync([], CancellationToken.None);
await Task.Delay(120); // let sub2's loop seed its own empty LastByDevice baseline
// One device raise transition. Add to every created client so that whichever instance the
// live loop(s) poll, the raise is observed (EnsureConnectedAsync may have churned clients).
foreach (var c in factory.Clients)
c.Alarms.Add(new FocasActiveAlarm(500, FocasAlarmType.Overtravel, 1, "Axis 1 overtravel"));
// Wait until at least one raise is observed, then give any leaked second loop ample time
// (well past several 30ms poll intervals) to ALSO fire its duplicate before we assert.
await WaitFor(() => { lock (raises) return raises.Count > 0; }, TimeSpan.FromSeconds(3));
await Task.Delay(200);
await drv.UnsubscribeAlarmsAsync(sub1, CancellationToken.None);
await drv.UnsubscribeAlarmsAsync(sub2, CancellationToken.None);
handle.ShouldNotBeNull();
await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None);
await drv.ShutdownAsync(CancellationToken.None);
}
lock (raises)
// ---- authoring at init ------------------------------------------------------------------------------------
[Fact]
public async Task Alarm_tags_are_conditions_not_values_and_half_authored_ones_are_skipped_and_reported()
{
var noObject = new RawTagEntry("Plant/Cnc/m1/noobj",
JsonSerializer.Serialize(new { address = "ALARM:Servo", dataType = "Int32" }), false, "m1");
var pmcWithObject = new RawTagEntry("Plant/Cnc/m1/pmc",
JsonSerializer.Serialize(new { address = "R100", dataType = "Byte", alarm = new { severity = 700 } }), false, "m1");
var (drv, _) = NewDriver(Tag(ServoPath, "ALARM:Servo/401", severity: 800), noObject, pmcWithObject);
await drv.InitializeAsync("{}", CancellationToken.None);
drv.AlarmTags.Keys.ShouldBe([ServoPath]);
drv.AlarmTags[ServoPath].AuthoredSeverity.ShouldBe(800);
drv.AlarmTags[ServoPath].Selector.Canonical.ShouldBe("Servo/401");
var skipped = drv.GetSkippedTags()!;
skipped.Sample.Select(s => s.RawPath).Order().ToArray().ShouldBe(new[] { "Plant/Cnc/m1/noobj", "Plant/Cnc/m1/pmc" });
var read = await drv.ReadAsync([ServoPath], CancellationToken.None);
read[0].StatusCode.ShouldBe(FocasStatusMapper.BadNotSupported);
var write = await drv.WriteAsync([new WriteRequest(ServoPath, 1)], CancellationToken.None);
write[0].StatusCode.ShouldBe(FocasStatusMapper.BadNotWritable);
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>
/// A re-initialise without teardown (#630) adopting a config that turns a value tag into an alarm tag, or
/// drops it, must not leave the old value-table entry serving reads.
/// </summary>
[Fact]
public async Task A_reinitialise_rebuilds_the_value_and_alarm_tables_from_scratch()
{
const string path = "Plant/Cnc/m1/t";
FocasDriverOptions Options(params RawTagEntry[] tags) => new()
{
raises.Count.ShouldBe(1,
"exactly one poll loop must survive a re-subscribe; a leaked loop fires a duplicate raise");
Devices = [new FocasDeviceOptions(Host, DeviceName: "m1")],
RawTags = [.. tags],
Probe = new FocasProbeOptions { Enabled = false },
};
var asValue = Options(new RawTagEntry(path, """{"address":"R100","dataType":"Byte"}""", false, "m1"));
var asAlarm = Options(Tag(path, "ALARM:*"));
var removed = Options();
var factory = new FakeFocasClientFactory();
var drv = new FocasDriver(asValue, "drv-1", factory,
rebind: json => new FocasDriverBinding(json switch
{
"""{"v":"alarm"}""" => asAlarm,
"""{"v":"removed"}""" => removed,
_ => asValue,
}, factory));
await drv.InitializeAsync("""{"v":"value"}""", CancellationToken.None);
(await drv.ReadAsync([path], CancellationToken.None))[0].StatusCode.ShouldBe(FocasStatusMapper.Good);
await drv.InitializeAsync("""{"v":"alarm"}""", CancellationToken.None);
drv.AlarmTags.Keys.ShouldBe([path]);
(await drv.ReadAsync([path], CancellationToken.None))[0].StatusCode.ShouldBe(FocasStatusMapper.BadNotSupported);
await drv.InitializeAsync("""{"v":"removed"}""", CancellationToken.None);
drv.AlarmTags.ShouldBeEmpty();
(await drv.ReadAsync([path], CancellationToken.None))[0].StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown,
"a tag the new config removed must not keep reading");
await drv.ShutdownAsync(CancellationToken.None);
}
// ---- Gitea #647 review MAJOR-2: an unreachable alarm source must say so ----------------------------------
private const uint BadNoCommunication = 0x80310000u;
private static void TickFailed(FocasDriver drv) =>
drv.AlarmProjection.TickFailed(drv.AlarmProjection.Current!, Host, drv.AlarmTags, new InvalidOperationException("down"));
/// <summary>
/// A failed read marks each subscribed condition BadNoCommunication ONCE per outage, quality-only (no Clear,
/// no state change); recovery lifts it to Good and, with the alarm unchanged, re-raises nothing.
/// </summary>
[Fact]
public async Task A_failed_read_annotates_quality_once_and_recovery_lifts_it_without_a_re_raise()
{
var (drv, factory) = NewDriver(Tag(ServoPath, "ALARM:Servo"), Tag(AnyPath, "ALARM:*"));
await InitAndSubscribeForTicksAsync(drv, factory, ServoPath, AnyPath);
var events = Capture(drv);
Tick(drv, Servo401());
events.Clear();
TickFailed(drv);
TickFailed(drv);
events.Count.ShouldBe(2, "one Bad per condition for the whole outage");
events.ShouldAllBe(e => e.IsQualityOnly && e.QualityStatusCode == BadNoCommunication);
events.Select(e => e.ConditionId).Order().ToArray().ShouldBe(new[] { AnyPath, ServoPath });
events.Clear();
Tick(drv, Servo401());
events.Count.ShouldBe(2);
events.ShouldAllBe(e => e.IsQualityOnly && e.QualityStatusCode == 0u, "recovery lifts the Bad and says nothing else");
}
[Fact]
public async Task A_recovery_that_finds_the_alarm_cleared_lifts_quality_then_clears()
{
var (drv, factory) = NewDriver(Tag(ServoPath, "ALARM:Servo"));
await InitAndSubscribeForTicksAsync(drv, factory, ServoPath);
var events = Capture(drv);
Tick(drv, Servo401());
TickFailed(drv);
events.Clear();
Tick(drv);
events.Select(e => (e.Kind, e.QualityStatusCode)).ToArray().ShouldBe(new (AlarmTransitionKind, uint?)[]
{
(AlarmTransitionKind.Unspecified, 0u),
(AlarmTransitionKind.Clear, null),
});
}
[Fact]
public async Task A_persistently_failing_alarm_read_warns_once_per_outage()
{
var logger = new ListLogger();
var factory = new FakeFocasClientFactory();
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host, DeviceName: "m1")],
RawTags = [Tag(ServoPath, "ALARM:Servo")],
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-1", factory, logger);
await InitAndSubscribeForTicksAsync(drv, factory, ServoPath);
Capture(drv);
var before = logger.Warnings();
TickFailed(drv);
TickFailed(drv);
TickFailed(drv);
logger.Warnings().ShouldBe(before + 1);
Tick(drv); // recovery ends the episode…
TickFailed(drv); // …so the next outage warns again
logger.Warnings().ShouldBe(before + 2);
}
/// <summary>
/// An alarm-only driver must be able to trip the comms-loss watchdog (#527): sustained alarm-read failure
/// goes non-Healthy with LastSuccessfulRead FROZEN; a successful alarm read advances it.
/// </summary>
[Fact]
public async Task Sustained_alarm_read_failure_degrades_health_with_LastSuccessfulRead_frozen()
{
var (drv, factory) = NewDriver(Tag(ServoPath, "ALARM:Servo"));
factory.Customise = () => new FakeFocasClient { ThrowOnReadAlarms = true };
await drv.InitializeAsync("{}", CancellationToken.None);
var atInit = drv.GetHealth().LastSuccessfulRead;
await drv.SubscribeAlarmsAsync([ServoPath], CancellationToken.None);
await WaitFor(() => factory.Clients.Count > 0 && factory.Clients[0].AlarmReadCount >= 3);
var failing = drv.GetHealth();
failing.State.ShouldBe(DriverState.Degraded);
failing.LastSuccessfulRead.ShouldBe(atInit, "a failed read never advances LastSuccessfulRead");
factory.Clients[0].ThrowOnReadAlarms = false;
await WaitFor(() => drv.GetHealth().State == DriverState.Healthy);
drv.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThan(atInit!.Value);
await drv.ShutdownAsync(CancellationToken.None);
}
private sealed class ListLogger : Microsoft.Extensions.Logging.ILogger<FocasDriver>
{
private int _warnings;
public int Warnings() => Volatile.Read(ref _warnings);
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel) => true;
public void Log<TState>(Microsoft.Extensions.Logging.LogLevel logLevel, Microsoft.Extensions.Logging.EventId eventId,
TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
if (logLevel == Microsoft.Extensions.Logging.LogLevel.Warning
&& formatter(state, exception).Contains("cnc_rdalmmsg2", StringComparison.Ordinal))
Interlocked.Increment(ref _warnings);
}
}
/// <summary>Verifies that tick diffs raises and clears without polling loop.</summary>
// ---- Gitea #647 re-review R1–R3 -------------------------------------------------------------------------
/// <summary>
/// R1 — the host treats a respawned driver's Raise as a re-statement only when its message matches what the
/// condition shows, so the message must be a deterministic function of the matched SET: two fresh driver
/// instances reading the same alarms in a different wire (and axis) order say exactly the same thing, and a
/// different set says something different.
/// </summary>
[Fact]
public async Task Tick_diffs_raises_and_clears_without_polling_loop()
public async Task The_condition_message_is_deterministic_for_the_same_alarm_set_across_driver_instances()
{
// Drive Tick directly so the test isn't timing-dependent. The projection's
// Tick() is internal so we reach it through the driver using a handcrafted
// subscription — simpler than standing up the full loop.
var (drv, factory) = NewDriver(alarmsEnabled: true);
factory.Customise = () => new FakeFocasClient();
async Task<string> FirstRaiseMessage(params FocasActiveAlarm[] alarms)
{
var (drv, factory) = NewDriver(Tag(AnyPath, "ALARM:*"));
await InitAndSubscribeForTicksAsync(drv, factory, AnyPath);
var events = Capture(drv);
Tick(drv, alarms);
return events.Single().Message;
}
var a = await FirstRaiseMessage(Servo401(axis: 2), Overheat700, Servo401(axis: 1), Overtravel506);
var b = await FirstRaiseMessage(Overtravel506, Servo401(axis: 1), Overheat700, Servo401(axis: 2));
b.ShouldBe(a);
(await FirstRaiseMessage(Overtravel506, Servo401(axis: 1))).ShouldNotBe(a, "a different set must not read as a re-statement");
}
private static Wire.FocasWireException CncRefusal() => new("cnc_rdalmmsg2 refused EW_FUNC", rc: 1);
/// <summary>
/// R2 — a CNC REFUSAL of cnc_rdalmmsg2 (an rc; the transport is fine) is a capability answer, not comms loss:
/// health stays Healthy (so the #527 watchdog can never reconnect-loop a production control), the conditions
/// are annotated BadNotSupported once, one Warning is logged, and polling continues at the normal cadence.
/// Falsification control: a TRANSPORT failure on the same driver does degrade.
/// </summary>
[Fact]
public async Task A_CNC_refusal_marks_conditions_not_supported_without_degrading_health()
{
var logger = new ListLogger();
var factory = new FakeFocasClientFactory { Customise = () => new FakeFocasClient { AlarmReadException = CncRefusal() } };
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host, DeviceName: "m1")],
RawTags = [Tag(ServoPath, "ALARM:Servo")],
Probe = new FocasProbeOptions { Enabled = false },
AlarmProjection = new FocasAlarmProjectionOptions { PollInterval = TimeSpan.FromMilliseconds(20) },
}, "drv-1", factory, logger);
await drv.InitializeAsync("{}", CancellationToken.None);
var events = Capture(drv);
await drv.SubscribeAlarmsAsync([ServoPath], CancellationToken.None);
await WaitFor(() => factory.Clients.Count > 0 && factory.Clients[0].AlarmReadCount >= 5);
drv.GetHealth().State.ShouldBe(DriverState.Healthy, "a refusal is not a comms fault");
lock (events)
{
events.Count.ShouldBe(1);
events[0].IsQualityOnly.ShouldBeTrue();
events[0].QualityStatusCode.ShouldBe(FocasStatusMapper.BadNotSupported);
}
logger.Warnings().ShouldBe(1);
factory.Clients.Count.ShouldBe(1, "a refusal does not tear the session down");
// Control: the same driver losing its transport DOES degrade, and re-annotates the condition.
factory.Clients[0].AlarmReadException = null;
factory.Clients[0].ThrowOnReadAlarms = true;
await WaitFor(() => drv.GetHealth().State == DriverState.Degraded);
lock (events) events[^1].QualityStatusCode.ShouldBe(BadNoCommunication);
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>
/// R3 — on a mixed driver health is the WORST of the value path and the alarm path, computed together: a
/// failing alarm read keeps the driver Degraded however many value reads succeed (no per-poll flip on
/// /hosts), and a successful alarm read cannot mask a value-path Degraded.
/// </summary>
[Fact]
public async Task Health_is_the_worst_of_the_value_and_alarm_paths()
{
const string valuePath = "Plant/Cnc/m1/r100";
var (drv, factory) = NewDriver(
new RawTagEntry(valuePath, """{"address":"R100","dataType":"Byte"}""", false, "m1"),
Tag(ServoPath, "ALARM:Servo"));
await drv.InitializeAsync("{}", CancellationToken.None);
var projection = new FocasAlarmProjection(drv, TimeSpan.FromMinutes(1));
var sub = new FocasAlarmProjection.Subscription(
new FocasAlarmSubscriptionHandle(1), deviceFilter: null,
new CancellationTokenSource());
// Value reads good, alarm path failing ⇒ Degraded, and it stays Degraded across further good value reads.
(await drv.ReadAsync([valuePath], CancellationToken.None))[0].StatusCode.ShouldBe(FocasStatusMapper.Good);
drv.NoteAlarmReadFailed(Host, new InvalidOperationException("socket closed"));
for (var i = 0; i < 3; i++)
{
await drv.ReadAsync([valuePath], CancellationToken.None);
drv.GetHealth().State.ShouldBe(DriverState.Degraded, "a good value read must not mask the failing alarm path");
}
drv.GetHealth().LastSuccessfulRead.ShouldNotBeNull("value reads still succeed, so the watchdog is not tripped");
var events = new List<AlarmEventArgs>();
drv.OnAlarmEvent += (_, e) => events.Add(e);
// Alarm path recovers ⇒ Healthy again.
drv.NoteAlarmReadSucceeded(Host);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
// Tick 1 — raise two alarms.
projection.Tick(sub, Host, [
new FocasActiveAlarm(100, FocasAlarmType.Parameter, 0, "Param 100"),
new FocasActiveAlarm(200, FocasAlarmType.Servo, 1, "Servo 200"),
]);
events.Count.ShouldBe(2);
events[0].Severity.ShouldBe(AlarmSeverity.Medium);
events[1].Severity.ShouldBe(AlarmSeverity.Critical);
// Tick 2 — same alarms stay active → no new events.
events.Clear();
projection.Tick(sub, Host, [
new FocasActiveAlarm(100, FocasAlarmType.Parameter, 0, "Param 100"),
new FocasActiveAlarm(200, FocasAlarmType.Servo, 1, "Servo 200"),
]);
events.ShouldBeEmpty();
// Tick 3 — one clears, one stays → one "cleared" event only.
projection.Tick(sub, Host, [
new FocasActiveAlarm(200, FocasAlarmType.Servo, 1, "Servo 200"),
]);
events.Count.ShouldBe(1);
events[0].Message.ShouldEndWith("(cleared)");
events[0].AlarmType.ShouldBe("Parameter");
// Value path Degraded, alarm path good ⇒ Degraded: an alarm success cannot mask it.
factory.Clients.Single().ReadStatuses["R100"] = FocasStatusMapper.BadOutOfRange;
await drv.ReadAsync([valuePath], CancellationToken.None);
drv.NoteAlarmReadSucceeded(Host);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>Verifies that severity mapping matches docs.</summary>
[Fact]
public void Severity_mapping_matches_docs()
private static int Count(List<AlarmEventArgs> events)
{
FocasAlarmProjection.MapSeverity(FocasAlarmType.Overtravel).ShouldBe(AlarmSeverity.Critical);
FocasAlarmProjection.MapSeverity(FocasAlarmType.Servo).ShouldBe(AlarmSeverity.Critical);
FocasAlarmProjection.MapSeverity(FocasAlarmType.PulseCode).ShouldBe(AlarmSeverity.Critical);
FocasAlarmProjection.MapSeverity(FocasAlarmType.Parameter).ShouldBe(AlarmSeverity.Medium);
FocasAlarmProjection.MapSeverity(FocasAlarmType.MacroAlarm).ShouldBe(AlarmSeverity.Medium);
FocasAlarmProjection.MapSeverity(FocasAlarmType.Overheat).ShouldBe(AlarmSeverity.High);
lock (events) return events.Count;
}
private static async Task WaitFor(Func<bool> pred, TimeSpan timeout)
private static async Task WaitFor(Func<bool> pred, int timeoutMs = 3000)
{
var deadline = DateTime.UtcNow + timeout;
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
while (DateTime.UtcNow < deadline)
{
if (pred()) return;
await Task.Delay(30);
await Task.Delay(10);
}
pred().ShouldBeTrue("condition not reached within the timeout");
}
}
@@ -0,0 +1,160 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>Gitea #647 — the <c>ALARM:</c> address grammar, shared by the editor, the deploy gate and the driver.</summary>
[Trait("Category", "Unit")]
public sealed class FocasAlarmSelectorTests
{
[Theory]
[InlineData("ALARM:*", "ALARM:*")]
[InlineData("alarm:other", "ALARM:OTHER")]
[InlineData("ALARM:servo", "ALARM:Servo")]
[InlineData("ALARM:Servo/401", "ALARM:Servo/401")]
[InlineData("ALARM:SERVO/401-453", "ALARM:Servo/401-453")]
[InlineData("ALARM:Servo/401-401", "ALARM:Servo/401")]
[InlineData("ALARM:T9", "ALARM:T9")]
[InlineData("ALARM:t9/750-758", "ALARM:T9/750-758")]
[InlineData(" ALARM:MacroAlarm/3000 ", "ALARM:MacroAlarm/3000")]
public void Valid_selectors_parse_to_their_canonical_form(string address, string canonical)
{
var parsed = FocasAddress.TryParse(address);
parsed.ShouldNotBeNull();
parsed.Kind.ShouldBe(FocasAreaKind.Alarm);
parsed.Canonical.ShouldBe(canonical);
FocasAddress.TryParse(parsed.Canonical)!.Canonical.ShouldBe(canonical, "canonical must round-trip");
FocasAddress.DescribeAlarmAddressError(address).ShouldBeNull();
}
[Theory]
[InlineData("ALARM:")]
[InlineData("ALARM:Bogus")]
[InlineData("ALARM:Servo/")]
[InlineData("ALARM:Servo/x")]
[InlineData("ALARM:Servo/-5")]
[InlineData("ALARM:Servo/10-5")]
[InlineData("ALARM:Servo/1-")]
[InlineData("ALARM:T")]
[InlineData("ALARM:T-1")]
[InlineData("ALARM:T99999")]
[InlineData("ALARM:Servo /401")]
[InlineData("ALARM:*/401")]
public void Malformed_selectors_are_refused_with_a_reason(string address)
{
FocasAddress.TryParse(address).ShouldBeNull();
FocasAddress.DescribeAlarmAddressError(address).ShouldNotBeNullOrWhiteSpace();
}
/// <summary>"A" is a PMC letter — an ALARM: address must be owned by the alarm rule, never mis-read as PMC.</summary>
[Fact]
public void Alarm_prefix_is_not_read_as_a_PMC_A_address()
{
FocasAddress.TryParse("A10")!.Kind.ShouldBe(FocasAreaKind.Pmc);
FocasAddress.TryParse("ALARM:*")!.Kind.ShouldBe(FocasAreaKind.Alarm);
FocasAddress.DescribeAlarmAddressError("A10").ShouldBeNull("not an ALARM: address at all");
}
[Fact]
public void Type_selector_matches_on_type_and_inclusive_number_range()
{
var range = FocasAlarmSelector.TryParse("Servo/401-453")!;
range.Matches(FocasAlarmType.Servo, 401).ShouldBeTrue();
range.Matches(FocasAlarmType.Servo, 453).ShouldBeTrue();
range.Matches(FocasAlarmType.Servo, 454).ShouldBeFalse();
range.Matches(FocasAlarmType.Overtravel, 401).ShouldBeFalse("(type, number) is the identity");
var any = FocasAlarmSelector.TryParse("*")!;
any.Matches(FocasAlarmType.Overheat, 1).ShouldBeTrue();
// OTHER depends on the device's other selectors, so it never matches on its own terms.
FocasAlarmSelector.TryParse("OTHER")!.Matches(FocasAlarmType.Servo, 401).ShouldBeFalse();
// A raw code selects exactly that code — the series-independent form.
FocasAlarmSelector.TryParse("T4")!.Matches(FocasAlarmType.Servo, 1).ShouldBeTrue();
FocasAlarmSelector.TryParse("T9")!.Matches(FocasAlarmType.Servo, 1).ShouldBeFalse();
}
[Fact]
public void Every_named_type_resolves_through_the_one_table()
{
foreach (var name in FocasAlarmType.Names)
{
FocasAlarmType.TryResolve(name.ToUpperInvariant(), out var code).ShouldBeTrue(name);
FocasAlarmType.NameOf(code).ShouldBe(name);
}
FocasAlarmType.NameOf(9).ShouldBeNull("codes outside the 16i-era table are not invented");
}
[Fact]
public void Capability_matrix_accepts_an_alarm_address_on_every_series()
{
var address = FocasAddress.TryParse("ALARM:Servo/401")!;
foreach (var series in Enum.GetValues<FocasCncSeries>())
FocasCapabilityMatrix.Validate(series, address).ShouldBeNull(series.ToString());
}
// ---- the deploy-time inspector (the gate CSV import / API authoring meets) ----
[Fact]
public void Inspect_is_clean_for_an_alarm_selector_with_its_alarm_object()
{
FocasTagDefinitionFactory.Inspect("""{"address":"ALARM:Servo/401-453","dataType":"Int32","alarm":{"severity":800}}""")
.ShouldBeEmpty();
}
[Fact]
public void Inspect_advises_when_an_alarm_selector_has_no_alarm_object_or_an_alarm_object_has_no_selector()
{
FocasTagDefinitionFactory.Inspect("""{"address":"ALARM:*","dataType":"Int32"}""")
.ShouldHaveSingleItem().ShouldContain("no 'alarm' object");
FocasTagDefinitionFactory.Inspect("""{"address":"R100","dataType":"Byte","alarm":{}}""")
.ShouldHaveSingleItem().ShouldContain("not an ALARM: selector");
}
[Fact]
public void Inspect_names_the_selector_fault_for_a_malformed_alarm_address()
{
var warning = FocasTagDefinitionFactory.Inspect("""{"address":"ALARM:Servo/10-5","alarm":{}}""").ShouldHaveSingleItem();
warning.ShouldContain("reversed");
warning.ShouldContain(FocasAddress.DescribeAlarmAddressError("ALARM:Servo/10-5")!,
customMessage: "the gate and the editor must say the same thing about the same string");
}
[Fact]
public void The_mapper_reads_the_alarm_object_and_its_optional_integer_severity()
{
FocasTagDefinitionFactory.FromTagConfig("""{"address":"ALARM:*","alarm":{"severity":650}}""", "p", out var a).ShouldBeTrue();
a.Alarm.ShouldBe(new FocasTagAlarm(650));
FocasTagDefinitionFactory.FromTagConfig("""{"address":"ALARM:*","alarm":{"severity":"high"}}""", "p", out var b).ShouldBeTrue();
b.Alarm.ShouldBe(new FocasTagAlarm(null));
FocasTagDefinitionFactory.FromTagConfig("""{"address":"R100"}""", "p", out var c).ShouldBeTrue();
c.Alarm.ShouldBeNull();
}
/// <summary>
/// Gitea #647 review MINOR-5 — a non-empty alarm payload that fits neither record stride is malformed, not
/// "no alarms" (which would mass-clear every condition on the device). Empty stays empty.
/// </summary>
[Fact]
public void A_malformed_alarm_payload_throws_while_an_empty_one_is_no_alarms()
{
FocasWireClient.ParseAlarms([], 32).ShouldBeEmpty();
FocasWireClient.ParseAlarms(new byte[76], 32).Count.ShouldBe(1);
FocasWireClient.ParseAlarms(new byte[160], 32).Count.ShouldBe(2);
Should.Throw<FocasWireException>(() => FocasWireClient.ParseAlarms(new byte[77], 32));
Should.Throw<FocasWireException>(() => FocasWireClient.ParseAlarms(new byte[12], 32));
}
/// <summary>
/// Gitea #647 defect 4 — a read that FAILED must not look like "no alarms": the projection would diff it
/// to a mass clear and re-raise everything un-acknowledged on recovery. A disconnected wire client throws.
/// </summary>
[Fact]
public async Task Wire_client_ReadAlarms_throws_when_not_connected_rather_than_returning_empty()
{
using var client = new WireFocasClient();
await Should.ThrowAsync<InvalidOperationException>(() => client.ReadAlarmsAsync(CancellationToken.None));
}
}
@@ -58,9 +58,9 @@ public sealed class GatewayGalaxyAlarmFeedTests
observed.Count.ShouldBe(3);
// Active snapshot entry → Raise.
// Active (unacked) snapshot entry → Retrigger (#647 review: forces the upstream's unack on the host).
observed[0].AlarmFullReference.ShouldBe("Tank01.Level.HiHi");
observed[0].TransitionKind.ShouldBe(GalaxyAlarmTransitionKind.Raise);
observed[0].TransitionKind.ShouldBe(GalaxyAlarmTransitionKind.Retrigger);
observed[0].SeverityBucket.ShouldBe(AlarmSeverity.Critical);
observed[0].RawMxAccessSeverity.ShouldBe(750);
@@ -232,6 +232,21 @@ public sealed class OpcUaClientAlarmTests
OpcUaClientDriver.AlarmTransitions((priorActive, priorAcked), (active, acked)).ShouldBe(expected);
}
/// <summary>
/// Gitea #647 review — with no prior this driver told the host (first sight after start or a deploy respawn),
/// an unacknowledged upstream target goes out as Retrigger, never Raise: the host keeps an existing ack across
/// a Raise on an already-active condition, so only a Retrigger makes the host match the upstream's unack.
/// An acknowledged target keeps its Raise (a re-statement there is exactly right).
/// </summary>
[Theory]
[InlineData(true, false, new[] { AlarmTransitionKind.Retrigger })]
[InlineData(false, false, new[] { AlarmTransitionKind.Retrigger, AlarmTransitionKind.Clear })]
[InlineData(true, true, new[] { AlarmTransitionKind.Raise, AlarmTransitionKind.Acknowledge })]
public void An_unknown_prior_tells_an_unacked_upstream_with_Retrigger(bool active, bool acked, AlarmTransitionKind[] expected)
{
OpcUaClientDriver.AlarmTransitions((false, true), (active, acked), priorKnown: false).ShouldBe(expected);
}
// ---- #619 re-review: the transition table must reach EVERY target from EVERY prior. ----
/// <summary>The host projector's semantics, restated: the only thing the driver's kinds act upon.</summary>
@@ -357,7 +372,9 @@ public sealed class OpcUaClientAlarmTests
drv.RouteAlarmEvent(ConditionFields(true, false), TripIndex, handle);
drv.RouteAlarmEvent(ConditionFields(true, true), TripIndex, handle);
seen.Select(e => e.Kind).ShouldBe([AlarmTransitionKind.Raise, AlarmTransitionKind.Acknowledge]);
// First sight of an UNACKED upstream condition is a Retrigger (#647 review: the host keeps an ack across a
// Raise on an already-active condition, and only the upstream knows it is unacknowledged).
seen.Select(e => e.Kind).ShouldBe([AlarmTransitionKind.Retrigger, AlarmTransitionKind.Acknowledge]);
drv.RouteAlarmEvent(ConditionFields(true, true), TripIndex, handle); // the refresh copy
seen.Count.ShouldBe(2);
@@ -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);
}
}
@@ -80,6 +80,10 @@ public sealed class FocasTagConfigModelTests
[InlineData("PARAM:1815/0")]
[InlineData("MACRO:500")]
[InlineData("FIXED:Identity/SeriesNumber")]
[InlineData("ALARM:*")]
[InlineData("ALARM:OTHER")]
[InlineData("ALARM:Servo/401-453")]
[InlineData("ALARM:T9")]
public void Validate_returns_null_for_every_address_form_the_driver_accepts(string address)
=> new FocasTagConfigModel { Address = address }.Validate().ShouldBeNull();
@@ -104,6 +108,25 @@ public sealed class FocasTagConfigModelTests
// The operator has to be able to act on it, so the message names the legal forms.
error.ShouldContain("PARAM:");
error.ShouldContain("FIXED:");
error.ShouldContain("ALARM:");
}
/// <summary>
/// Gitea #647 — a malformed ALARM: selector gets the selector's own reason, the same text the deploy-time
/// inspector gives for the same string (one authority: <c>FocasAddress.DescribeAlarmAddressError</c>).
/// </summary>
[Theory]
[InlineData("ALARM:Servo/10-5")]
[InlineData("ALARM:Bogus")]
[InlineData("ALARM:")]
public void Validate_rejects_a_malformed_alarm_selector_with_the_gate_s_reason(string address)
{
var error = new FocasTagConfigModel { Address = address }.Validate();
error.ShouldNotBeNull();
error.ShouldContain(FocasAddress.DescribeAlarmAddressError(address)!);
FocasTagDefinitionFactory.Inspect("{\"address\":\"" + address + "\",\"alarm\":{}}")
.ShouldContain(w => w.Contains(FocasAddress.DescribeAlarmAddressError(address)!));
}
[Fact]
@@ -256,7 +256,7 @@ public sealed class OpcUaClientDriverWireTests
/// <summary>
/// #619 — ConditionRefresh over the wire: a condition already active+unacked BEFORE the driver subscribes is
/// reported (Raise, keyed by the RawPath) right after subscribe, not only on its next transition. A second
/// reported (Retrigger since #647, keyed by the RawPath) right after subscribe, not only on its next transition. A second
/// subscribe of the same set refreshes again and must emit nothing (no re-raise); a positive control then
/// proves the feed is still live by acknowledging upstream and seeing exactly the Acknowledge arrive.
/// </summary>
@@ -291,9 +291,11 @@ public sealed class OpcUaClientDriverWireTests
AlarmEventArgs[] Seen() { lock (seen) return [.. seen]; }
var first = await drv.SubscribeAlarmsAsync([AlarmRef], ct);
await RecordedEvents.WaitUntilAsync(() => Seen().Any(e => e.Kind == AlarmTransitionKind.Raise));
// First sight of an active+UNACKED upstream condition is a Retrigger (#647 review): the host keeps an ack
// across a Raise on an already-active condition, so only a Retrigger forces the upstream's unack.
await RecordedEvents.WaitUntilAsync(() => Seen().Any(e => e.Kind == AlarmTransitionKind.Retrigger));
var raise = Seen().Single(e => e.Kind == AlarmTransitionKind.Raise);
var raise = Seen().Single(e => e.Kind == AlarmTransitionKind.Retrigger);
raise.ConditionId.ShouldBe(AlarmRef);
raise.Message.ShouldBe("already hot");
raise.OpcUaSeverity.ShouldBe(800);
@@ -105,7 +105,7 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
}
/// <summary>Gitea #641 (item 4) — routing is an EXACT match on the RawPath: a driver that decorates its
/// ConditionId (AbCip used to send <c>"{RawPath}#active"</c>, FOCAS sends <c>"{host}#{type}:{number}"</c>) has
/// ConditionId (AbCip used to send <c>"{RawPath}#active"</c>, FOCAS <c>"{host}#{type}:{number}"</c> before #647) has
/// every transition dropped, so a native source MUST report <c>ConditionId = RawPath</c>.</summary>
[Fact]
public void A_decorated_condition_id_is_dropped()
@@ -0,0 +1,316 @@
using System.Text.Json;
using Akka.Actor;
using Akka.Cluster.Tools.PublishSubscribe;
using Akka.TestKit;
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// Gitea #647 — a FOCAS CNC alarm reaches its Part 9 condition END TO END: the REAL <see cref="FocasDriver"/>
/// (over a fake CNC client), fed the raw tags exactly as the deployment artifact delivers them, raises into the
/// REAL <see cref="DriverHostActor"/>, whose <c>ForwardNativeAlarm</c> + <c>NativeAlarmProjector</c> must turn it
/// into an Active and then an inactive condition at the tag's RawPath.
/// </summary>
/// <remarks>
/// The lesson this exists for: OpcUaClient and AbCip native alarms were "routed" — events raised, unit tests
/// green — but never reached a condition, because nothing checked the event against the key the host actually
/// routes on (ConditionId == RawPath) or the field its projector actually reads (Kind). A test that stops at the
/// driver's event cannot see either.
/// </remarks>
public sealed class FocasNativeAlarmEndToEndTests : RuntimeActorTestBase
{
private static readonly NodeId TestNode = NodeId.Parse("focas-alarm-e2e");
private static readonly RevisionHash Rev = RevisionHash.Parse(new string('f', 64));
private const string Host = "focas://10.0.0.5:8193";
private const string DriverId = "drv-focas";
private const string ExpectedRawPath = "Plant/Cnc/m1/servo_alarms";
[Fact]
public async Task A_CNC_servo_alarm_activates_then_clears_the_condition_at_the_RawPath()
{
var db = NewInMemoryDbFactory();
var blob = BuildArtifact();
var deploymentId = Seed(db, blob, Rev);
var (host, publish, _) = SpawnHostAndApply(db, deploymentId);
// The raw tags exactly as the driver receives them: the artifact's merged DriverConfig RawTags array.
var rawTags = RawTagsFor(blob, DriverId);
rawTags.Select(t => t.RawPath).ShouldBe([ExpectedRawPath]);
var cnc = new AlarmOnlyFocasClient();
var driver = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host, DeviceName: "m1")],
RawTags = rawTags,
Probe = new FocasProbeOptions { Enabled = false },
AlarmProjection = new FocasAlarmProjectionOptions { PollInterval = TimeSpan.FromMilliseconds(20) },
}, DriverId, new SingleClientFactory(cnc));
driver.OnAlarmEvent += (_, e) => host.Tell(new DriverInstanceActor.AttributeAlarmPublished(DriverId, e));
await driver.InitializeAsync("{}", CancellationToken.None);
// The host's alarm subscription set is the alarm-bearing raw tags' RawPaths. Called directly, as the
// DriverInstanceActor would through its invoker: the routing is under test, not resilience wrapping.
#pragma warning disable OTOPCUA0001
await driver.SubscribeAlarmsAsync([.. rawTags.Select(t => t.RawPath)], CancellationToken.None);
#pragma warning restore OTOPCUA0001
try
{
cnc.SetAlarms(new FocasActiveAlarm(401, FocasAlarmType.Servo, 1, "SERVO V-READY OFF"));
var active = publish.FishForMessage<OpcUaPublishActor.AlarmStateUpdate>(_ => true, TimeSpan.FromSeconds(5));
active.AlarmNodeId.ShouldBe(ExpectedRawPath);
active.Realm.ShouldBe(AddressSpaceRealm.Raw);
active.State.Active.ShouldBeTrue("the Raise must project to an ACTIVE condition, not 'keep prior state'");
active.State.Acknowledged.ShouldBeFalse();
active.State.Severity.ShouldBe((ushort)800, "the authored alarm.severity is the condition's severity");
active.State.Message.ShouldStartWith("401 SERVO V-READY OFF");
cnc.SetAlarms();
var cleared = publish.FishForMessage<OpcUaPublishActor.AlarmStateUpdate>(_ => true, TimeSpan.FromSeconds(5));
cleared.AlarmNodeId.ShouldBe(ExpectedRawPath);
cleared.State.Active.ShouldBeFalse();
}
finally
{
await driver.ShutdownAsync(CancellationToken.None);
}
}
/// <summary>
/// Gitea #647 review (MAJOR-1) — a deploy that changes ANY of the driver's config respawns it, and the fresh
/// instance has no record of what the server was told. Its first poll re-reads the still-active alarm and
/// sends Raise; the host must treat that as a re-statement: the operator's acknowledgement survives and no
/// second "Activated" alerts row is written. Driven through the real host with a FRESH driver instance.
/// </summary>
/// <para>
/// Re-review R1 — the other half: a DIFFERENT alarm that appeared while the driver was down (here a second
/// servo alarm, so the condition's displayed set changed) is a new statement even though the fresh driver
/// can only say Raise. It must un-acknowledge and write its alerts row.
/// </para>
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task A_respawned_driver_re_reading_an_acknowledged_active_alarm_keeps_the_ack(bool newAlarmDuringRespawn)
{
var db = NewInMemoryDbFactory();
var blob = BuildArtifact();
var alerts = CreateTestProbe();
DistributedPubSub.Get(Sys).Mediator.Tell(new Subscribe(ScriptedAlarmHostActor.AlertsTopic, alerts.Ref), alerts.Ref);
alerts.ExpectMsg<SubscribeAck>(TimeSpan.FromSeconds(5));
var (host, publish, coordinator) = SpawnHostAndApply(db, Seed(db, blob, Rev));
var rawTags = RawTagsFor(blob, DriverId);
var cnc = new AlarmOnlyFocasClient();
cnc.SetAlarms(new FocasActiveAlarm(401, FocasAlarmType.Servo, 1, "SERVO V-READY OFF"));
var first = await StartDriverAsync(rawTags, cnc, host);
try
{
publish.FishForMessage<OpcUaPublishActor.AlarmStateUpdate>(_ => true, TimeSpan.FromSeconds(5))
.State.Acknowledged.ShouldBeFalse();
alerts.ExpectMsg<AlarmTransitionEvent>(TimeSpan.FromSeconds(5));
}
finally
{
await first.ShutdownAsync(CancellationToken.None);
}
// The operator acknowledges, then a deploy changes the driver's config => stop + respawn.
host.Tell(new DriverHostActor.RouteNativeAlarmAck(ExpectedRawPath, "seen", "op"));
var revB = RevisionHash.Parse(new string('e', 64));
host.Tell(new DispatchDeployment(Seed(db, blob, revB), revB, CorrelationId.NewId()));
coordinator.FishForMessage<ApplyAck>(a => a.Outcome == ApplyAckOutcome.Applied, TimeSpan.FromSeconds(5));
publish.FishForMessage<OpcUaPublishActor.RebuildAddressSpace>(_ => true, TimeSpan.FromSeconds(5));
if (newAlarmDuringRespawn)
cnc.SetAlarms(
new FocasActiveAlarm(401, FocasAlarmType.Servo, 1, "SERVO V-READY OFF"),
new FocasActiveAlarm(410, FocasAlarmType.Servo, 2, "SERVO EXCESS ERROR"));
var respawned = await StartDriverAsync(rawTags, cnc, host);
try
{
var restated = publish.FishForMessage<OpcUaPublishActor.AlarmStateUpdate>(_ => true, TimeSpan.FromSeconds(5));
restated.AlarmNodeId.ShouldBe(ExpectedRawPath);
restated.State.Active.ShouldBeTrue();
if (newAlarmDuringRespawn)
{
restated.State.Acknowledged.ShouldBeFalse("an alarm the operator has not seen must not inherit the old ack");
alerts.ExpectMsg<AlarmTransitionEvent>(TimeSpan.FromSeconds(5));
}
else
{
restated.State.Acknowledged.ShouldBeTrue("a respawned driver's re-statement must not un-acknowledge");
alerts.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
}
finally
{
await respawned.ShutdownAsync(CancellationToken.None);
}
}
private static async Task<FocasDriver> StartDriverAsync(List<RawTagEntry> rawTags, IFocasClient cnc, IActorRef host)
{
var driver = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host, DeviceName: "m1")],
RawTags = rawTags,
Probe = new FocasProbeOptions { Enabled = false },
AlarmProjection = new FocasAlarmProjectionOptions { PollInterval = TimeSpan.FromMilliseconds(20) },
}, DriverId, new SingleClientFactory(cnc));
driver.OnAlarmEvent += (_, e) => host.Tell(new DriverInstanceActor.AttributeAlarmPublished(DriverId, e));
await driver.InitializeAsync("{}", CancellationToken.None);
#pragma warning disable OTOPCUA0001 // the routing is under test, not resilience wrapping
await driver.SubscribeAlarmsAsync([.. rawTags.Select(t => t.RawPath)], CancellationToken.None);
#pragma warning restore OTOPCUA0001
return driver;
}
/// <summary>The seeded artifact's FOCAS driver raw tags, decoded through the production artifact reader.</summary>
private static List<RawTagEntry> RawTagsFor(byte[] blob, string driverInstanceId)
{
using var artifact = DeploymentArtifact.Open(blob);
var spec = artifact.ParseDriverInstances().Single(s => s.DriverInstanceId == driverInstanceId);
using var doc = JsonDocument.Parse(spec.DriverConfig);
return JsonSerializer.Deserialize<List<RawTagEntry>>(
doc.RootElement.GetProperty("RawTags").GetRawText(),
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
}
private static byte[] BuildArtifact() => JsonSerializer.SerializeToUtf8Bytes(new
{
RawFolders = new[] { new { RawFolderId = "rf-plant", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } },
DriverInstances = new[]
{
// Enabled=false: the host must not spawn a real wire driver — the test drives its own instance.
new { DriverInstanceId = DriverId, RawFolderId = "rf-plant", Name = "Cnc", DriverType = "FOCAS", DriverConfig = "{}", ClusterId = "c1", Enabled = false },
},
Devices = new[]
{
new { DeviceId = "dev-m1", DriverInstanceId = DriverId, Name = "m1", DeviceConfig = JsonSerializer.Serialize(new { HostAddress = Host }) },
},
TagGroups = Array.Empty<object>(),
Tags = new[]
{
new
{
TagId = "tag-servo",
DeviceId = "dev-m1",
TagGroupId = (string?)null,
Name = "servo_alarms",
DataType = "Int32",
AccessLevel = 0,
TagConfig = JsonSerializer.Serialize(new
{
address = "ALARM:Servo",
dataType = "Int32",
alarm = new { alarmType = "AlarmCondition", severity = 800 },
}),
},
},
});
private static DeploymentId Seed(IDbContextFactory<OtOpcUaConfigDbContext> db, byte[] blob, RevisionHash rev)
{
var id = DeploymentId.NewId();
using var ctx = db.CreateDbContext();
ctx.Deployments.Add(new Deployment
{
DeploymentId = id.Value,
RevisionHash = rev.Value,
Status = DeploymentStatus.Sealed,
CreatedBy = "test",
SealedAtUtc = DateTime.UtcNow,
ArtifactBlob = blob,
});
ctx.SaveChanges();
return id;
}
private (IActorRef Actor, TestProbe Publish, TestProbe Coordinator) SpawnHostAndApply(
IDbContextFactory<OtOpcUaConfigDbContext> db, DeploymentId deploymentId)
{
var coordinator = CreateTestProbe();
var publish = this.CreateCleanOutcomePublishProbe();
var actor = Sys.ActorOf(DriverHostActor.Props(
db, TestNode, coordinator.Ref,
localRoles: new HashSet<string> { "driver" },
dependencyMux: CreateTestProbe().Ref,
opcUaPublishActor: publish.Ref,
virtualTagEvaluator: NullVirtualTagEvaluator.Instance,
virtualTagHostOverride: CreateTestProbe().Ref));
actor.Tell(new DispatchDeployment(deploymentId, Rev, CorrelationId.NewId()));
coordinator.ExpectMsg<ApplyAck>(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied);
publish.ExpectMsg<OpcUaPublishActor.RebuildAddressSpace>(TimeSpan.FromSeconds(5));
return (actor, publish, coordinator);
}
private sealed class SingleClientFactory(IFocasClient client) : IFocasClientFactory
{
public IFocasClient Create() => client;
public void EnsureUsable() { }
}
/// <summary>A CNC that only answers the alarm list — everything else this test never reaches.</summary>
private sealed class AlarmOnlyFocasClient : IFocasClient
{
private readonly Lock _gate = new();
private FocasActiveAlarm[] _alarms = [];
public bool IsConnected { get; private set; }
public void SetAlarms(params FocasActiveAlarm[] alarms)
{
lock (_gate) _alarms = alarms;
}
public Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken cancellationToken)
{
IsConnected = true;
return Task.CompletedTask;
}
public Task<IReadOnlyList<FocasActiveAlarm>> ReadAlarmsAsync(CancellationToken cancellationToken)
{
lock (_gate) return Task.FromResult<IReadOnlyList<FocasActiveAlarm>>([.. _alarms]);
}
public Task<bool> ProbeAsync(CancellationToken cancellationToken) => Task.FromResult(true);
public void Dispose() => IsConnected = false;
public Task<(object? value, uint status)> ReadAsync(FocasAddress address, FocasDataType type, CancellationToken cancellationToken) => throw Unused();
public Task<uint> WriteAsync(FocasAddress address, FocasDataType type, object? value, CancellationToken cancellationToken) => throw Unused();
public Task<(IReadOnlyList<long>? slots, uint status)> ReadPmcSlotsAsync(string letter, FocasDataType slotType, int start, int end, CancellationToken cancellationToken) => throw Unused();
public Task<FocasSysInfo> GetSysInfoAsync(CancellationToken cancellationToken) => throw Unused();
public Task<IReadOnlyList<FocasAxisName>> GetAxisNamesAsync(CancellationToken cancellationToken) => throw Unused();
public Task<IReadOnlyList<FocasSpindleName>> GetSpindleNamesAsync(CancellationToken cancellationToken) => throw Unused();
public Task<FocasDynamicSnapshot> ReadDynamicAsync(int axisIndex, CancellationToken cancellationToken) => throw Unused();
public Task<FocasProgramInfo> GetProgramInfoAsync(CancellationToken cancellationToken) => throw Unused();
public Task<FocasStatus> GetStatusAsync(CancellationToken cancellationToken) => throw Unused();
public Task<FocasTimer> GetTimerAsync(FocasTimerKind kind, CancellationToken cancellationToken) => throw Unused();
public Task<IReadOnlyList<FocasServoLoad>> GetServoLoadsAsync(CancellationToken cancellationToken) => throw Unused();
public Task<IReadOnlyList<double?>> GetSpindleLoadsAsync(CancellationToken cancellationToken) => throw Unused();
public Task<IReadOnlyList<double?>> GetSpindleMotorSpeedsAsync(CancellationToken cancellationToken) => throw Unused();
private static NotSupportedException Unused() => new("not reached by the alarm end-to-end test");
}
}
@@ -224,6 +224,50 @@ public class NativeAlarmProjectorTests
snap.StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
}
/// <summary>
/// Gitea #647 review — a Raise on a condition that is ALREADY active is a re-statement (a respawned driver
/// re-reading a still-active alarm it never told the server about): it keeps the acknowledgement and is
/// flagged so the host writes no duplicate "Activated" row. Only a Retrigger (or inactive → active) un-acks.
/// </summary>
[Fact]
public void A_Raise_on_an_already_active_condition_is_a_restatement_that_keeps_the_ack()
{
var sut = new NativeAlarmProjector();
sut.Project("n1", Evt(AlarmTransitionKind.Raise), out var first).Active.ShouldBeTrue();
first.ShouldBeFalse("inactive → active is a real transition");
sut.MarkAcknowledged("n1");
var restated = sut.Project("n1", Evt(AlarmTransitionKind.Raise), out var restatement);
restatement.ShouldBeTrue();
restated.Active.ShouldBeTrue();
restated.Acknowledged.ShouldBeTrue("a re-statement must not un-acknowledge");
var retriggered = sut.Project("n1", Evt(AlarmTransitionKind.Retrigger), out var retrigger);
retrigger.ShouldBeFalse();
retriggered.Acknowledged.ShouldBeFalse("a Retrigger is a new statement and un-acks");
}
/// <summary>
/// Gitea #647 re-review R1 — a respawned driver cannot tell "the same alarm" from "a different one that
/// appeared while it was down"; its Raise is a re-statement only when its message matches what the condition
/// shows. A different message is a new statement: it un-acks and is not flagged (so the alerts row is written).
/// </summary>
[Fact]
public void A_Raise_on_an_active_condition_with_a_different_message_is_a_new_statement()
{
var sut = new NativeAlarmProjector();
sut.Project("n1", Evt(AlarmTransitionKind.Raise, msg: "401 SERVO (type 4 Servo, axis 1)"));
sut.MarkAcknowledged("n1");
var same = sut.Project("n1", Evt(AlarmTransitionKind.Raise, msg: "401 SERVO (type 4 Servo, axis 1)"), out var r1);
r1.ShouldBeTrue();
same.Acknowledged.ShouldBeTrue();
var other = sut.Project("n1", Evt(AlarmTransitionKind.Raise, msg: "506 OVER TRAVEL (type 2 Overtravel, axis 2)"), out var r2);
r2.ShouldBeFalse("a different alarm is not a re-statement");
other.Acknowledged.ShouldBeFalse("the operator has not seen this alarm");
}
private static AlarmEventArgs Evt(
AlarmTransitionKind kind,
AlarmSeverity sev = AlarmSeverity.High,
@@ -31,6 +31,9 @@
<!-- WP7: the real Calculation driver, so the dependency-flow integration test drives values through
the real DependencyMuxActor → DependencyConsumerMuxAdapter → CalculationDriver path. -->
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Calculation\ZB.MOM.WW.OtOpcUa.Driver.Calculation.csproj"/>
<!-- Gitea #647: the real FOCAS driver, so the native-alarm end-to-end test proves a CNC alarm reaches the
host's Part 9 condition (ForwardNativeAlarm → NativeAlarmProjector), not merely that it was raised. -->
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.FOCAS\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
</ItemGroup>
</Project>