From 36f7c3c5bfc806f8af6629b011046a14ca8fdb42 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 26 Jun 2026 17:07:59 -0400 Subject: [PATCH] =?UTF-8?q?feat(historian-gateway):=20read=20cutover=20?= =?UTF-8?q?=E2=80=94=20AddServerHistorian=20builds=20GatewayHistorianDataS?= =?UTF-8?q?ource?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii --- ...wayHistorianServiceCollectionExtensions.cs | 42 ++++++ .../HistorianGatewayClientAdapter.cs | 126 ++++++++++++++++++ ...WW.OtOpcUa.Driver.Historian.Gateway.csproj | 5 + src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs | 14 +- .../ZB.MOM.WW.OtOpcUa.Host.csproj | 1 + .../HistorianGatewayClientAdapterTests.cs | 32 +++++ ...pcUa.Driver.Historian.Gateway.Tests.csproj | 2 + 7 files changed, 213 insertions(+), 9 deletions(-) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianServiceCollectionExtensions.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/HistorianGatewayClientAdapter.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/HistorianGatewayClientAdapterTests.cs diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianServiceCollectionExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianServiceCollectionExtensions.cs new file mode 100644 index 00000000..b52fa217 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianServiceCollectionExtensions.cs @@ -0,0 +1,42 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Runtime.Historian; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway; + +/// +/// Host-callable factory that builds the gateway-backed server-side HistoryRead data source. The +/// Host's AddServerHistorian wiring supplies as its +/// Func<ServerHistorianOptions, IServiceProvider, IHistorianDataSource>, keeping the +/// concrete package-client dependency inside this driver project (the Host references only the +/// driver, not the package client directly). +/// +public static class GatewayHistorian +{ + /// + /// Builds a over a lazily connected + /// mapped from the bound + /// . Resolves an and the data + /// source's from , falling back to + /// the null implementations when absent (e.g. minimal test providers). Performs no network I/O — + /// the underlying channel dials on first use. + /// + /// The bound ServerHistorian configuration. + /// The resolving service provider (used only to locate logging services). + /// The gateway-backed . + public static IHistorianDataSource CreateDataSource(ServerHistorianOptions options, IServiceProvider services) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(services); + + var loggerFactory = services.GetService() ?? NullLoggerFactory.Instance; + var logger = services.GetService>() + ?? NullLogger.Instance; + + return new GatewayHistorianDataSource( + HistorianGatewayClientAdapter.Create(options, loggerFactory), + logger); + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/HistorianGatewayClientAdapter.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/HistorianGatewayClientAdapter.cs new file mode 100644 index 00000000..f69426e8 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/HistorianGatewayClientAdapter.cs @@ -0,0 +1,126 @@ +using Microsoft.Extensions.Logging; +using ZB.MOM.WW.HistorianGateway.Client; +using ZB.MOM.WW.HistorianGateway.Contracts.Grpc; +using ZB.MOM.WW.OtOpcUa.Runtime.Historian; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway; + +/// +/// Concrete backed by the published +/// package client. Each seam method forwards directly to the +/// matching client wrapper — both sides speak the same generated historian_gateway.v1 proto +/// types, so no shape translation happens here. The package client's typed exception hierarchy +/// (HistorianGatewayUnavailableException et al.) is allowed to surface unchanged; the +/// records it as a health failure and the node manager +/// turns it into a Bad HistoryRead result. +/// +/// +/// +/// Lazy channel. calls , +/// which constructs a GrpcChannel over a SocketsHttpHandler without opening a +/// connection — the first RPC dials. Constructing the adapter therefore performs no network I/O, +/// which the offline seam tests rely on (they build from bogus endpoints and must not connect). +/// +/// +public sealed class HistorianGatewayClientAdapter : IHistorianGatewayClient, IDisposable +{ + private readonly HistorianGatewayClient _inner; + + private HistorianGatewayClientAdapter(HistorianGatewayClient inner) => _inner = inner; + + /// + /// Builds an adapter over a freshly created package client mapped from the bound + /// . No connection is opened (lazy channel). + /// + /// The bound ServerHistorian configuration (endpoint, key, TLS posture). + /// Logger factory threaded into the package client's channel diagnostics. + /// A ready-to-use adapter whose underlying channel has not yet dialed the gateway. + public static HistorianGatewayClientAdapter Create(ServerHistorianOptions options, ILoggerFactory loggerFactory) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(loggerFactory); + + var clientOptions = new HistorianGatewayClientOptions + { + Endpoint = new Uri(options.Endpoint), + ApiKey = options.ApiKey, + UseTls = options.UseTls, + CaCertificatePath = options.CaCertificatePath, + // INVERTED mapping: ServerHistorianOptions.AllowUntrustedServerCertificate (opt-in to accept + // a self-signed cert) is the negation of the client's RequireCertificateValidation. Allowing + // an untrusted cert == not requiring validation; a pinned CaCertificatePath always verifies. + RequireCertificateValidation = !options.AllowUntrustedServerCertificate, + DefaultCallTimeout = options.CallTimeout, + LoggerFactory = loggerFactory, + }; + + return new HistorianGatewayClientAdapter(HistorianGatewayClient.Create(clientOptions)); + } + + /// + public IAsyncEnumerable ReadRawAsync( + string tag, DateTime startUtc, DateTime endUtc, int maxValues, CancellationToken ct) => + _inner.ReadRawAsync(tag, startUtc, endUtc, maxValues, ct); + + /// + public IAsyncEnumerable ReadAggregateAsync( + string tag, DateTime startUtc, DateTime endUtc, RetrievalMode mode, TimeSpan interval, CancellationToken ct) => + _inner.ReadAggregateAsync(tag, startUtc, endUtc, mode, interval, ct); + + /// + public Task> ReadAtTimeAsync( + string tag, IReadOnlyList timestampsUtc, CancellationToken ct) => + _inner.ReadAtTimeAsync(tag, timestampsUtc, ct); + + /// + /// + /// is rendered into the gateway's one server-filterable predicate — + /// a Source_Object filter the SQL ReadEvents + /// path binds as WHERE Source_Object = @source. A null source passes a null filter + /// (full window). is intentionally ignored here: the gateway wire + /// contract carries no per-call cap, so the cap is enforced upstream by + /// via early stream termination. + /// + public IAsyncEnumerable ReadEventsAsync( + string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, CancellationToken ct) + { + HistorianEventFilter? filter = sourceName is null + ? null + : new HistorianEventFilter + { + PropertyName = "Source_Object", + Comparison = HistorianEventComparison.Equal, + Value = sourceName, + }; + + return _inner.ReadEventsAsync(startUtc, endUtc, filter, ct); + } + + /// + public Task WriteLiveValuesAsync( + string tag, IReadOnlyList values, CancellationToken ct) => + _inner.WriteLiveValuesAsync(tag, values, ct); + + /// + public Task SendEventAsync(HistorianEvent evt, CancellationToken ct) => + _inner.SendEventAsync(evt, ct); + + /// + public Task EnsureTagsAsync( + IReadOnlyList definitions, CancellationToken ct) => + _inner.EnsureTagsAsync(definitions, ct); + + /// + public Task ProbeAsync(CancellationToken ct) => _inner.ProbeAsync(ct); + + /// + public Task GetConnectionStatusAsync(CancellationToken ct) => + _inner.GetConnectionStatusAsync(ct); + + /// Disposes the underlying package client (and its channel). Prefer . + public void Dispose() => _inner.Dispose(); + + /// Asynchronously disposes the underlying package client (and its channel). + /// A task that completes when the client has been disposed. + public ValueTask DisposeAsync() => _inner.DisposeAsync(); +} 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 index 63c34238..8660ec4f 100644 --- 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 @@ -11,6 +11,11 @@ + + diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs index 59a824a2..478dd454 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs @@ -22,6 +22,7 @@ using ZB.MOM.WW.OtOpcUa.Host.Health; using ZB.MOM.WW.OtOpcUa.Host.Logging; using ZB.MOM.WW.OtOpcUa.Host.Observability; using ZB.MOM.WW.OtOpcUa.Host.OpcUa; +using ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway; using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client; using ZB.MOM.WW.OtOpcUa.OpcUaServer; using ZB.MOM.WW.OtOpcUa.Runtime.Historian; @@ -109,17 +110,12 @@ if (hasDriver) // Config-gated server-side HistoryRead backend. When the ServerHistorian section is enabled this // overrides the NullHistorianDataSource default from AddOtOpcUaRuntime (last registration wins) with - // a read-only WonderwareHistorianClient the node manager's HistoryRead overrides block-bridge to. - // The client is supplied here because the Host is the only project that references the Wonderware - // client — Runtime owns the gating, the Host supplies the concrete read downstream. + // a read-only HistorianGateway-backed data source the node manager's HistoryRead overrides + // block-bridge to. The factory lives in the Gateway driver (which owns the package-client adapter + // and the ServerHistorianOptions -> client-options mapping); Runtime owns the gating. builder.Services.AddServerHistorian( builder.Configuration, - (opts, sp) => new WonderwareHistorianClient( - new WonderwareHistorianClientOptions(opts.Host, opts.Port, opts.SharedSecret) - { - UseTls = opts.UseTls, ServerCertThumbprint = opts.ServerCertThumbprint, - }, - sp.GetService>())); + (opts, sp) => GatewayHistorian.CreateDataSource(opts, sp)); // Bind every cross-platform driver factory before AddAkka resolves IDriverFactory — replaces // the F7-default NullDriverFactory with a real DriverFactoryRegistryAdapter so DriverHostActor diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj b/src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj index a46c3693..1c6275ec 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj @@ -61,6 +61,7 @@ + diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/HistorianGatewayClientAdapterTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/HistorianGatewayClientAdapterTests.cs new file mode 100644 index 00000000..08dcc002 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/HistorianGatewayClientAdapterTests.cs @@ -0,0 +1,32 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Runtime.Historian; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests; + +/// +/// Read-cutover seam tests (T10). Both assert offline construction only — the package client builds +/// its GrpcChannel lazily, so neither the adapter ctor nor the factory dials the gateway. A +/// bogus/unreachable endpoint must therefore construct without throwing or performing network I/O. +/// +public sealed class HistorianGatewayClientAdapterTests +{ + [Fact] + public void Adapter_constructs_from_options_without_dialing() + { + // Constructing the channel must not perform network I/O (lazy connect). + var opts = new ServerHistorianOptions { Enabled = true, Endpoint = "https://localhost:5222", ApiKey = "histgw_x_y" }; + using var adapter = HistorianGatewayClientAdapter.Create(opts, NullLoggerFactory.Instance); + Assert.NotNull(adapter); + } + + [Fact] + public void Factory_builds_GatewayHistorianDataSource() + { + var opts = new ServerHistorianOptions { Enabled = true, Endpoint = "https://localhost:5222", ApiKey = "histgw_x_y" }; + using var services = new ServiceCollection().BuildServiceProvider(); + var dataSource = GatewayHistorian.CreateDataSource(opts, services); + Assert.IsType(dataSource); + } +} 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 index f4ddf47d..16e7fc33 100644 --- 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 @@ -13,6 +13,8 @@ + + all runtime; build; native; contentfiles; analyzers; buildtransitive