diff --git a/Directory.Packages.props b/Directory.Packages.props index 460f35ac..3c258375 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -120,5 +120,7 @@ + + \ No newline at end of file diff --git a/NuGet.config b/NuGet.config index d656667c..2d7a0dcd 100644 --- a/NuGet.config +++ b/NuGet.config @@ -23,6 +23,8 @@ + + diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx index 6b7bc0bc..1c4b6908 100644 --- a/ZB.MOM.WW.OtOpcUa.slnx +++ b/ZB.MOM.WW.OtOpcUa.slnx @@ -26,6 +26,7 @@ + @@ -86,6 +87,7 @@ + diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/IHistorianGatewayClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/IHistorianGatewayClient.cs new file mode 100644 index 00000000..efb969a4 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/IHistorianGatewayClient.cs @@ -0,0 +1,64 @@ +using ZB.MOM.WW.HistorianGateway.Contracts.Grpc; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway; + +/// +/// Abstraction over the HistorianGateway gRPC client surface consumed by the OtOpcUa historian +/// backend driver. Proto-typed (the wire contract lives in +/// ZB.MOM.WW.HistorianGateway.Contracts.Grpc); the concrete adapter wrapping +/// HistorianGatewayClient is supplied by a later task. The seam exists so the driver and +/// its tests can depend on a fake without a live gateway. +/// +public interface IHistorianGatewayClient : IAsyncDisposable +{ + /// Streams raw historian samples for a tag over a time window. + IAsyncEnumerable ReadRawAsync( + string tag, + DateTime startUtc, + DateTime endUtc, + int maxValues, + CancellationToken ct); + + /// Streams aggregate samples for a tag using the given retrieval mode and interval. + IAsyncEnumerable ReadAggregateAsync( + string tag, + DateTime startUtc, + DateTime endUtc, + RetrievalMode mode, + TimeSpan interval, + CancellationToken ct); + + /// Reads the samples nearest to each of the requested timestamps (unary). + Task> ReadAtTimeAsync( + string tag, + IReadOnlyList timestampsUtc, + CancellationToken ct); + + /// Streams historian events over a window, optionally filtered to a single source name. + IAsyncEnumerable ReadEventsAsync( + string? sourceName, + DateTime startUtc, + DateTime endUtc, + int maxEvents, + CancellationToken ct); + + /// Writes live values for a tag through the gateway's SQL live-write path. + Task WriteLiveValuesAsync( + string tag, + IReadOnlyList values, + CancellationToken ct); + + /// Sends a single historian event. + Task SendEventAsync(HistorianEvent evt, CancellationToken ct); + + /// Ensures the supplied tag definitions exist (create-or-update). + Task EnsureTagsAsync( + IReadOnlyList definitions, + CancellationToken ct); + + /// Probes gateway/historian reachability. + Task ProbeAsync(CancellationToken ct); + + /// Reads the gateway's current historian connection status. + Task GetConnectionStatusAsync(CancellationToken ct); +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.csproj new file mode 100644 index 00000000..aad1040c --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.csproj @@ -0,0 +1,24 @@ + + + + true + true + $(NoWarn);CS1591 + ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway + + + + + + + + + + + + + + + + + diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/FakeHistorianGatewayClient.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/FakeHistorianGatewayClient.cs new file mode 100644 index 00000000..1eda81d0 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/FakeHistorianGatewayClient.cs @@ -0,0 +1,214 @@ +using System.Runtime.CompilerServices; +using ZB.MOM.WW.HistorianGateway.Contracts.Grpc; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests; + +/// +/// Reusable in-memory test double for . Every method returns +/// from a public settable result field and records its call arguments into public fields, so the +/// later driver tasks (T7/T8/T11/T12/T14) can drive behaviour and assert on what the driver sent +/// without a live gateway. Throw fields let a test simulate transport faults (e.g. an +/// RpcException) per operation; reads share . +/// +public sealed class FakeHistorianGatewayClient : IHistorianGatewayClient +{ + // ---- ReadRaw ------------------------------------------------------------------------------- + public IReadOnlyList RawSamples = Array.Empty(); + public string? LastReadRawTag; + public DateTime LastReadRawStartUtc; + public DateTime LastReadRawEndUtc; + public int LastReadRawMaxValues; + public int ReadRawCallCount; + + // ---- ReadAggregate ------------------------------------------------------------------------- + public IReadOnlyList AggregateSamples = Array.Empty(); + public string? LastAggregateTag; + public DateTime LastAggregateStartUtc; + public DateTime LastAggregateEndUtc; + public RetrievalMode LastAggregateMode; + public TimeSpan LastAggregateInterval; + public int ReadAggregateCallCount; + + // ---- ReadAtTime ---------------------------------------------------------------------------- + public IReadOnlyList AtTimeSamples = Array.Empty(); + public string? LastReadAtTimeTag; + public IReadOnlyList? LastReadAtTimeTimestamps; + public int ReadAtTimeCallCount; + + // ---- ReadEvents ---------------------------------------------------------------------------- + public IReadOnlyList Events = Array.Empty(); + public string? LastReadEventsSourceName; + public DateTime LastReadEventsStartUtc; + public DateTime LastReadEventsEndUtc; + public int LastReadEventsMaxEvents; + public int ReadEventsCallCount; + + /// Thrown (deferred to first enumeration) by every read method when set. + public Exception? ThrowOnRead; + + // ---- WriteLiveValues ----------------------------------------------------------------------- + public WriteAck WriteLiveValuesResult = new() { Success = true }; + public string? LastWriteLiveTag; + public IReadOnlyList? LastWriteLiveValues; + public int WriteLiveValuesCallCount; + public Exception? WriteLiveValuesThrows; + + // ---- SendEvent ----------------------------------------------------------------------------- + public WriteAck SendEventResult = new() { Success = true }; + public HistorianEvent? LastSendEvent; + public int SendEventCallCount; + public Exception? SendEventThrows; + + // ---- EnsureTags ---------------------------------------------------------------------------- + public TagOperationResults EnsureTagsResult = new(); + public IReadOnlyList? LastEnsureDefinitions; + public int EnsureTagsCallCount; + public Exception? EnsureTagsThrows; + + // ---- Probe --------------------------------------------------------------------------------- + public bool ProbeResult = true; + public int ProbeCallCount; + public Exception? ProbeThrows; + + // ---- GetConnectionStatus ------------------------------------------------------------------- + public ConnectionStatus ConnectionStatus = new(); + public int GetConnectionStatusCallCount; + public Exception? GetConnectionStatusThrows; + + // ---- Dispose ------------------------------------------------------------------------------- + public int DisposeCallCount; + + public IAsyncEnumerable ReadRawAsync( + string tag, + DateTime startUtc, + DateTime endUtc, + int maxValues, + CancellationToken ct) + { + LastReadRawTag = tag; + LastReadRawStartUtc = startUtc; + LastReadRawEndUtc = endUtc; + LastReadRawMaxValues = maxValues; + ReadRawCallCount++; + return ToAsyncStream(RawSamples, ThrowOnRead, ct); + } + + public IAsyncEnumerable ReadAggregateAsync( + string tag, + DateTime startUtc, + DateTime endUtc, + RetrievalMode mode, + TimeSpan interval, + CancellationToken ct) + { + LastAggregateTag = tag; + LastAggregateStartUtc = startUtc; + LastAggregateEndUtc = endUtc; + LastAggregateMode = mode; + LastAggregateInterval = interval; + ReadAggregateCallCount++; + return ToAsyncStream(AggregateSamples, ThrowOnRead, ct); + } + + public Task> ReadAtTimeAsync( + string tag, + IReadOnlyList timestampsUtc, + CancellationToken ct) + { + LastReadAtTimeTag = tag; + LastReadAtTimeTimestamps = timestampsUtc; + ReadAtTimeCallCount++; + return ThrowOnRead is not null + ? Task.FromException>(ThrowOnRead) + : Task.FromResult(AtTimeSamples); + } + + public IAsyncEnumerable ReadEventsAsync( + string? sourceName, + DateTime startUtc, + DateTime endUtc, + int maxEvents, + CancellationToken ct) + { + LastReadEventsSourceName = sourceName; + LastReadEventsStartUtc = startUtc; + LastReadEventsEndUtc = endUtc; + LastReadEventsMaxEvents = maxEvents; + ReadEventsCallCount++; + return ToAsyncStream(Events, ThrowOnRead, ct); + } + + public Task WriteLiveValuesAsync( + string tag, + IReadOnlyList values, + CancellationToken ct) + { + LastWriteLiveTag = tag; + LastWriteLiveValues = values; + WriteLiveValuesCallCount++; + return WriteLiveValuesThrows is not null + ? Task.FromException(WriteLiveValuesThrows) + : Task.FromResult(WriteLiveValuesResult); + } + + public Task SendEventAsync(HistorianEvent evt, CancellationToken ct) + { + LastSendEvent = evt; + SendEventCallCount++; + return SendEventThrows is not null + ? Task.FromException(SendEventThrows) + : Task.FromResult(SendEventResult); + } + + public Task EnsureTagsAsync( + IReadOnlyList definitions, + CancellationToken ct) + { + LastEnsureDefinitions = definitions; + EnsureTagsCallCount++; + return EnsureTagsThrows is not null + ? Task.FromException(EnsureTagsThrows) + : Task.FromResult(EnsureTagsResult); + } + + public Task ProbeAsync(CancellationToken ct) + { + ProbeCallCount++; + return ProbeThrows is not null + ? Task.FromException(ProbeThrows) + : Task.FromResult(ProbeResult); + } + + public Task GetConnectionStatusAsync(CancellationToken ct) + { + GetConnectionStatusCallCount++; + return GetConnectionStatusThrows is not null + ? Task.FromException(GetConnectionStatusThrows) + : Task.FromResult(ConnectionStatus); + } + + public ValueTask DisposeAsync() + { + DisposeCallCount++; + return ValueTask.CompletedTask; + } + + private static async IAsyncEnumerable ToAsyncStream( + IReadOnlyList items, + Exception? error, + [EnumeratorCancellation] CancellationToken ct) + { + if (error is not null) + { + throw error; + } + + foreach (var item in items) + { + ct.ThrowIfCancellationRequested(); + yield return item; + } + + await Task.CompletedTask.ConfigureAwait(false); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/ProjectSmokeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/ProjectSmokeTests.cs new file mode 100644 index 00000000..f4c3e324 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/ProjectSmokeTests.cs @@ -0,0 +1,13 @@ +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests; + +public sealed class ProjectSmokeTests +{ + [Fact] + public void GatewayClientSeam_IsReferenceable() + { + var t = typeof(IHistorianGatewayClient); + Assert.Equal("IHistorianGatewayClient", t.Name); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests.csproj b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests.csproj new file mode 100644 index 00000000..f4ddf47d --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + false + true + ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + +