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); + } +}