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() { } + } +}