diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs index 2eb9aa82..5a9344b1 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs @@ -22,7 +22,9 @@ 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.Wonderware.Client; using ZB.MOM.WW.OtOpcUa.OpcUaServer; +using ZB.MOM.WW.OtOpcUa.Runtime.Historian; using ZB.MOM.WW.OtOpcUa.Runtime.Scripting; using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; using ZB.MOM.WW.OtOpcUa.Runtime; @@ -83,6 +85,18 @@ builder.Services.AddOtOpcUaCluster(builder.Configuration); if (hasDriver) { builder.Services.AddOtOpcUaRuntime(); + + // Config-gated durable alarm-historian sink. When the AlarmHistorian section is enabled this + // overrides the NullAlarmHistorianSink default from AddOtOpcUaRuntime (last registration wins) + // with a SqliteStoreAndForwardSink draining to the Wonderware named-pipe writer. The writer is + // injected here because the Host is the only project that references the Wonderware client — + // Runtime owns the gating + Sqlite construction, the Host supplies the concrete downstream. + builder.Services.AddAlarmHistorian( + builder.Configuration, + (opts, sp) => new WonderwareHistorianClient( + new WonderwareHistorianClientOptions(opts.PipeName, opts.SharedSecret), + 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 cd01b339..31d5d140 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.json +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.json @@ -10,5 +10,11 @@ "Auth": { "DisableLogin": false } + }, + "AlarmHistorian": { + "Enabled": false, + "DatabasePath": "alarm-historian.db", + "PipeName": "OtOpcUaHistorian", + "SharedSecret": "" } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/AlarmHistorianOptions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/AlarmHistorianOptions.cs new file mode 100644 index 00000000..11121d79 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/AlarmHistorianOptions.cs @@ -0,0 +1,32 @@ +namespace ZB.MOM.WW.OtOpcUa.Runtime.Historian; + +/// +/// Binds the AlarmHistorian configuration section that gates the durable +/// store-and-forward alarm sink. When is true, +/// AddAlarmHistorian registers a SqliteStoreAndForwardSink (draining to the +/// Wonderware named-pipe writer supplied by the Host) in place of the +/// NullAlarmHistorianSink default; otherwise the Null default survives. +/// +public sealed class AlarmHistorianOptions +{ + /// The configuration section name this options class binds. + public const string SectionName = "AlarmHistorian"; + + /// + /// When true, the durable SQLite store-and-forward sink is registered; when + /// false (the default) the no-op NullAlarmHistorianSink stays in place. + /// + public bool Enabled { get; init; } + + /// Filesystem path to the local SQLite store-and-forward queue database. + public string DatabasePath { get; init; } = "alarm-historian.db"; + + /// Named-pipe name the Wonderware historian sidecar listens on. + public string PipeName { get; init; } = "OtOpcUaHistorian"; + + /// Per-process shared secret the sidecar verifies in the Hello frame. + public string SharedSecret { get; init; } = ""; + + /// Maximum number of queued rows the drain worker forwards in a single batch. + public int BatchSize { get; init; } = 100; +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs index eea9f934..7af3aec5 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs @@ -1,6 +1,7 @@ using Akka.Actor; using Akka.Hosting; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; @@ -48,6 +49,45 @@ public static class ServiceCollectionExtensions return services; } + /// + /// Config-gated durable alarm-historian sink. When the AlarmHistorian section has + /// Enabled=true, registers a (draining via the + /// -supplied writer) as the , + /// overriding the default. Otherwise a no-op (Null stays). + /// The writer is injected so the durable downstream (Wonderware named-pipe 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 AlarmHistorian section. + /// + /// Factory the Host supplies to build the concrete + /// (the Wonderware named-pipe client) from the bound options + the resolving provider. + /// + /// The same instance for chaining. + public static IServiceCollection AddAlarmHistorian( + this IServiceCollection services, + IConfiguration configuration, + Func writerFactory) + { + var opts = configuration.GetSection(AlarmHistorianOptions.SectionName).Get(); + if (opts is not { Enabled: true }) return services; // leave the Null default from AddOtOpcUaRuntime + + services.AddSingleton(sp => + { + // SqliteStoreAndForwardSink takes a Serilog ILogger (not Microsoft.Extensions.Logging). + // Resolve it off the host's configured static logger so the drain worker's WARN/INFO + // lines land in the same sinks as the rest of the process. + var sink = new SqliteStoreAndForwardSink( + opts.DatabasePath, + writerFactory(opts, sp), + Serilog.Log.Logger.ForContext(), + batchSize: opts.BatchSize); + sink.StartDrainLoop(TimeSpan.FromSeconds(5)); + return sink; + }); + 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/AlarmHistorianRegistrationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/AlarmHistorianRegistrationTests.cs new file mode 100644 index 00000000..bcef6db2 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/AlarmHistorianRegistrationTests.cs @@ -0,0 +1,94 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian; + +namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Historian; + +/// +/// Verifies the config-gated AddAlarmHistorian registration: when the +/// AlarmHistorian section is absent or disabled the +/// default survives; when it is enabled a real wins +/// (last-registration-wins over the TryAddSingleton Null default). +/// +public sealed class AlarmHistorianRegistrationTests +{ + /// A no-op writer the factory hands the Sqlite sink; never actually invoked in these tests. + private sealed class FakeWriter : IAlarmHistorianWriter + { + public Task> WriteBatchAsync( + IReadOnlyList batch, CancellationToken cancellationToken) + => Task.FromResult>( + batch.Select(_ => HistorianWriteOutcome.Ack).ToList()); + } + + /// Seed the Null default exactly the way AddOtOpcUaRuntime does, then add logging. + private static ServiceCollection BaseServices() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.TryAddSingleton(NullAlarmHistorianSink.Instance); + return services; + } + + private static IConfiguration ConfigFrom(Dictionary values) + => new ConfigurationBuilder().AddInMemoryCollection(values).Build(); + + [Fact] + public void Section_absent_keeps_null_sink() + { + var services = BaseServices(); + var config = ConfigFrom(new Dictionary()); + + services.AddAlarmHistorian(config, (_, _) => new FakeWriter()); + + using var provider = services.BuildServiceProvider(); + provider.GetRequiredService().ShouldBeOfType(); + } + + [Fact] + public void Section_disabled_keeps_null_sink() + { + var services = BaseServices(); + var config = ConfigFrom(new Dictionary + { + ["AlarmHistorian:Enabled"] = "false", + }); + + services.AddAlarmHistorian(config, (_, _) => new FakeWriter()); + + using var provider = services.BuildServiceProvider(); + provider.GetRequiredService().ShouldBeOfType(); + } + + [Fact] + public void Section_enabled_registers_sqlite_sink() + { + var tempDir = Path.Combine(Path.GetTempPath(), "otopcua-alarmhist-test-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + var dbPath = Path.Combine(tempDir, "alarm-historian-test.db"); + + try + { + var services = BaseServices(); + var config = ConfigFrom(new Dictionary + { + ["AlarmHistorian:Enabled"] = "true", + ["AlarmHistorian:DatabasePath"] = dbPath, + }); + + services.AddAlarmHistorian(config, (_, _) => new FakeWriter()); + + using (var provider = services.BuildServiceProvider()) + { + provider.GetRequiredService().ShouldBeOfType(); + } // dispose stops the drain loop + releases the SQLite file handle + } + finally + { + try { Directory.Delete(tempDir, recursive: true); } catch (IOException) { /* best effort */ } + } + } +}