From bf329b05d8aead20d1ff9a95c7c1eb442f523a47 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 18 Apr 2026 16:08:27 -0400 Subject: [PATCH] =?UTF-8?q?Phase=203=20PR=2035=20=E2=80=94=20IHistoryProvi?= =?UTF-8?q?der=20gains=20ReadAtTimeAsync=20+=20ReadEventsAsync;=20GalaxyPr?= =?UTF-8?q?oxyDriver=20implements=20both.=20Extends=20Core.Abstractions.IH?= =?UTF-8?q?istoryProvider=20with=20two=20new=20methods=20that=20round=20ou?= =?UTF-8?q?t=20the=20OPC=20UA=20Part=2011=20HistoryRead=20surface=20(Histo?= =?UTF-8?q?ryReadAtTime=20+=20HistoryReadEvents=20are=20the=20last=20two?= =?UTF-8?q?=20modes=20not=20covered=20by=20the=20PR=2019-era=20ReadRawAsyn?= =?UTF-8?q?c=20+=20ReadProcessedAsync)=20and=20wires=20GalaxyProxyDriver?= =?UTF-8?q?=20to=20call=20the=20existing=20PR-10/PR-11=20IPC=20contracts?= =?UTF-8?q?=20the=20Host=20already=20implements.=20Interface=20additions?= =?UTF-8?q?=20use=20C#=20default=20interface=20implementations=20that=20th?= =?UTF-8?q?row=20NotSupportedException=20=E2=80=94=20existing=20IHistoryPr?= =?UTF-8?q?ovider=20implementations=20keep=20compiling,=20only=20drivers?= =?UTF-8?q?=20whose=20backend=20carries=20the=20relevant=20capability=20ov?= =?UTF-8?q?erride.=20This=20matches=20the=20'capabilities=20are=20optional?= =?UTF-8?q?=20per=20driver'=20design=20already=20used=20by=20IHistoryProvi?= =?UTF-8?q?der.ReadProcessedAsync's=20docs=20(Modbus=20/=20OPC=20UA=20Clie?= =?UTF-8?q?nt=20drivers=20never=20had=20an=20event=20historian=20and=20the?= =?UTF-8?q?=20default-throw=20path=20lets=20callers=20see=20BadHistoryOper?= =?UTF-8?q?ationUnsupported=20naturally).=20New=20HistoricalEvent=20record?= =?UTF-8?q?=20models=20one=20historian=20row=20(EventId,=20SourceName,=20E?= =?UTF-8?q?ventTimeUtc=20+=20ReceivedTimeUtc=20=E2=80=94=20process=20vs=20?= =?UTF-8?q?historian-persist=20timestamps,=20Message,=20Severity=20mapped?= =?UTF-8?q?=20to=20OPC=20UA's=201-1000=20range);=20HistoricalEventsResult?= =?UTF-8?q?=20pairs=20the=20event=20list=20with=20a=20continuation-point?= =?UTF-8?q?=20token=20for=20future=20batching.=20Both=20live=20in=20Core.A?= =?UTF-8?q?bstractions=20so=20downstream=20(Proxy,=20Host,=20Server)=20ref?= =?UTF-8?q?erence=20a=20single=20domain=20shape=20=E2=80=94=20no=20Shared-?= =?UTF-8?q?contract=20leak=20into=20the=20driver-facing=20interface.=20Gal?= =?UTF-8?q?axyProxyDriver.ReadAtTimeAsync=20maps=20the=20domain=20DateTime?= =?UTF-8?q?[]=20to=20Unix-ms=20longs,=20calls=20CallAsync=20on=20the=20exi?= =?UTF-8?q?sting=20MessageKind.HistoryReadAtTimeRequest,=20and=20trusts=20?= =?UTF-8?q?the=20Host's=20one-sample-per-requested-timestamp=20contract=20?= =?UTF-8?q?(the=20Host=20pads=20with=20bad-quality=20snapshots=20for=20tim?= =?UTF-8?q?estamps=20it=20can't=20interpolate;=20re-aligning=20on=20the=20?= =?UTF-8?q?Proxy=20side=20would=20duplicate=20the=20Host's=20interpolation?= =?UTF-8?q?=20policy=20logic).=20ReadEventsAsync=20does=20the=20same=20for?= =?UTF-8?q?=20HistoryReadEventsRequest;=20ToHistoricalEvent=20translates?= =?UTF-8?q?=20GalaxyHistoricalEvent=20(MessagePack-annotated,=20Unix-ms)?= =?UTF-8?q?=20to=20the=20domain=20record,=20explicitly=20tagging=20DateTim?= =?UTF-8?q?eKind.Utc=20on=20both=20timestamp=20fields=20so=20downstream=20?= =?UTF-8?q?serializers=20(JSON,=20OPC=20UA=20types)=20don't=20apply=20an?= =?UTF-8?q?=20unexpected=20local-time=20offset.=20Tests=20=E2=80=94=20Hist?= =?UTF-8?q?oricalEventMappingTests=20(3=20new=20Proxy.Tests=20unit=20cases?= =?UTF-8?q?):=20every=20field=20maps=20correctly=20from=20wire=20to=20doma?= =?UTF-8?q?in;=20null=20SourceName=20and=20null=20DisplayText=20preserve?= =?UTF-8?q?=20through=20the=20mapping=20(system=20events=20without=20a=20s?= =?UTF-8?q?ource=20come=20out=20with=20null=20so=20callers=20can=20disting?= =?UTF-8?q?uish=20them=20from=20alarm=20events);=20both=20timestamps=20com?= =?UTF-8?q?e=20out=20as=20DateTimeKind.Utc=20(regression=20guard=20against?= =?UTF-8?q?=20a=20future=20refactor=20using=20DateTime.FromFileTimeUtc=20o?= =?UTF-8?q?r=20similar=20that=20defaults=20to=20Unspecified).=20Driver.Gal?= =?UTF-8?q?axy.Proxy.Tests=20Unit=20suite:=2017=20pass=20/=200=20fail=20(1?= =?UTF-8?q?4=20prior=20+=203=20new).=20Full=20solution=20build=20clean,=20?= =?UTF-8?q?0=20errors.=20Scope=20exclusions=20=E2=80=94=20DriverNodeManage?= =?UTF-8?q?r=20HistoryRead=20service-handler=20wiring=20(on=20the=20OPC=20?= =?UTF-8?q?UA=20Server=20side,=20where=20HistoryReadAtTime=20and=20History?= =?UTF-8?q?ReadEvents=20service=20requests=20land)=20and=20the=20full-loop?= =?UTF-8?q?=20integration=20test=20(OPC=20UA=20client=20=E2=86=92=20server?= =?UTF-8?q?=20=E2=86=92=20IPC=20=E2=86=92=20Host=20=E2=86=92=20HistorianDa?= =?UTF-8?q?taSource=20=E2=86=92=20back)=20are=20deferred=20to=20a=20focuse?= =?UTF-8?q?d=20follow-up=20PR.=20The=20capability=20surface=20is=20the=20l?= =?UTF-8?q?oad-bearing=20change;=20wiring=20the=20service=20handlers=20is?= =?UTF-8?q?=20mechanical=20in=20comparison=20and=20worth=20its=20own=20PR?= =?UTF-8?q?=20for=20reviewability.=20docs/v2/lmx-followups.md=20#1=20updat?= =?UTF-8?q?ed=20with=20the=20split.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/v2/lmx-followups.md | 21 +++-- .../IHistoryProvider.cs | 72 +++++++++++++++++ .../GalaxyProxyDriver.cs | 58 +++++++++++++ .../HistoricalEventMappingTests.cs | 81 +++++++++++++++++++ 4 files changed, 221 insertions(+), 11 deletions(-) create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/HistoricalEventMappingTests.cs diff --git a/docs/v2/lmx-followups.md b/docs/v2/lmx-followups.md index d5b91eb..a6f5f90 100644 --- a/docs/v2/lmx-followups.md +++ b/docs/v2/lmx-followups.md @@ -9,19 +9,18 @@ rough priority order. ## 1. Proxy-side `IHistoryProvider` for `ReadAtTime` / `ReadEvents` -**Status**: Host-side IPC shipped (PR 10 + PR 11). Proxy consumer not written. +**Status**: Capability surface complete (PR 35). OPC UA HistoryRead service-handler +wiring in `DriverNodeManager` remains as the next step; integration-test still +pending. -PR 10 added `HistoryReadAtTimeRequest/Response` on the IPC wire and -`MxAccessGalaxyBackend.HistoryReadAtTimeAsync` delegates to -`HistorianDataSource.ReadAtTimeAsync`. PR 11 did the same for events -(`HistoryReadEventsRequest/Response` + `GalaxyHistoricalEvent`). The Proxy -side (`GalaxyProxyDriver`) doesn't call those yet — `Core.Abstractions.IHistoryProvider` -only exposes `ReadRawAsync` + `ReadProcessedAsync`. +PR 35 extended `IHistoryProvider` with `ReadAtTimeAsync` + `ReadEventsAsync` +(default throwing implementations so existing impls keep compiling), added the +`HistoricalEvent` + `HistoricalEventsResult` records to +`Core.Abstractions`, and implemented both methods in `GalaxyProxyDriver` on top +of the PR 10 / PR 11 IPC messages. Wire-to-domain mapping (`ToHistoricalEvent`) +is unit-tested for field fidelity, null-preservation, and `DateTimeKind.Utc`. -**To do**: -- Extend `IHistoryProvider` with `ReadAtTimeAsync(string, DateTime[], …)` and - `ReadEventsAsync(string?, DateTime, DateTime, int, …)`. -- `GalaxyProxyDriver` calls the new IPC message kinds. +**Remaining**: - `DriverNodeManager` wires the new capability methods onto `HistoryRead` `AtTime` + `Events` service handlers. - Integration test: OPC UA client calls `HistoryReadAtTime` / `HistoryReadEvents`, diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IHistoryProvider.cs b/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IHistoryProvider.cs index af48bbc..b26108f 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IHistoryProvider.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IHistoryProvider.cs @@ -30,6 +30,52 @@ public interface IHistoryProvider TimeSpan interval, HistoryAggregateType aggregate, CancellationToken cancellationToken); + + /// + /// Read one sample per requested timestamp — OPC UA HistoryReadAtTime service. The + /// driver interpolates (or returns the prior-boundary sample) when no exact match + /// exists. Optional; drivers that can't interpolate throw . + /// + /// + /// Default implementation throws. Drivers opt in by overriding; keeps existing + /// IHistoryProvider implementations compiling without forcing a ReadAtTime path + /// they may not have a backend for. + /// + Task ReadAtTimeAsync( + string fullReference, + IReadOnlyList timestampsUtc, + CancellationToken cancellationToken) + => throw new NotSupportedException( + $"{GetType().Name} does not implement ReadAtTimeAsync. " + + "Drivers whose backends support at-time reads override this method."); + + /// + /// Read historical alarm/event records — OPC UA HistoryReadEvents service. Distinct + /// from the live event stream — historical rows come from an event historian (Galaxy's + /// Alarm Provider history log, etc.) rather than the driver's active subscription. + /// + /// + /// Optional filter: null means "all sources", otherwise restrict to events from that + /// source-object name. Drivers may ignore the filter if the backend doesn't support it. + /// + /// Inclusive lower bound on EventTimeUtc. + /// Exclusive upper bound on EventTimeUtc. + /// Upper cap on returned events — the driver's backend enforces this. + /// Request cancellation. + /// + /// Default implementation throws. Only drivers with an event historian (Galaxy via the + /// Wonderware Alarm & Events log) override. Modbus / the OPC UA Client driver stay + /// with the default and let callers see BadHistoryOperationUnsupported. + /// + Task ReadEventsAsync( + string? sourceName, + DateTime startUtc, + DateTime endUtc, + int maxEvents, + CancellationToken cancellationToken) + => throw new NotSupportedException( + $"{GetType().Name} does not implement ReadEventsAsync. " + + "Drivers whose backends have an event historian override this method."); } /// Result of a HistoryRead call. @@ -48,3 +94,29 @@ public enum HistoryAggregateType Total, Count, } + +/// +/// One row returned by — a historical +/// alarm/event record, not the OPC UA live-event stream. Fields match the minimum set the +/// Server needs to populate a HistoryEventFieldList for HistoryReadEvents responses. +/// +/// Stable unique id for the event — driver-specific format. +/// Source object that emitted the event. May differ from the sourceName filter the caller passed (fuzzy matches). +/// Process-side timestamp — when the event actually occurred. +/// Historian-side timestamp — when the historian persisted the row; may lag by the historian's buffer flush cadence. +/// Human-readable message text. +/// OPC UA severity (1-1000). Drivers map their native priority scale onto this range. +public sealed record HistoricalEvent( + string EventId, + string? SourceName, + DateTime EventTimeUtc, + DateTime ReceivedTimeUtc, + string? Message, + ushort Severity); + +/// Result of a call. +/// Events in chronological order by EventTimeUtc. +/// Opaque token for the next call when more events are available; null when complete. +public sealed record HistoricalEventsResult( + IReadOnlyList Events, + byte[]? ContinuationPoint); diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/GalaxyProxyDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/GalaxyProxyDriver.cs index 8762cd4..a233b26 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/GalaxyProxyDriver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/GalaxyProxyDriver.cs @@ -339,6 +339,64 @@ public sealed class GalaxyProxyDriver(GalaxyProxyOptions options) return new HistoryReadResult(samples, ContinuationPoint: null); } + public async Task ReadAtTimeAsync( + string fullReference, IReadOnlyList timestampsUtc, CancellationToken cancellationToken) + { + var client = RequireClient(); + var resp = await client.CallAsync( + MessageKind.HistoryReadAtTimeRequest, + new HistoryReadAtTimeRequest + { + SessionId = _sessionId, + TagReference = fullReference, + TimestampsUtcUnixMs = [.. timestampsUtc.Select(t => new DateTimeOffset(t, TimeSpan.Zero).ToUnixTimeMilliseconds())], + }, + MessageKind.HistoryReadAtTimeResponse, + cancellationToken); + + if (!resp.Success) + throw new InvalidOperationException($"Galaxy.Host HistoryReadAtTime failed: {resp.Error}"); + + // ReadAtTime returns one sample per requested timestamp in the same order — the Host + // pads with bad-quality snapshots when a timestamp can't be interpolated, so response + // length matches request length exactly. We trust that contract rather than + // re-aligning here, because the Host is the source-of-truth for interpolation policy. + IReadOnlyList samples = [.. resp.Values.Select(ToSnapshot)]; + return new HistoryReadResult(samples, ContinuationPoint: null); + } + + public async Task ReadEventsAsync( + string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, CancellationToken cancellationToken) + { + var client = RequireClient(); + var resp = await client.CallAsync( + MessageKind.HistoryReadEventsRequest, + new HistoryReadEventsRequest + { + SessionId = _sessionId, + SourceName = sourceName, + StartUtcUnixMs = new DateTimeOffset(startUtc, TimeSpan.Zero).ToUnixTimeMilliseconds(), + EndUtcUnixMs = new DateTimeOffset(endUtc, TimeSpan.Zero).ToUnixTimeMilliseconds(), + MaxEvents = maxEvents, + }, + MessageKind.HistoryReadEventsResponse, + cancellationToken); + + if (!resp.Success) + throw new InvalidOperationException($"Galaxy.Host HistoryReadEvents failed: {resp.Error}"); + + IReadOnlyList events = [.. resp.Events.Select(ToHistoricalEvent)]; + return new HistoricalEventsResult(events, ContinuationPoint: null); + } + + internal static HistoricalEvent ToHistoricalEvent(GalaxyHistoricalEvent wire) => new( + EventId: wire.EventId, + SourceName: wire.SourceName, + EventTimeUtc: DateTimeOffset.FromUnixTimeMilliseconds(wire.EventTimeUtcUnixMs).UtcDateTime, + ReceivedTimeUtc: DateTimeOffset.FromUnixTimeMilliseconds(wire.ReceivedTimeUtcUnixMs).UtcDateTime, + Message: wire.DisplayText, + Severity: wire.Severity); + /// /// Maps the OPC UA Part 13 aggregate enum onto the Wonderware Historian /// AnalogSummaryQuery column names consumed by HistorianDataSource.ReadAggregateAsync. diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/HistoricalEventMappingTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/HistoricalEventMappingTests.cs new file mode 100644 index 0000000..9986c62 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/HistoricalEventMappingTests.cs @@ -0,0 +1,81 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests; + +/// +/// Pins — the wire-to-domain mapping +/// from (MessagePack-annotated IPC contract, +/// Unix-ms timestamps) to Core.Abstractions.HistoricalEvent (domain record, +/// timestamps). Added in PR 35 alongside the new +/// IHistoryProvider.ReadEventsAsync method. +/// +[Trait("Category", "Unit")] +public sealed class HistoricalEventMappingTests +{ + [Fact] + public void Maps_every_field_from_wire_to_domain_record() + { + var wire = new GalaxyHistoricalEvent + { + EventId = "evt-42", + SourceName = "Tank1.HiAlarm", + EventTimeUtcUnixMs = 1_700_000_000_000L, // 2023-11-14T22:13:20.000Z + ReceivedTimeUtcUnixMs = 1_700_000_000_500L, + DisplayText = "High level reached", + Severity = 750, + }; + + var domain = GalaxyProxyDriver.ToHistoricalEvent(wire); + + domain.EventId.ShouldBe("evt-42"); + domain.SourceName.ShouldBe("Tank1.HiAlarm"); + domain.EventTimeUtc.ShouldBe(new DateTime(2023, 11, 14, 22, 13, 20, DateTimeKind.Utc)); + domain.ReceivedTimeUtc.ShouldBe(new DateTime(2023, 11, 14, 22, 13, 20, 500, DateTimeKind.Utc)); + domain.Message.ShouldBe("High level reached"); + domain.Severity.ShouldBe((ushort)750); + } + + [Fact] + public void Preserves_null_SourceName_and_DisplayText() + { + // Historical rows from the Galaxy event historian often omit source or message for + // system events (e.g. time sync). The mapping must preserve null — callers use it to + // distinguish system events from alarm events. + var wire = new GalaxyHistoricalEvent + { + EventId = "sys-1", + SourceName = null, + EventTimeUtcUnixMs = 0, + ReceivedTimeUtcUnixMs = 0, + DisplayText = null, + Severity = 1, + }; + + var domain = GalaxyProxyDriver.ToHistoricalEvent(wire); + + domain.SourceName.ShouldBeNull(); + domain.Message.ShouldBeNull(); + } + + [Fact] + public void EventTime_and_ReceivedTime_are_produced_as_DateTimeKind_Utc() + { + // Unix-ms timestamps come off the wire timezone-agnostic; the mapping must tag the + // resulting DateTime as Utc so downstream serializers (JSON, OPC UA types) don't apply + // an unexpected local-time offset. + var wire = new GalaxyHistoricalEvent + { + EventId = "e", + EventTimeUtcUnixMs = 1_000L, + ReceivedTimeUtcUnixMs = 2_000L, + }; + + var domain = GalaxyProxyDriver.ToHistoricalEvent(wire); + + domain.EventTimeUtc.Kind.ShouldBe(DateTimeKind.Utc); + domain.ReceivedTimeUtc.Kind.ShouldBe(DateTimeKind.Utc); + } +}