feat(historian): AddServerHistorian DI + Host wiring of IHistorianDataSource
This commit is contained in:
@@ -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<ActorSystem> _actorSystemAccessor;
|
||||
private readonly ActorRegistry _actorRegistry;
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
@@ -45,6 +47,11 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
|
||||
/// <param name="deferredSink">The deferred address space sink that receives the real sink once the server is ready.</param>
|
||||
/// <param name="deferredServiceLevel">The deferred service level publisher that receives the real publisher once the server is ready.</param>
|
||||
/// <param name="userAuthenticator">The OPC UA user authenticator.</param>
|
||||
/// <param name="historianDataSource">The server-side HistoryRead backend resolved from DI — the
|
||||
/// <c>NullHistorianDataSource</c> default seeded by <c>AddOtOpcUaRuntime</c> (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 <c>AddServerHistorian</c> enabled it. Wired onto the node manager in
|
||||
/// <see cref="StartAsync"/>.</param>
|
||||
/// <param name="actorSystemAccessor">Lazy accessor for the running <see cref="ActorSystem"/>, used to
|
||||
/// resolve the DistributedPubSub mediator the inbound alarm-command router publishes through. Resolved
|
||||
/// lazily (mirroring <c>DpsScriptLogPublisher</c>) so construction never races Akka startup.</param>
|
||||
@@ -57,6 +64,7 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
|
||||
DeferredAddressSpaceSink deferredSink,
|
||||
DeferredServiceLevelPublisher deferredServiceLevel,
|
||||
IOpcUaUserAuthenticator userAuthenticator,
|
||||
IHistorianDataSource historianDataSource,
|
||||
Func<ActorSystem> 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<DriverHostActorKey>(out var driverHost) ? driverHost : null,
|
||||
logger: _loggerFactory.CreateLogger<ActorNodeWriteGateway>()));
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -100,6 +100,20 @@ if (hasDriver)
|
||||
},
|
||||
sp.GetService<ILogger<WonderwareHistorianClient>>()));
|
||||
|
||||
// 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<ILogger<WonderwareHistorianClient>>()));
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -22,5 +22,13 @@
|
||||
"DrainIntervalSeconds": 5,
|
||||
"Capacity": 1000000,
|
||||
"DeadLetterRetentionDays": 30
|
||||
},
|
||||
"ServerHistorian": {
|
||||
"Enabled": false,
|
||||
"Host": "localhost",
|
||||
"Port": 32569,
|
||||
"UseTls": false,
|
||||
"ServerCertThumbprint": null,
|
||||
"SharedSecret": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wire the server-side HistoryRead backend (the <see cref="IHistorianDataSource"/> the node
|
||||
/// manager's HistoryRead overrides block-bridge to) onto the created
|
||||
/// <see cref="OtOpcUaNodeManager"/>. The host calls this after start with the DI-resolved source —
|
||||
/// the <c>NullHistorianDataSource</c> default (GoodNoData-empty reads) or the configured Wonderware
|
||||
/// read client. Passing <c>null</c> restores the Null default (the property setter null-coalesces),
|
||||
/// i.e. "no historian". No-op (returns <c>false</c>) when the node manager has not been created yet,
|
||||
/// so the caller can detect a too-early call (mirrors <see cref="SetNodeWriteGateway"/>).
|
||||
/// </summary>
|
||||
/// <param name="source">The read backend invoked by the node manager's HistoryRead overrides; may be
|
||||
/// <c>null</c> to restore the Null default (GoodNoData-empty reads).</param>
|
||||
/// <returns><c>true</c> when the source was set on a live node manager; <c>false</c> when no node
|
||||
/// manager exists yet.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override MasterNodeManager CreateMasterNodeManager(
|
||||
IServerInternal server, ApplicationConfiguration configuration)
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Historian;
|
||||
|
||||
/// <summary>
|
||||
/// Binds the <c>ServerHistorian</c> configuration section that gates the server-side
|
||||
/// HistoryRead backend. When <see cref="Enabled"/> is <c>true</c>, <c>AddServerHistorian</c>
|
||||
/// registers a read-only <c>WonderwareHistorianClient</c> (supplied by the Host) as the
|
||||
/// <c>IHistorianDataSource</c> in place of the <c>NullHistorianDataSource</c> default;
|
||||
/// otherwise the Null default survives and HistoryRead returns <c>GoodNoData</c>-empty.
|
||||
/// <para>
|
||||
/// This is the READ path only — there are no DatabasePath / drain / capacity / retention
|
||||
/// knobs (those belong to the write-side <c>AlarmHistorian</c> store-and-forward sink). The
|
||||
/// client's own <c>CallTimeout</c> bounds each read; the node manager adds no extra timeout.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class ServerHistorianOptions
|
||||
{
|
||||
/// <summary>The configuration section name this options class binds.</summary>
|
||||
public const string SectionName = "ServerHistorian";
|
||||
|
||||
/// <summary>
|
||||
/// When <c>true</c>, the Wonderware read client is registered as the
|
||||
/// <c>IHistorianDataSource</c>; when <c>false</c> (the default) the no-op
|
||||
/// <c>NullHistorianDataSource</c> stays in place and HistoryRead returns empty.
|
||||
/// </summary>
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
/// <summary>TCP hostname or IP address the Wonderware historian sidecar listens on.</summary>
|
||||
public string Host { get; init; } = "localhost";
|
||||
|
||||
/// <summary>TCP port the Wonderware historian sidecar listens on.</summary>
|
||||
public int Port { get; init; }
|
||||
|
||||
/// <summary>When <c>true</c>, the client connects over TLS.</summary>
|
||||
public bool UseTls { get; init; }
|
||||
|
||||
/// <summary>Expected TLS server certificate thumbprint (hex, no spaces). Null or empty disables pinning.</summary>
|
||||
public string? ServerCertThumbprint { get; init; }
|
||||
|
||||
/// <summary>Per-process shared secret the sidecar verifies in the Hello frame.</summary>
|
||||
public string SharedSecret { get; init; } = "";
|
||||
|
||||
/// <summary>Returns operator-facing misconfiguration warnings for an <c>Enabled</c> historian
|
||||
/// (empty when disabled or correctly configured). Pure — the registration logs each entry.</summary>
|
||||
/// <returns>Zero or more human-readable warning messages.</returns>
|
||||
public IEnumerable<string> 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.";
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ public static class ServiceCollectionExtensions
|
||||
public static IServiceCollection AddOtOpcUaRuntime(this IServiceCollection services)
|
||||
{
|
||||
services.TryAddSingleton<IAlarmHistorianSink>(NullAlarmHistorianSink.Instance);
|
||||
services.TryAddSingleton<IHistorianDataSource>(NullHistorianDataSource.Instance);
|
||||
services.TryAddSingleton<IDriverFactory>(NullDriverFactory.Instance);
|
||||
services.TryAddSingleton<IOpcUaAddressSpaceSink>(NullOpcUaAddressSpaceSink.Instance);
|
||||
services.TryAddSingleton<IServiceLevelPublisher>(NullServiceLevelPublisher.Instance);
|
||||
@@ -93,6 +94,38 @@ public static class ServiceCollectionExtensions
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Config-gated server-side HistoryRead backend. When the <c>ServerHistorian</c> section has
|
||||
/// <c>Enabled=true</c>, registers the <paramref name="dataSourceFactory"/>-supplied
|
||||
/// <see cref="IHistorianDataSource"/> (the read-only Wonderware client) overriding the
|
||||
/// <see cref="NullHistorianDataSource"/> default from <see cref="AddOtOpcUaRuntime"/>. Otherwise
|
||||
/// a no-op (the Null default stays and the node manager's HistoryRead returns
|
||||
/// <c>GoodNoData</c>-empty). The data source is injected so the Wonderware client can be supplied
|
||||
/// by the Host, which is the only project that references it.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection to register with.</param>
|
||||
/// <param name="configuration">The configuration carrying the <c>ServerHistorian</c> section.</param>
|
||||
/// <param name="dataSourceFactory">
|
||||
/// Factory the Host supplies to build the concrete read <see cref="IHistorianDataSource"/>
|
||||
/// (the Wonderware client) from the bound options + the resolving provider.
|
||||
/// </param>
|
||||
/// <returns>The same <paramref name="services"/> instance for chaining.</returns>
|
||||
public static IServiceCollection AddServerHistorian(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration,
|
||||
Func<ServerHistorianOptions, IServiceProvider, IHistorianDataSource> dataSourceFactory)
|
||||
{
|
||||
var opts = configuration.GetSection(ServerHistorianOptions.SectionName).Get<ServerHistorianOptions>();
|
||||
if (opts is not { Enabled: true }) return services; // leave the Null default from AddOtOpcUaRuntime
|
||||
|
||||
foreach (var warning in opts.Validate())
|
||||
Serilog.Log.Logger.ForContext<ServerHistorianOptions>().Warning("ServerHistorian config: {ServerHistorianConfigWarning}", warning);
|
||||
|
||||
// Last-registration-wins over the TryAddSingleton Null default seeded by AddOtOpcUaRuntime.
|
||||
services.AddSingleton<IHistorianDataSource>(sp => dataSourceFactory(opts, sp));
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Spawns the per-node driver-role actors on the host's <see cref="ActorSystem"/>:
|
||||
/// <see cref="DriverHostActor"/> (one per node), <see cref="DbHealthProbeActor"/>
|
||||
|
||||
Reference in New Issue
Block a user