From a6f1f4ef1527b2c2cacc9113943ff9fcf53f6cab Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 14 Jun 2026 20:17:10 -0400 Subject: [PATCH] feat(historian): AddServerHistorian DI + Host wiring of IHistorianDataSource --- .../OpcUa/OtOpcUaServerHostedService.cs | 16 ++ src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs | 14 ++ .../ZB.MOM.WW.OtOpcUa.Host/appsettings.json | 8 + .../OtOpcUaSdkServer.cs | 23 ++ .../Historian/ServerHistorianOptions.cs | 55 +++++ .../ServiceCollectionExtensions.cs | 33 +++ .../Historian/AddServerHistorianTests.cs | 197 ++++++++++++++++++ 7 files changed, 346 insertions(+) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/AddServerHistorianTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs index 37f28d86..2819657b 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.OpcUaServer; using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; using ZB.MOM.WW.OtOpcUa.Runtime; @@ -30,6 +31,7 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl private readonly DeferredAddressSpaceSink _deferredSink; private readonly DeferredServiceLevelPublisher _deferredServiceLevel; private readonly IOpcUaUserAuthenticator _userAuthenticator; + private readonly IHistorianDataSource _historianDataSource; private readonly Func _actorSystemAccessor; private readonly ActorRegistry _actorRegistry; private readonly ILoggerFactory _loggerFactory; @@ -45,6 +47,11 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl /// The deferred address space sink that receives the real sink once the server is ready. /// The deferred service level publisher that receives the real publisher once the server is ready. /// The OPC UA user authenticator. + /// The server-side HistoryRead backend resolved from DI — the + /// NullHistorianDataSource default seeded by AddOtOpcUaRuntime (which runs on this driver + /// node, the same source the address-space sink + node-write gateway come from), or the configured + /// Wonderware read client when AddServerHistorian enabled it. Wired onto the node manager in + /// . /// Lazy accessor for the running , used to /// resolve the DistributedPubSub mediator the inbound alarm-command router publishes through. Resolved /// lazily (mirroring DpsScriptLogPublisher) so construction never races Akka startup. @@ -57,6 +64,7 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl DeferredAddressSpaceSink deferredSink, DeferredServiceLevelPublisher deferredServiceLevel, IOpcUaUserAuthenticator userAuthenticator, + IHistorianDataSource historianDataSource, Func actorSystemAccessor, ActorRegistry actorRegistry, ILoggerFactory loggerFactory) @@ -65,6 +73,7 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl _deferredSink = deferredSink; _deferredServiceLevel = deferredServiceLevel; _userAuthenticator = userAuthenticator; + _historianDataSource = historianDataSource; _actorSystemAccessor = actorSystemAccessor; _actorRegistry = actorRegistry; _loggerFactory = loggerFactory; @@ -148,6 +157,11 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl resolveDriverHost: () => _actorRegistry.TryGet(out var driverHost) ? driverHost : null, logger: _loggerFactory.CreateLogger())); + // Wire the server-side read backend resolved from DI — the NullHistorianDataSource default (when + // the ServerHistorian section is disabled) or the configured Wonderware read client (when enabled). + // The node manager's HistoryRead overrides block-bridge to whatever source is set here. + _server.SetHistorianDataSource(_historianDataSource); + // ServiceLevel publisher needs IServerInternal — only available after Start. if (_server.CurrentInstance is { } serverInternal) { @@ -171,6 +185,8 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl _deferredServiceLevel.SetInner(null); // Restore the Null write gateway so a late client write doesn't Ask a stopping DriverHostActor. _server?.SetNodeWriteGateway(null); + // Restore the Null historian so a late HistoryRead doesn't hit a disposed read client. + _server?.SetHistorianDataSource(null); return Task.CompletedTask; } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs index f2d36aea..92974dd1 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs @@ -100,6 +100,20 @@ if (hasDriver) }, sp.GetService>())); + // 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. + 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>())); + // Bind every cross-platform driver factory before AddAkka resolves IDriverFactory — replaces // the F7-default NullDriverFactory with a real DriverFactoryRegistryAdapter so DriverHostActor // can materialise real IDriver instances on deploy. diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.json b/src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.json index 29d09f1d..b712c3a1 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.json +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.json @@ -22,5 +22,13 @@ "DrainIntervalSeconds": 5, "Capacity": 1000000, "DeadLetterRetentionDays": 30 + }, + "ServerHistorian": { + "Enabled": false, + "Host": "localhost", + "Port": 32569, + "UseTls": false, + "ServerCertThumbprint": null, + "SharedSecret": "" } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaSdkServer.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaSdkServer.cs index b9275efb..48fbdc1d 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaSdkServer.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaSdkServer.cs @@ -1,6 +1,7 @@ using Opc.Ua; using Opc.Ua.Server; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer; @@ -59,6 +60,28 @@ public sealed class OtOpcUaSdkServer : StandardServer return true; } + /// + /// Wire the server-side HistoryRead backend (the the node + /// manager's HistoryRead overrides block-bridge to) onto the created + /// . The host calls this after start with the DI-resolved source — + /// the NullHistorianDataSource default (GoodNoData-empty reads) or the configured Wonderware + /// read client. Passing null restores the Null default (the property setter null-coalesces), + /// i.e. "no historian". No-op (returns false) when the node manager has not been created yet, + /// so the caller can detect a too-early call (mirrors ). + /// + /// The read backend invoked by the node manager's HistoryRead overrides; may be + /// null to restore the Null default (GoodNoData-empty reads). + /// true when the source was set on a live node manager; false when no node + /// manager exists yet. + public bool SetHistorianDataSource(IHistorianDataSource? source) + { + if (_otOpcUaNodeManager is null) return false; + // The HistorianDataSource setter null-coalesces to the Null default, so a null source is intentional + // (restores GoodNoData-empty reads); forgive the nullable-in here. + _otOpcUaNodeManager.HistorianDataSource = source!; + return true; + } + /// protected override MasterNodeManager CreateMasterNodeManager( IServerInternal server, ApplicationConfiguration configuration) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.cs new file mode 100644 index 00000000..c63cb16a --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.cs @@ -0,0 +1,55 @@ +using System.Collections.Generic; + +namespace ZB.MOM.WW.OtOpcUa.Runtime.Historian; + +/// +/// Binds the ServerHistorian configuration section that gates the server-side +/// HistoryRead backend. When is true, AddServerHistorian +/// registers a read-only WonderwareHistorianClient (supplied by the Host) as the +/// IHistorianDataSource in place of the NullHistorianDataSource default; +/// otherwise the Null default survives and HistoryRead returns GoodNoData-empty. +/// +/// This is the READ path only — there are no DatabasePath / drain / capacity / retention +/// knobs (those belong to the write-side AlarmHistorian store-and-forward sink). The +/// client's own CallTimeout bounds each read; the node manager adds no extra timeout. +/// +/// +public sealed class ServerHistorianOptions +{ + /// The configuration section name this options class binds. + public const string SectionName = "ServerHistorian"; + + /// + /// When true, the Wonderware read client is registered as the + /// IHistorianDataSource; when false (the default) the no-op + /// NullHistorianDataSource stays in place and HistoryRead returns empty. + /// + public bool Enabled { get; init; } + + /// TCP hostname or IP address the Wonderware historian sidecar listens on. + public string Host { get; init; } = "localhost"; + + /// TCP port the Wonderware historian sidecar listens on. + public int Port { get; init; } + + /// When true, the client connects over TLS. + public bool UseTls { get; init; } + + /// Expected TLS server certificate thumbprint (hex, no spaces). Null or empty disables pinning. + public string? ServerCertThumbprint { get; init; } + + /// Per-process shared secret the sidecar verifies in the Hello frame. + public string SharedSecret { get; init; } = ""; + + /// Returns operator-facing misconfiguration warnings for an Enabled historian + /// (empty when disabled or correctly configured). Pure — the registration logs each entry. + /// Zero or more human-readable warning messages. + public IEnumerable Validate() + { + if (!Enabled) yield break; + if (string.IsNullOrWhiteSpace(SharedSecret)) + yield return "ServerHistorian:SharedSecret is empty while the historian is enabled — the Wonderware sidecar Hello frame will carry an empty secret."; + if (Port <= 0) + yield return $"ServerHistorian:Port is {Port} — must be > 0; the read client cannot dial the sidecar."; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs index fc89162c..e31a023a 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs @@ -42,6 +42,7 @@ public static class ServiceCollectionExtensions public static IServiceCollection AddOtOpcUaRuntime(this IServiceCollection services) { services.TryAddSingleton(NullAlarmHistorianSink.Instance); + services.TryAddSingleton(NullHistorianDataSource.Instance); services.TryAddSingleton(NullDriverFactory.Instance); services.TryAddSingleton(NullOpcUaAddressSpaceSink.Instance); services.TryAddSingleton(NullServiceLevelPublisher.Instance); @@ -93,6 +94,38 @@ public static class ServiceCollectionExtensions return services; } + /// + /// Config-gated server-side HistoryRead backend. When the ServerHistorian section has + /// Enabled=true, registers the -supplied + /// (the read-only Wonderware client) overriding the + /// default from . Otherwise + /// a no-op (the Null default stays and the node manager's HistoryRead returns + /// GoodNoData-empty). The data source is injected so the Wonderware client can be supplied + /// by the Host, which is the only project that references it. + /// + /// The service collection to register with. + /// The configuration carrying the ServerHistorian section. + /// + /// Factory the Host supplies to build the concrete read + /// (the Wonderware client) from the bound options + the resolving provider. + /// + /// The same instance for chaining. + public static IServiceCollection AddServerHistorian( + this IServiceCollection services, + IConfiguration configuration, + Func dataSourceFactory) + { + var opts = configuration.GetSection(ServerHistorianOptions.SectionName).Get(); + if (opts is not { Enabled: true }) return services; // leave the Null default from AddOtOpcUaRuntime + + foreach (var warning in opts.Validate()) + Serilog.Log.Logger.ForContext().Warning("ServerHistorian config: {ServerHistorianConfigWarning}", warning); + + // Last-registration-wins over the TryAddSingleton Null default seeded by AddOtOpcUaRuntime. + services.AddSingleton(sp => dataSourceFactory(opts, sp)); + return services; + } + /// /// Spawns the per-node driver-role actors on the host's : /// (one per node), diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/AddServerHistorianTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/AddServerHistorianTests.cs new file mode 100644 index 00000000..5ae9b0ba --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/AddServerHistorianTests.cs @@ -0,0 +1,197 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Runtime; +using ZB.MOM.WW.OtOpcUa.Runtime.Historian; + +namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Historian; + +/// +/// Verifies the config-gated AddServerHistorian registration: when the +/// ServerHistorian section is absent or disabled the +/// default seeded by AddOtOpcUaRuntime survives (the factory is never invoked); when it is +/// enabled the factory's returned wins +/// (last-registration-wins over the TryAddSingleton Null default). +/// +public sealed class AddServerHistorianTests +{ + /// A trivial read source the factory hands back when enabled; never actually invoked. + private sealed class FakeHistorianDataSource : IHistorianDataSource + { + private static readonly HistoryReadResult EmptyRead = new(Array.Empty(), null); + private static readonly HistoricalEventsResult EmptyEvents = new(Array.Empty(), null); + + public Task ReadRawAsync( + string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode, + CancellationToken cancellationToken) => Task.FromResult(EmptyRead); + + public Task ReadProcessedAsync( + string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval, + HistoryAggregateType aggregate, CancellationToken cancellationToken) => Task.FromResult(EmptyRead); + + public Task ReadAtTimeAsync( + string fullReference, IReadOnlyList timestampsUtc, + CancellationToken cancellationToken) => Task.FromResult(EmptyRead); + + public Task ReadEventsAsync( + string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, + CancellationToken cancellationToken) => Task.FromResult(EmptyEvents); + + public HistorianHealthSnapshot GetHealthSnapshot() => new( + TotalQueries: 0, TotalSuccesses: 0, TotalFailures: 0, ConsecutiveFailures: 0, + LastSuccessTime: null, LastFailureTime: null, LastError: null, + ProcessConnectionOpen: false, EventConnectionOpen: false, + ActiveProcessNode: null, ActiveEventNode: null, + Nodes: Array.Empty()); + + public void Dispose() + { + // Stateless fake; nothing to release. + } + } + + private static IConfiguration ConfigFrom(Dictionary values) + => new ConfigurationBuilder().AddInMemoryCollection(values).Build(); + + [Fact] + public void Section_absent_keeps_null_source_and_factory_not_invoked() + { + var factoryInvoked = false; + var services = new ServiceCollection(); + services.AddOtOpcUaRuntime(); + var config = ConfigFrom(new Dictionary()); + + services.AddServerHistorian(config, (_, _) => + { + factoryInvoked = true; + return new FakeHistorianDataSource(); + }); + + using var provider = services.BuildServiceProvider(); + provider.GetRequiredService().ShouldBeSameAs(NullHistorianDataSource.Instance); + factoryInvoked.ShouldBeFalse(); + } + + [Fact] + public void Section_disabled_keeps_null_source_and_factory_not_invoked() + { + var factoryInvoked = false; + var services = new ServiceCollection(); + services.AddOtOpcUaRuntime(); + var config = ConfigFrom(new Dictionary + { + ["ServerHistorian:Enabled"] = "false", + }); + + services.AddServerHistorian(config, (_, _) => + { + factoryInvoked = true; + return new FakeHistorianDataSource(); + }); + + using var provider = services.BuildServiceProvider(); + provider.GetRequiredService().ShouldBeSameAs(NullHistorianDataSource.Instance); + factoryInvoked.ShouldBeFalse(); + } + + [Fact] + public void Section_enabled_registers_factory_source() + { + var services = new ServiceCollection(); + services.AddOtOpcUaRuntime(); + var config = ConfigFrom(new Dictionary + { + ["ServerHistorian:Enabled"] = "true", + ["ServerHistorian:Host"] = "historian.example.com", + ["ServerHistorian:Port"] = "32569", + ["ServerHistorian:SharedSecret"] = "s", + }); + + services.AddServerHistorian(config, (_, _) => new FakeHistorianDataSource()); + + using var provider = services.BuildServiceProvider(); + var resolved = provider.GetRequiredService(); + resolved.ShouldBeOfType(); + resolved.ShouldNotBeSameAs(NullHistorianDataSource.Instance); + } + + [Fact] + public void Section_enabled_passes_bound_options_to_factory() + { + ServerHistorianOptions? seen = null; + var services = new ServiceCollection(); + services.AddOtOpcUaRuntime(); + var config = ConfigFrom(new Dictionary + { + ["ServerHistorian:Enabled"] = "true", + ["ServerHistorian:Host"] = "historian.example.com", + ["ServerHistorian:Port"] = "12345", + ["ServerHistorian:UseTls"] = "true", + ["ServerHistorian:ServerCertThumbprint"] = "AABBCCDDEEFF", + ["ServerHistorian:SharedSecret"] = "s", + }); + + services.AddServerHistorian(config, (opts, _) => + { + seen = opts; + return new FakeHistorianDataSource(); + }); + + using var provider = services.BuildServiceProvider(); + _ = provider.GetRequiredService(); + + seen.ShouldNotBeNull(); + seen.Host.ShouldBe("historian.example.com"); + seen.Port.ShouldBe(12345); + seen.UseTls.ShouldBeTrue(); + seen.ServerCertThumbprint.ShouldBe("AABBCCDDEEFF"); + } + + [Fact] + public void Validate_warns_on_empty_shared_secret_when_enabled() + { + var opts = new ServerHistorianOptions { Enabled = true, SharedSecret = "", Port = 32569 }; + opts.Validate().ShouldContain(w => w.Contains("SharedSecret")); + } + + [Fact] + public void Validate_warns_on_non_positive_port_when_enabled() + { + var opts = new ServerHistorianOptions { Enabled = true, SharedSecret = "s", Port = 0 }; + opts.Validate().ShouldContain(w => w.Contains("Port")); + } + + [Fact] + public void Validate_is_silent_when_correctly_configured() + { + new ServerHistorianOptions { Enabled = true, SharedSecret = "s", Port = 32569 }.Validate().ShouldBeEmpty(); + } + + [Fact] + public void Validate_is_silent_when_disabled() + { + new ServerHistorianOptions { Enabled = false, SharedSecret = "", Port = 0 }.Validate().ShouldBeEmpty(); + } + + [Fact] + public void Section_binds_host_port_tls_fields() + { + var config = ConfigFrom(new Dictionary + { + ["ServerHistorian:Host"] = "historian.example.com", + ["ServerHistorian:Port"] = "12345", + ["ServerHistorian:UseTls"] = "true", + ["ServerHistorian:ServerCertThumbprint"] = "AABBCCDDEEFF", + }); + + var opts = config.GetSection(ServerHistorianOptions.SectionName).Get(); + + opts.ShouldNotBeNull(); + opts.Host.ShouldBe("historian.example.com"); + opts.Port.ShouldBe(12345); + opts.UseTls.ShouldBeTrue(); + opts.ServerCertThumbprint.ShouldBe("AABBCCDDEEFF"); + } +}