Auto: focas-f3a — cnc_rdalmhistry alarm-history extension
Adds FocasAlarmProjection with two modes (ActiveOnly default, ActivePlusHistory) that polls cnc_rdalmhistry on connect + on a configurable cadence (5 min default, HistoryDepth=100 capped at 250). Emits historic events via IAlarmSource with SourceTimestampUtc set from the CNC's reported timestamp; dedup keyed on (OccurrenceTime, AlarmNumber, AlarmType). Ships the ODBALMHIS packed-buffer decoder + encoder in Wire/FocasAlarmHistoryDecoder.cs and threads ReadAlarmHistoryAsync through IFocasClient (default no-op so existing transport variants stay back-compat). FocasDriver now implements IAlarmSource. 13 new unit tests cover: mode switch, dedup, distinct-timestamp emission, type-as-key behaviour, OccurrenceTime passthrough (not Now), HistoryDepth clamp/fallback, and decoder round-trip. All 341 FOCAS unit tests still pass. Docs: docs/drivers/FOCAS.md (new), docs/v2/focas-deployment.md (new), docs/v2/implementation/focas-wire-protocol.md (new), docs/v2/implementation/focas-simulator-plan.md (new), docs/drivers/FOCAS-Test-Fixture.md (alarm-history bullet appended). Closes #267
This commit is contained in:
@@ -107,6 +107,29 @@ internal class FakeFocasClient : IFocasClient
|
||||
return Task.FromResult<(byte[]?, uint)>((buf, FocasStatusMapper.Good));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Canned alarm-history payload returned to <see cref="ReadAlarmHistoryAsync"/>.
|
||||
/// Defaults to empty so tests that don't care about history get the back-compat
|
||||
/// no-op behaviour. Tests asserting <c>cnc_rdalmhistry</c> behaviour seed entries
|
||||
/// here (issue #267, plan PR F3-a).
|
||||
/// </summary>
|
||||
public List<FocasAlarmHistoryEntry> AlarmHistory { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Ordered log of <c>cnc_rdalmhistry</c>-shaped calls observed on this fake session
|
||||
/// (depth-per-call). Tests assert this length to verify the projection's poll
|
||||
/// cadence + that <c>HistoryDepth</c> got clamped to the wire correctly.
|
||||
/// </summary>
|
||||
public List<int> AlarmHistoryReadLog { get; } = new();
|
||||
|
||||
public virtual Task<IReadOnlyList<FocasAlarmHistoryEntry>> ReadAlarmHistoryAsync(
|
||||
int depth, CancellationToken ct)
|
||||
{
|
||||
AlarmHistoryReadLog.Add(depth);
|
||||
IReadOnlyList<FocasAlarmHistoryEntry> snap = AlarmHistory.ToList();
|
||||
return Task.FromResult(snap);
|
||||
}
|
||||
|
||||
public virtual void Dispose()
|
||||
{
|
||||
DisposeCount++;
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Issue #267 (plan PR F3-a) — coverage for the <c>cnc_rdalmhistry</c> alarm-history
|
||||
/// extension. Asserts mode switch, dedup, timestamp passthrough, depth clamp, and
|
||||
/// the back-compat ActiveOnly path.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasAlarmProjectionTests
|
||||
{
|
||||
private const string Device = "focas://10.0.0.5:8193";
|
||||
|
||||
private static FocasAlarmHistoryEntry Entry(
|
||||
DateTimeOffset when, int alarmNumber, int alarmType = 1, string msg = "Spindle overload")
|
||||
=> new(when, AxisNo: 1, AlarmType: alarmType, AlarmNumber: alarmNumber, Message: msg);
|
||||
|
||||
// ---- Mode switch -------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task ActiveOnly_Mode_Does_Not_Issue_History_Poll()
|
||||
{
|
||||
var factory = new FakeFocasClientFactory();
|
||||
var fake = new FakeFocasClient
|
||||
{
|
||||
AlarmHistory = { Entry(DateTimeOffset.UtcNow, 100) },
|
||||
};
|
||||
factory.Customise = () => fake;
|
||||
|
||||
var opts = new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Device)],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
AlarmProjection = new FocasAlarmProjectionOptions { Mode = FocasAlarmProjectionMode.ActiveOnly },
|
||||
};
|
||||
var drv = new FocasDriver(opts, "drv-active-only", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var emitted = new List<AlarmEventArgs>();
|
||||
drv.OnAlarmEvent += (_, args) => emitted.Add(args);
|
||||
|
||||
var handle = await drv.SubscribeAlarmsAsync(["AlarmRoot"], CancellationToken.None);
|
||||
handle.ShouldNotBeNull();
|
||||
handle.DiagnosticId.ShouldStartWith("focas-alarm-drv-active-only-");
|
||||
|
||||
// Give the projection a moment — if it were polling, the fake's log would tick.
|
||||
await Task.Delay(150);
|
||||
fake.AlarmHistoryReadLog.ShouldBeEmpty();
|
||||
emitted.ShouldBeEmpty();
|
||||
|
||||
await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ActivePlusHistory_Mode_Polls_On_Connect_And_Emits_Entries()
|
||||
{
|
||||
var fake = new FakeFocasClient();
|
||||
fake.AlarmHistory.Add(Entry(new DateTimeOffset(2025, 4, 1, 9, 30, 0, TimeSpan.Zero), 100));
|
||||
fake.AlarmHistory.Add(Entry(new DateTimeOffset(2025, 4, 1, 9, 31, 0, TimeSpan.Zero), 200, alarmType: 2));
|
||||
var factory = new FakeFocasClientFactory { Customise = () => fake };
|
||||
|
||||
var opts = OptionsWithHistory(historyDepth: 50, interval: TimeSpan.FromMinutes(5));
|
||||
var drv = new FocasDriver(opts, "drv-history", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var emitted = new List<AlarmEventArgs>();
|
||||
drv.OnAlarmEvent += (_, args) => emitted.Add(args);
|
||||
|
||||
await drv.SubscribeAlarmsAsync(["AlarmRoot"], CancellationToken.None);
|
||||
await WaitForAsync(() => emitted.Count >= 2);
|
||||
|
||||
emitted.Count.ShouldBe(2);
|
||||
emitted[0].SourceTimestampUtc.ShouldBe(new DateTime(2025, 4, 1, 9, 30, 0, DateTimeKind.Utc));
|
||||
emitted[1].SourceTimestampUtc.ShouldBe(new DateTime(2025, 4, 1, 9, 31, 0, DateTimeKind.Utc));
|
||||
fake.AlarmHistoryReadLog.Count.ShouldBeGreaterThanOrEqualTo(1);
|
||||
fake.AlarmHistoryReadLog[0].ShouldBe(50); // declared depth threaded through
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
// ---- Dedup -------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Same_Entry_Across_Two_Polls_Is_Emitted_Once()
|
||||
{
|
||||
var fake = new FakeFocasClient();
|
||||
var sameEntry = Entry(new DateTimeOffset(2025, 4, 1, 9, 30, 0, TimeSpan.Zero), 100);
|
||||
fake.AlarmHistory.Add(sameEntry);
|
||||
var factory = new FakeFocasClientFactory { Customise = () => fake };
|
||||
|
||||
var opts = OptionsWithHistory(historyDepth: 100, interval: TimeSpan.FromMilliseconds(50));
|
||||
var drv = new FocasDriver(opts, "drv-dedup", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var emitted = new List<AlarmEventArgs>();
|
||||
drv.OnAlarmEvent += (_, args) => emitted.Add(args);
|
||||
|
||||
await drv.SubscribeAlarmsAsync(["AlarmRoot"], CancellationToken.None);
|
||||
await WaitForAsync(() => fake.AlarmHistoryReadLog.Count >= 3);
|
||||
|
||||
emitted.Count.ShouldBe(1);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Distinct_Entries_With_Different_Timestamps_Each_Emit_Once()
|
||||
{
|
||||
var fake = new FakeFocasClient();
|
||||
// Tick 1 yields entry A.
|
||||
fake.AlarmHistory.Add(Entry(new DateTimeOffset(2025, 4, 1, 9, 0, 0, TimeSpan.Zero), 100));
|
||||
var factory = new FakeFocasClientFactory { Customise = () => fake };
|
||||
|
||||
var opts = OptionsWithHistory(historyDepth: 100, interval: TimeSpan.FromMilliseconds(50));
|
||||
var drv = new FocasDriver(opts, "drv-distinct", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var emitted = new List<AlarmEventArgs>();
|
||||
drv.OnAlarmEvent += (_, args) => emitted.Add(args);
|
||||
|
||||
await drv.SubscribeAlarmsAsync(["AlarmRoot"], CancellationToken.None);
|
||||
await WaitForAsync(() => emitted.Count >= 1);
|
||||
|
||||
// Now add a second entry at a different timestamp + wait for the next tick.
|
||||
fake.AlarmHistory.Add(Entry(new DateTimeOffset(2025, 4, 1, 9, 1, 0, TimeSpan.Zero), 200));
|
||||
await WaitForAsync(() => emitted.Count >= 2);
|
||||
|
||||
emitted.Count.ShouldBe(2);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Same_AlarmNumber_With_Different_Type_Both_Emit()
|
||||
{
|
||||
// The dedup key includes type — alarm #100 type=1 and alarm #100 type=2 are distinct.
|
||||
var fake = new FakeFocasClient();
|
||||
var ts = new DateTimeOffset(2025, 4, 1, 9, 0, 0, TimeSpan.Zero);
|
||||
fake.AlarmHistory.Add(Entry(ts, 100, alarmType: 1));
|
||||
fake.AlarmHistory.Add(Entry(ts, 100, alarmType: 2));
|
||||
var factory = new FakeFocasClientFactory { Customise = () => fake };
|
||||
|
||||
var opts = OptionsWithHistory(historyDepth: 100, interval: TimeSpan.FromMilliseconds(50));
|
||||
var drv = new FocasDriver(opts, "drv-type-key", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var emitted = new List<AlarmEventArgs>();
|
||||
drv.OnAlarmEvent += (_, args) => emitted.Add(args);
|
||||
|
||||
await drv.SubscribeAlarmsAsync(["AlarmRoot"], CancellationToken.None);
|
||||
await WaitForAsync(() => emitted.Count >= 2);
|
||||
|
||||
emitted.Select(e => e.AlarmType).ShouldContain("FOCAS_T1");
|
||||
emitted.Select(e => e.AlarmType).ShouldContain("FOCAS_T2");
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
// ---- Timestamp passthrough --------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task OccurrenceTime_Is_The_Wire_Timestamp_Not_Now()
|
||||
{
|
||||
var fake = new FakeFocasClient();
|
||||
var oldStamp = new DateTimeOffset(2024, 1, 15, 8, 5, 30, TimeSpan.Zero);
|
||||
fake.AlarmHistory.Add(Entry(oldStamp, 100));
|
||||
var factory = new FakeFocasClientFactory { Customise = () => fake };
|
||||
|
||||
var opts = OptionsWithHistory(historyDepth: 50, interval: TimeSpan.FromMinutes(5));
|
||||
var drv = new FocasDriver(opts, "drv-ts", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
AlarmEventArgs? captured = null;
|
||||
drv.OnAlarmEvent += (_, args) => captured = args;
|
||||
|
||||
await drv.SubscribeAlarmsAsync(["AlarmRoot"], CancellationToken.None);
|
||||
await WaitForAsync(() => captured is not null);
|
||||
|
||||
captured!.SourceTimestampUtc.ShouldBe(oldStamp.UtcDateTime);
|
||||
// Sanity — must not be "Now" (more than a year stale).
|
||||
(DateTime.UtcNow - captured.SourceTimestampUtc).ShouldBeGreaterThan(TimeSpan.FromDays(180));
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
// ---- HistoryDepth clamp -----------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void ResolveDepth_Clamps_To_MaxHistoryDepth()
|
||||
{
|
||||
FocasAlarmProjection.ResolveDepth(500).ShouldBe(FocasAlarmProjectionOptions.MaxHistoryDepth);
|
||||
FocasAlarmProjection.ResolveDepth(10_000).ShouldBe(FocasAlarmProjectionOptions.MaxHistoryDepth);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveDepth_Falls_Back_To_Default_When_NonPositive()
|
||||
{
|
||||
FocasAlarmProjection.ResolveDepth(0).ShouldBe(FocasAlarmProjectionOptions.DefaultHistoryDepth);
|
||||
FocasAlarmProjection.ResolveDepth(-1).ShouldBe(FocasAlarmProjectionOptions.DefaultHistoryDepth);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveDepth_Returns_User_Value_When_Within_Bounds()
|
||||
{
|
||||
FocasAlarmProjection.ResolveDepth(1).ShouldBe(1);
|
||||
FocasAlarmProjection.ResolveDepth(50).ShouldBe(50);
|
||||
FocasAlarmProjection.ResolveDepth(FocasAlarmProjectionOptions.MaxHistoryDepth)
|
||||
.ShouldBe(FocasAlarmProjectionOptions.MaxHistoryDepth);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task User_Depth_500_Clamps_To_250_On_The_Wire()
|
||||
{
|
||||
var fake = new FakeFocasClient();
|
||||
var factory = new FakeFocasClientFactory { Customise = () => fake };
|
||||
|
||||
var opts = OptionsWithHistory(historyDepth: 500, interval: TimeSpan.FromMinutes(5));
|
||||
var drv = new FocasDriver(opts, "drv-clamp", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.SubscribeAlarmsAsync(["AlarmRoot"], CancellationToken.None);
|
||||
await WaitForAsync(() => fake.AlarmHistoryReadLog.Count >= 1);
|
||||
|
||||
fake.AlarmHistoryReadLog[0].ShouldBe(FocasAlarmProjectionOptions.MaxHistoryDepth);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
// ---- Decoder + encoder round-trip -------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void AlarmHistoryDecoder_RoundTrips_Through_Encode_Decode()
|
||||
{
|
||||
var src = new List<FocasAlarmHistoryEntry>
|
||||
{
|
||||
new(new DateTimeOffset(2025, 4, 1, 9, 0, 0, TimeSpan.Zero), 1, 2, 100, "Spindle"),
|
||||
new(new DateTimeOffset(2025, 4, 1, 9, 5, 30, TimeSpan.Zero), 0, 4, 506, "OT axis Z"),
|
||||
new(new DateTimeOffset(2025, 4, 1, 9, 6, 0, TimeSpan.Zero), 2, 1, 7, ""),
|
||||
};
|
||||
var bytes = FocasAlarmHistoryDecoder.Encode(src);
|
||||
var decoded = FocasAlarmHistoryDecoder.Decode(bytes);
|
||||
|
||||
decoded.Count.ShouldBe(src.Count);
|
||||
for (var i = 0; i < src.Count; i++)
|
||||
{
|
||||
decoded[i].OccurrenceTime.ShouldBe(src[i].OccurrenceTime);
|
||||
decoded[i].AxisNo.ShouldBe(src[i].AxisNo);
|
||||
decoded[i].AlarmType.ShouldBe(src[i].AlarmType);
|
||||
decoded[i].AlarmNumber.ShouldBe(src[i].AlarmNumber);
|
||||
decoded[i].Message.ShouldBe(src[i].Message);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmHistoryDecoder_Empty_Buffer_Yields_Empty_List()
|
||||
{
|
||||
FocasAlarmHistoryDecoder.Decode(ReadOnlySpan<byte>.Empty).Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmHistoryDecoder_Has_Stable_CommandId()
|
||||
{
|
||||
// Don't accidentally renumber — the simulator + Tier-C backend pin on this id.
|
||||
FocasAlarmHistoryDecoder.CommandId.ShouldBe<ushort>(0x0F1A);
|
||||
}
|
||||
|
||||
// ---- Helpers ----------------------------------------------------------
|
||||
|
||||
private static FocasDriverOptions OptionsWithHistory(int historyDepth, TimeSpan interval) => new()
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Device)],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
AlarmProjection = new FocasAlarmProjectionOptions
|
||||
{
|
||||
Mode = FocasAlarmProjectionMode.ActivePlusHistory,
|
||||
HistoryDepth = historyDepth,
|
||||
HistoryPollInterval = interval,
|
||||
},
|
||||
};
|
||||
|
||||
private static async Task WaitForAsync(Func<bool> condition, int timeoutMs = 5000)
|
||||
{
|
||||
var deadline = Environment.TickCount + timeoutMs;
|
||||
while (Environment.TickCount < deadline)
|
||||
{
|
||||
if (condition()) return;
|
||||
await Task.Delay(20);
|
||||
}
|
||||
condition().ShouldBeTrue("Condition not satisfied within timeout");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user