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:
Joseph Doherty
2026-04-26 00:07:59 -04:00
parent 1922b93bd5
commit 7f9d6a778e
12 changed files with 1248 additions and 1 deletions

View File

@@ -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++;

View File

@@ -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");
}
}