From d13f91911276d888a4066eb0e7e97bbc66e2574e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 18 Apr 2026 07:03:25 -0400 Subject: [PATCH] =?UTF-8?q?Phase=202=20PR=2010=20=E2=80=94=20HistoryReadAt?= =?UTF-8?q?Time=20IPC=20surface.=20New=20Shared.Contracts=20messages=20His?= =?UTF-8?q?toryReadAtTimeRequest/Response=20(MessageKind=200x64/0x65),=20I?= =?UTF-8?q?GalaxyBackend=20gains=20HistoryReadAtTimeAsync,=20Stub/DbBacked?= =?UTF-8?q?=20return=20canonical=20pending=20error,=20MxAccessGalaxyBacken?= =?UTF-8?q?d=20delegates=20to=20=5Fhistorian.ReadAtTimeAsync=20(ported=20i?= =?UTF-8?q?n=20PR=205,=20exposed=20now)=20=E2=80=94=20request=20timestamp?= =?UTF-8?q?=20array=20is=20flow-encoded=20as=20Unix=20ms=20to=20avoid=20Me?= =?UTF-8?q?ssagePack=20DateTime=20quirks=20then=20re-hydrated=20to=20DateT?= =?UTF-8?q?ime=20on=20the=20Host=20side.=20Per-sample=20mapping=20uses=20t?= =?UTF-8?q?he=20same=20ToWire(HistorianSample)=20helper=20as=20ReadRawAsyn?= =?UTF-8?q?c=20so=20the=20category=E2=86=92StatusCode=20mapping=20stays=20?= =?UTF-8?q?consistent=20(Quality=20byte=20192+=20=E2=86=92=20Good=200u,=20?= =?UTF-8?q?64-191=20=E2=86=92=20Uncertain,=200-63=20=E2=86=92=20Bad=200x80?= =?UTF-8?q?000000u).=20Guards:=20null=20historian=20=E2=86=92=20"Historian?= =?UTF-8?q?=20disabled"=20(symmetric=20with=20other=20history=20paths);=20?= =?UTF-8?q?empty=20timestamp=20array=20short-circuits=20to=20Success=3Dtru?= =?UTF-8?q?e,=20Values=3D[]=20without=20an=20SDK=20round-trip;=20SDK=20exc?= =?UTF-8?q?eption=20=E2=86=92=20Success=3Dfalse=20with=20the=20message=20c?= =?UTF-8?q?hained.=20Proxy-side=20IHistoryProvider.ReadAtTimeAsync=20capab?= =?UTF-8?q?ility=20doesn't=20exist=20in=20Core.Abstractions=20yet=20(OPC?= =?UTF-8?q?=20UA=20HistoryReadAtTime=20service=20is=20supported=20but=20th?= =?UTF-8?q?e=20current=20IHistoryProvider=20only=20has=20ReadRawAsync=20+?= =?UTF-8?q?=20ReadProcessedAsync)=20=E2=80=94=20this=20PR=20adds=20the=20H?= =?UTF-8?q?ost-side=20surface=20so=20a=20future=20Core.Abstractions=20exte?= =?UTF-8?q?nsion=20can=20wire=20it=20through=20without=20needing=20another?= =?UTF-8?q?=20IPC=20change.=20Tests=20(4=20new):=20disabled-error=20when?= =?UTF-8?q?=20historian=20null,=20empty-timestamp=20short-circuit=20withou?= =?UTF-8?q?t=20SDK=20call,=20Unix-ms=E2=86=94DateTime=20round-trip=20with?= =?UTF-8?q?=20Good=20samples=20at=20two=20distinct=20timestamps,=20missing?= =?UTF-8?q?=20sample=20(Quality=3D0)=20maps=20to=200x80000000u=20Bad=20cat?= =?UTF-8?q?egory.=20Galaxy.Host.Tests=20Unit=20suite:=2031=20pass=20/=200?= =?UTF-8?q?=20fail=20(4=20new=20at-time=20+=2027=20pre-existing).=20Galaxy?= =?UTF-8?q?.Host=20builds=20clean.=20Branches=20off=20v2.?= 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 | 36 +++++ .../Backend/StubGalaxyBackend.cs | 9 ++ .../Ipc/GalaxyFrameHandler.cs | 7 + .../Contracts/Framing.cs | 6 +- .../Contracts/History.cs | 21 +++ .../HistoryReadAtTimeTests.cs | 147 ++++++++++++++++++ 8 files changed, 234 insertions(+), 2 deletions(-) create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/HistoryReadAtTimeTests.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 29bd850..f167d37 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 @@ -136,6 +136,15 @@ public sealed class DbBackedGalaxyBackend(GalaxyRepository repository) : IGalaxy Values = System.Array.Empty(), }); + public Task HistoryReadAtTimeAsync( + HistoryReadAtTimeRequest req, CancellationToken ct) + => Task.FromResult(new HistoryReadAtTimeResponse + { + Success = false, + Error = "MXAccess + Historian code lift pending (Phase 2 Task B.1)", + Values = 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 5739146..3ac327f 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 @@ -39,6 +39,7 @@ public interface IGalaxyBackend Task HistoryReadAsync(HistoryReadRequest req, CancellationToken ct); Task HistoryReadProcessedAsync(HistoryReadProcessedRequest req, CancellationToken ct); + Task HistoryReadAtTimeAsync(HistoryReadAtTimeRequest 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 290fa29..895072c 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 @@ -324,6 +324,42 @@ public sealed class MxAccessGalaxyBackend : IGalaxyBackend, IDisposable } } + public async Task HistoryReadAtTimeAsync( + HistoryReadAtTimeRequest req, CancellationToken ct) + { + if (_historian is null) + return new HistoryReadAtTimeResponse + { + Success = false, + Error = "Historian disabled — no OTOPCUA_HISTORIAN_ENABLED configuration", + Values = Array.Empty(), + }; + + if (req.TimestampsUtcUnixMs.Length == 0) + return new HistoryReadAtTimeResponse { Success = true, Values = Array.Empty() }; + + var timestamps = req.TimestampsUtcUnixMs + .Select(ms => DateTimeOffset.FromUnixTimeMilliseconds(ms).UtcDateTime) + .ToArray(); + + try + { + var samples = await _historian.ReadAtTimeAsync(req.TagReference, timestamps, ct).ConfigureAwait(false); + var wire = samples.Select(s => ToWire(req.TagReference, s)).ToArray(); + return new HistoryReadAtTimeResponse { Success = true, Values = wire }; + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + return new HistoryReadAtTimeResponse + { + Success = false, + Error = $"Historian at-time read failed: {ex.Message}", + Values = 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 27da0a4..c3811d7 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 @@ -94,6 +94,15 @@ public sealed class StubGalaxyBackend : IGalaxyBackend Values = System.Array.Empty(), }); + public Task HistoryReadAtTimeAsync( + HistoryReadAtTimeRequest req, CancellationToken ct) + => Task.FromResult(new HistoryReadAtTimeResponse + { + Success = false, + Error = "stub: MXAccess code lift pending (Phase 2 Task B.1)", + Values = 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 7d82808..d2807e6 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 @@ -87,6 +87,13 @@ public sealed class GalaxyFrameHandler(IGalaxyBackend backend, ILogger logger) : await writer.WriteAsync(MessageKind.HistoryReadProcessedResponse, resp, ct); return; } + case MessageKind.HistoryReadAtTimeRequest: + { + var resp = await backend.HistoryReadAtTimeAsync( + Deserialize(body), ct); + await writer.WriteAsync(MessageKind.HistoryReadAtTimeResponse, 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 2a17478..068b4b7 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 @@ -48,10 +48,12 @@ public enum MessageKind : byte AlarmEvent = 0x51, AlarmAckRequest = 0x52, - HistoryReadRequest = 0x60, - HistoryReadResponse = 0x61, + HistoryReadRequest = 0x60, + HistoryReadResponse = 0x61, HistoryReadProcessedRequest = 0x62, HistoryReadProcessedResponse = 0x63, + HistoryReadAtTimeRequest = 0x64, + HistoryReadAtTimeResponse = 0x65, 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 6990692..70c8aa0 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 @@ -50,3 +50,24 @@ public sealed class HistoryReadProcessedResponse [Key(1)] public string? Error { get; set; } [Key(2)] public GalaxyDataValue[] Values { get; set; } = System.Array.Empty(); } + +/// +/// At-time historian read — OPC UA HistoryReadAtTime service. Returns one sample per +/// requested timestamp (interpolated when no exact match exists). The per-timestamp array +/// is flow-encoded as Unix milliseconds to avoid MessagePack DateTime quirks. +/// +[MessagePackObject] +public sealed class HistoryReadAtTimeRequest +{ + [Key(0)] public long SessionId { get; set; } + [Key(1)] public string TagReference { get; set; } = string.Empty; + [Key(2)] public long[] TimestampsUtcUnixMs { get; set; } = System.Array.Empty(); +} + +[MessagePackObject] +public sealed class HistoryReadAtTimeResponse +{ + [Key(0)] public bool Success { get; set; } + [Key(1)] public string? Error { get; set; } + [Key(2)] public GalaxyDataValue[] Values { get; set; } = System.Array.Empty(); +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/HistoryReadAtTimeTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/HistoryReadAtTimeTests.cs new file mode 100644 index 0000000..4be8be9 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/HistoryReadAtTimeTests.cs @@ -0,0 +1,147 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MessagePack; +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 HistoryReadAtTimeTests +{ + private static MxAccessGalaxyBackend BuildBackend(IHistorianDataSource? historian, StaPump pump) => + new( + new GalaxyRepository(new GalaxyRepositoryOptions { ConnectionString = "Server=.;Database=ZB;Integrated Security=True;" }), + new MxAccessClient(pump, new MxProxyAdapter(), "attime-test"), + historian); + + [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.HistoryReadAtTimeAsync(new HistoryReadAtTimeRequest + { + TagReference = "T", + TimestampsUtcUnixMs = new[] { 1L, 2L }, + }, CancellationToken.None); + + resp.Success.ShouldBeFalse(); + resp.Error.ShouldContain("Historian disabled"); + } + + [Fact] + public async Task Empty_timestamp_list_short_circuits_to_success_with_no_values() + { + using var pump = new StaPump("Test.Sta"); + await pump.WaitForStartedAsync(); + var fake = new FakeHistorian(); + using var backend = BuildBackend(fake, pump); + + var resp = await backend.HistoryReadAtTimeAsync(new HistoryReadAtTimeRequest + { + TagReference = "T", + TimestampsUtcUnixMs = Array.Empty(), + }, CancellationToken.None); + + resp.Success.ShouldBeTrue(); + resp.Values.ShouldBeEmpty(); + fake.Calls.ShouldBe(0); // no round-trip to SDK for empty timestamp list + } + + [Fact] + public async Task Timestamps_survive_Unix_ms_round_trip_to_DateTime() + { + using var pump = new StaPump("Test.Sta"); + await pump.WaitForStartedAsync(); + var t1 = new DateTime(2026, 4, 18, 10, 0, 0, DateTimeKind.Utc); + var t2 = new DateTime(2026, 4, 18, 10, 5, 0, DateTimeKind.Utc); + var fake = new FakeHistorian( + new HistorianSample { Value = 100.0, Quality = 192, TimestampUtc = t1 }, + new HistorianSample { Value = 101.5, Quality = 192, TimestampUtc = t2 }); + using var backend = BuildBackend(fake, pump); + + var resp = await backend.HistoryReadAtTimeAsync(new HistoryReadAtTimeRequest + { + TagReference = "TankLevel", + TimestampsUtcUnixMs = new[] + { + new DateTimeOffset(t1, TimeSpan.Zero).ToUnixTimeMilliseconds(), + new DateTimeOffset(t2, TimeSpan.Zero).ToUnixTimeMilliseconds(), + }, + }, CancellationToken.None); + + resp.Success.ShouldBeTrue(); + resp.Values.Length.ShouldBe(2); + resp.Values[0].SourceTimestampUtcUnixMs.ShouldBe(new DateTimeOffset(t1, TimeSpan.Zero).ToUnixTimeMilliseconds()); + resp.Values[0].StatusCode.ShouldBe(0u); // Good (quality 192) + MessagePackSerializer.Deserialize(resp.Values[0].ValueBytes!).ShouldBe(100.0); + + fake.Calls.ShouldBe(1); + fake.LastTimestamps.Length.ShouldBe(2); + fake.LastTimestamps[0].ShouldBe(t1); + fake.LastTimestamps[1].ShouldBe(t2); + } + + [Fact] + public async Task Missing_sample_maps_to_Bad_category() + { + using var pump = new StaPump("Test.Sta"); + await pump.WaitForStartedAsync(); + // Quality=0 means no sample at that timestamp per HistorianDataSource.ReadAtTimeAsync. + var fake = new FakeHistorian(new HistorianSample + { + Value = null, + Quality = 0, + TimestampUtc = DateTime.UtcNow, + }); + using var backend = BuildBackend(fake, pump); + + var resp = await backend.HistoryReadAtTimeAsync(new HistoryReadAtTimeRequest + { + TagReference = "T", + TimestampsUtcUnixMs = new[] { 1L }, + }, CancellationToken.None); + + resp.Success.ShouldBeTrue(); + resp.Values.Length.ShouldBe(1); + resp.Values[0].StatusCode.ShouldBe(0x80000000u); // Bad category + resp.Values[0].ValueBytes.ShouldBeNull(); + } + + private sealed class FakeHistorian : IHistorianDataSource + { + private readonly HistorianSample[] _samples; + public int Calls { get; private set; } + public DateTime[] LastTimestamps { get; private set; } = Array.Empty(); + + public FakeHistorian(params HistorianSample[] samples) => _samples = samples; + + public Task> ReadAtTimeAsync(string tag, DateTime[] ts, CancellationToken ct) + { + Calls++; + LastTimestamps = ts; + return Task.FromResult(new List(_samples)); + } + + 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> ReadEventsAsync(string? src, DateTime s, DateTime e, int max, CancellationToken ct) + => Task.FromResult(new List()); + public HistorianHealthSnapshot GetHealthSnapshot() => new(); + public void Dispose() { } + } +} -- 2.49.1