From ca025ebe0cf3cac603ba9e141f06ae36c2fe3484 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 18 Apr 2026 07:08:16 -0400 Subject: [PATCH] =?UTF-8?q?Phase=202=20PR=2011=20=E2=80=94=20HistoryReadEv?= =?UTF-8?q?ents=20IPC=20(alarm=20history).=20New=20Shared.Contracts=20mess?= =?UTF-8?q?ages=20HistoryReadEventsRequest/Response=20+=20GalaxyHistorical?= =?UTF-8?q?Event=20DTO=20(MessageKind=200x66/0x67).=20IGalaxyBackend=20gai?= =?UTF-8?q?ns=20HistoryReadEventsAsync,=20Stub/DbBacked=20return=20canonic?= =?UTF-8?q?al=20pending=20error,=20MxAccessGalaxyBackend=20delegates=20to?= =?UTF-8?q?=20=5Fhistorian.ReadEventsAsync=20(ported=20in=20PR=205)=20and?= =?UTF-8?q?=20maps=20HistorianEventDto=20=E2=86=92=20GalaxyHistoricalEvent?= =?UTF-8?q?=20=E2=80=94=20Guid.ToString()=20for=20EventId=20wire=20shape,?= =?UTF-8?q?=20DateTime=20=E2=86=92=20Unix=20ms=20for=20both=20EventTime=20?= =?UTF-8?q?(when=20the=20event=20fired=20in=20the=20process)=20and=20Recei?= =?UTF-8?q?vedTime=20(when=20the=20Historian=20persisted=20it),=20DisplayT?= =?UTF-8?q?ext=20+=20Severity=20pass=20through.=20SourceName=20is=20string?= =?UTF-8?q?=3F=20=E2=80=94=20null=20means=20'all=20sources'=20(passed=20st?= =?UTF-8?q?raight=20through=20to=20HistorianDataSource.ReadEventsAsync=20w?= =?UTF-8?q?hich=20adds=20the=20AddEventFilter('Source',=20Equal,=20...)=20?= =?UTF-8?q?only=20when=20non-null).=20Distinct=20from=20the=20live=20Galax?= =?UTF-8?q?yAlarmEvent=20type=20because=20historical=20rows=20carry=20both?= =?UTF-8?q?=20timestamps=20and=20lack=20StateTransition=20(Historian=20log?= =?UTF-8?q?s=20instantaneous=20events,=20not=20the=20OPC=20UA=20Part=209?= =?UTF-8?q?=20alarm=20lifecycle;=20translating=20to=20OPC=20UA=20event=20l?= =?UTF-8?q?ifecycle=20is=20the=20alarm-subsystem's=20job).=20Guards:=20nul?= =?UTF-8?q?l=20historian=20=E2=86=92=20Historian-disabled=20error;=20SDK?= =?UTF-8?q?=20exception=20=E2=86=92=20Success=3Dfalse=20with=20message=20c?= =?UTF-8?q?hained.=20Tests=20(3=20new):=20disabled-error=20when=20historia?= =?UTF-8?q?n=20null,=20maps=20HistorianEventDto=20with=20full=20field=20se?= =?UTF-8?q?t=20(Id/Source/EventTime/ReceivedTime/DisplayText/Severity=3D90?= =?UTF-8?q?0)=20to=20GalaxyHistoricalEvent,=20null=20SourceName=20passes?= =?UTF-8?q?=20through=20unchanged=20(verifies=20the=20'all=20sources'=20co?= =?UTF-8?q?ntract).=20Galaxy.Host.Tests=20Unit=20suite=2034=20pass=20/=200?= =?UTF-8?q?=20fail.=20Galaxy.Host=20builds=20clean.=20Branches=20off=20pha?= =?UTF-8?q?se-2-pr10-history-attime=20since=20both=20extend=20the=20Messag?= =?UTF-8?q?eKind=20enum;=20fast-forwards=20if=20PR=2010=20merges=20first.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Backend/DbBackedGalaxyBackend.cs | 9 ++ .../Backend/IGalaxyBackend.cs | 1 + .../Backend/MxAccessGalaxyBackend.cs | 40 ++++++ .../Backend/StubGalaxyBackend.cs | 9 ++ .../Ipc/GalaxyFrameHandler.cs | 7 + .../Contracts/Framing.cs | 2 + .../Contracts/History.cs | 37 +++++ .../HistoryReadEventsTests.cs | 129 ++++++++++++++++++ 8 files changed, 234 insertions(+) create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/HistoryReadEventsTests.cs diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/DbBackedGalaxyBackend.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/DbBackedGalaxyBackend.cs index f167d37..63300be 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/DbBackedGalaxyBackend.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/DbBackedGalaxyBackend.cs @@ -145,6 +145,15 @@ public sealed class DbBackedGalaxyBackend(GalaxyRepository repository) : IGalaxy Values = System.Array.Empty(), }); + public Task HistoryReadEventsAsync( + HistoryReadEventsRequest req, CancellationToken ct) + => Task.FromResult(new HistoryReadEventsResponse + { + Success = false, + Error = "MXAccess + Historian code lift pending (Phase 2 Task B.1)", + Events = System.Array.Empty(), + }); + public Task RecycleAsync(RecycleHostRequest req, CancellationToken ct) => Task.FromResult(new RecycleStatusResponse { Accepted = true, GraceSeconds = 15 }); diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/IGalaxyBackend.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/IGalaxyBackend.cs index 3ac327f..5f7329b 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/IGalaxyBackend.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/IGalaxyBackend.cs @@ -40,6 +40,7 @@ public interface IGalaxyBackend Task HistoryReadAsync(HistoryReadRequest req, CancellationToken ct); Task HistoryReadProcessedAsync(HistoryReadProcessedRequest req, CancellationToken ct); Task HistoryReadAtTimeAsync(HistoryReadAtTimeRequest req, CancellationToken ct); + Task HistoryReadEventsAsync(HistoryReadEventsRequest req, CancellationToken ct); Task RecycleAsync(RecycleHostRequest req, CancellationToken ct); } diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccessGalaxyBackend.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccessGalaxyBackend.cs index 895072c..35cf733 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccessGalaxyBackend.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccessGalaxyBackend.cs @@ -360,6 +360,46 @@ public sealed class MxAccessGalaxyBackend : IGalaxyBackend, IDisposable } } + public async Task HistoryReadEventsAsync( + HistoryReadEventsRequest req, CancellationToken ct) + { + if (_historian is null) + return new HistoryReadEventsResponse + { + Success = false, + Error = "Historian disabled — no OTOPCUA_HISTORIAN_ENABLED configuration", + Events = Array.Empty(), + }; + + var start = DateTimeOffset.FromUnixTimeMilliseconds(req.StartUtcUnixMs).UtcDateTime; + var end = DateTimeOffset.FromUnixTimeMilliseconds(req.EndUtcUnixMs).UtcDateTime; + + try + { + var events = await _historian.ReadEventsAsync(req.SourceName, start, end, req.MaxEvents, ct).ConfigureAwait(false); + var wire = events.Select(e => new GalaxyHistoricalEvent + { + EventId = e.Id.ToString(), + SourceName = e.Source, + EventTimeUtcUnixMs = new DateTimeOffset(DateTime.SpecifyKind(e.EventTime, DateTimeKind.Utc), TimeSpan.Zero).ToUnixTimeMilliseconds(), + ReceivedTimeUtcUnixMs = new DateTimeOffset(DateTime.SpecifyKind(e.ReceivedTime, DateTimeKind.Utc), TimeSpan.Zero).ToUnixTimeMilliseconds(), + DisplayText = e.DisplayText, + Severity = e.Severity, + }).ToArray(); + return new HistoryReadEventsResponse { Success = true, Events = wire }; + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + return new HistoryReadEventsResponse + { + Success = false, + Error = $"Historian event read failed: {ex.Message}", + Events = Array.Empty(), + }; + } + } + public Task RecycleAsync(RecycleHostRequest req, CancellationToken ct) => Task.FromResult(new RecycleStatusResponse { Accepted = true, GraceSeconds = 15 }); diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/StubGalaxyBackend.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/StubGalaxyBackend.cs index c3811d7..ab8ffe6 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/StubGalaxyBackend.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/StubGalaxyBackend.cs @@ -103,6 +103,15 @@ public sealed class StubGalaxyBackend : IGalaxyBackend Values = System.Array.Empty(), }); + public Task HistoryReadEventsAsync( + HistoryReadEventsRequest req, CancellationToken ct) + => Task.FromResult(new HistoryReadEventsResponse + { + Success = false, + Error = "stub: MXAccess code lift pending (Phase 2 Task B.1)", + Events = System.Array.Empty(), + }); + public Task RecycleAsync(RecycleHostRequest req, CancellationToken ct) => Task.FromResult(new RecycleStatusResponse { diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Ipc/GalaxyFrameHandler.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Ipc/GalaxyFrameHandler.cs index d2807e6..dd7fa64 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Ipc/GalaxyFrameHandler.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Ipc/GalaxyFrameHandler.cs @@ -94,6 +94,13 @@ public sealed class GalaxyFrameHandler(IGalaxyBackend backend, ILogger logger) : await writer.WriteAsync(MessageKind.HistoryReadAtTimeResponse, resp, ct); return; } + case MessageKind.HistoryReadEventsRequest: + { + var resp = await backend.HistoryReadEventsAsync( + Deserialize(body), ct); + await writer.WriteAsync(MessageKind.HistoryReadEventsResponse, resp, ct); + return; + } case MessageKind.RecycleHostRequest: { var resp = await backend.RecycleAsync(Deserialize(body), ct); diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/Framing.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/Framing.cs index 068b4b7..09db862 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/Framing.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/Framing.cs @@ -54,6 +54,8 @@ public enum MessageKind : byte HistoryReadProcessedResponse = 0x63, HistoryReadAtTimeRequest = 0x64, HistoryReadAtTimeResponse = 0x65, + HistoryReadEventsRequest = 0x66, + HistoryReadEventsResponse = 0x67, HostConnectivityStatus = 0x70, RuntimeStatusChange = 0x71, diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/History.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/History.cs index 70c8aa0..4a7f16e 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/History.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/History.cs @@ -71,3 +71,40 @@ public sealed class HistoryReadAtTimeResponse [Key(1)] public string? Error { get; set; } [Key(2)] public GalaxyDataValue[] Values { get; set; } = System.Array.Empty(); } + +/// +/// Historical events read — OPC UA HistoryReadEvents service and Alarm & Condition +/// history. SourceName null means "all sources". Distinct from the live +/// stream because historical rows carry both +/// EventTime (when the event occurred in the process) and ReceivedTime +/// (when the Historian persisted it) and have no StateTransition — the Historian logs +/// the instantaneous event, not the OPC UA alarm lifecycle. +/// +[MessagePackObject] +public sealed class HistoryReadEventsRequest +{ + [Key(0)] public long SessionId { get; set; } + [Key(1)] public string? SourceName { get; set; } + [Key(2)] public long StartUtcUnixMs { get; set; } + [Key(3)] public long EndUtcUnixMs { get; set; } + [Key(4)] public int MaxEvents { get; set; } = 1000; +} + +[MessagePackObject] +public sealed class GalaxyHistoricalEvent +{ + [Key(0)] public string EventId { get; set; } = string.Empty; + [Key(1)] public string? SourceName { get; set; } + [Key(2)] public long EventTimeUtcUnixMs { get; set; } + [Key(3)] public long ReceivedTimeUtcUnixMs { get; set; } + [Key(4)] public string? DisplayText { get; set; } + [Key(5)] public ushort Severity { get; set; } +} + +[MessagePackObject] +public sealed class HistoryReadEventsResponse +{ + [Key(0)] public bool Success { get; set; } + [Key(1)] public string? Error { get; set; } + [Key(2)] public GalaxyHistoricalEvent[] Events { get; set; } = System.Array.Empty(); +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/HistoryReadEventsTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/HistoryReadEventsTests.cs new file mode 100644 index 0000000..2e57756 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/HistoryReadEventsTests.cs @@ -0,0 +1,129 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Historian; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Sta; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests; + +[Trait("Category", "Unit")] +public sealed class HistoryReadEventsTests +{ + private static MxAccessGalaxyBackend BuildBackend(IHistorianDataSource? h, StaPump pump) => + new( + new GalaxyRepository(new GalaxyRepositoryOptions { ConnectionString = "Server=.;Database=ZB;Integrated Security=True;" }), + new MxAccessClient(pump, new MxProxyAdapter(), "events-test"), + h); + + [Fact] + public async Task Returns_disabled_error_when_no_historian_configured() + { + using var pump = new StaPump("Test.Sta"); + await pump.WaitForStartedAsync(); + using var backend = BuildBackend(null, pump); + + var resp = await backend.HistoryReadEventsAsync(new HistoryReadEventsRequest + { + SourceName = "TankA", + StartUtcUnixMs = 0, + EndUtcUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + MaxEvents = 100, + }, CancellationToken.None); + + resp.Success.ShouldBeFalse(); + resp.Error.ShouldContain("Historian disabled"); + } + + [Fact] + public async Task Maps_HistorianEventDto_to_GalaxyHistoricalEvent_wire_shape() + { + using var pump = new StaPump("Test.Sta"); + await pump.WaitForStartedAsync(); + + var eventId = Guid.NewGuid(); + var eventTime = new DateTime(2026, 4, 18, 10, 0, 0, DateTimeKind.Utc); + var receivedTime = eventTime.AddMilliseconds(150); + var fake = new FakeHistorian(new HistorianEventDto + { + Id = eventId, + Source = "TankA.Level.HiHi", + EventTime = eventTime, + ReceivedTime = receivedTime, + DisplayText = "HiHi alarm tripped", + Severity = 900, + }); + using var backend = BuildBackend(fake, pump); + + var resp = await backend.HistoryReadEventsAsync(new HistoryReadEventsRequest + { + SourceName = "TankA.Level.HiHi", + StartUtcUnixMs = 0, + EndUtcUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + MaxEvents = 50, + }, CancellationToken.None); + + resp.Success.ShouldBeTrue(); + resp.Events.Length.ShouldBe(1); + var got = resp.Events[0]; + got.EventId.ShouldBe(eventId.ToString()); + got.SourceName.ShouldBe("TankA.Level.HiHi"); + got.DisplayText.ShouldBe("HiHi alarm tripped"); + got.Severity.ShouldBe(900); + got.EventTimeUtcUnixMs.ShouldBe(new DateTimeOffset(eventTime, TimeSpan.Zero).ToUnixTimeMilliseconds()); + got.ReceivedTimeUtcUnixMs.ShouldBe(new DateTimeOffset(receivedTime, TimeSpan.Zero).ToUnixTimeMilliseconds()); + + fake.LastSourceName.ShouldBe("TankA.Level.HiHi"); + fake.LastMaxEvents.ShouldBe(50); + } + + [Fact] + public async Task Null_source_name_is_passed_through_as_all_sources() + { + using var pump = new StaPump("Test.Sta"); + await pump.WaitForStartedAsync(); + var fake = new FakeHistorian(); + using var backend = BuildBackend(fake, pump); + + await backend.HistoryReadEventsAsync(new HistoryReadEventsRequest + { + SourceName = null, + StartUtcUnixMs = 0, + EndUtcUnixMs = 1, + MaxEvents = 10, + }, CancellationToken.None); + + fake.LastSourceName.ShouldBeNull(); + } + + private sealed class FakeHistorian : IHistorianDataSource + { + private readonly HistorianEventDto[] _events; + public string? LastSourceName { get; private set; } = ""; + public int LastMaxEvents { get; private set; } + + public FakeHistorian(params HistorianEventDto[] events) => _events = events; + + public Task> ReadEventsAsync(string? src, DateTime s, DateTime e, int max, CancellationToken ct) + { + LastSourceName = src; + LastMaxEvents = max; + return Task.FromResult(new List(_events)); + } + + public Task> ReadRawAsync(string tag, DateTime s, DateTime e, int max, CancellationToken ct) + => Task.FromResult(new List()); + public Task> ReadAggregateAsync(string tag, DateTime s, DateTime e, double ms, string col, CancellationToken ct) + => Task.FromResult(new List()); + public Task> ReadAtTimeAsync(string tag, DateTime[] ts, CancellationToken ct) + => Task.FromResult(new List()); + public HistorianHealthSnapshot GetHealthSnapshot() => new(); + public void Dispose() { } + } +} -- 2.49.1