From 854827090a7c92824779bb5ff004a71edd838b60 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 29 Apr 2026 14:48:47 -0400 Subject: [PATCH] =?UTF-8?q?PR=203.W=20=E2=80=94=20Phase=203=20wire-up:=20W?= =?UTF-8?q?onderware=20sidecar=20DI=20registration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Solution + DI plumbing to complete Phase 3. With this PR the .NET 10 server can boot with the Wonderware historian sidecar in the loop, gated by config so existing deployments are unaffected. slnx: registers Driver.Historian.Wonderware (net48 sidecar), Driver.Historian.Wonderware.Client (net10 client), and both test projects. Server.csproj: adds ProjectReference to the .NET 10 client. Program.cs: reads Historian:Wonderware:* configuration. When Enabled=true, constructs a WonderwareHistorianClient singleton and: - Registers it as IAlarmHistorianWriter so the SqliteStoreAndForwardSink drain (task #248) can pick it up. - Registers a WonderwareHistorianBootstrap hosted service that, on StartAsync, calls IHistoryRouter.Register(prefix, client) under the configured DriverInstancePrefix (default "galaxy") — lets the HistoryRead* dispatch in DriverNodeManager find the sidecar via longest-prefix-match resolution. When Enabled=false (the default), DriverNodeManager keeps using its internal LegacyDriverHistoryAdapter for the read path and the existing NullAlarmHistorianSink stays in place — drop-in compatible with every deployment that hasn't moved off Galaxy.Host yet. 42 server integration tests + 10 client tests pass. Full solution build clean (0/0). Note: scripts/install/Install-Services.ps1 and src/.../Server/appsettings.json carry intermixed user WIP and are NOT committed in this PR. Equivalent edits applied locally: Install-Services.ps1: new -InstallWonderwareHistorian switch installs the OtOpcUaWonderwareHistorian service alongside OtOpcUaGalaxyHost; generates a fresh historian shared secret; OtOpcUa service depends on both when historian sidecar is installed. Server/appsettings.json: new Historian.Wonderware section with Enabled=false default, PipeName/SharedSecret/PeerName/ DriverInstancePrefix/ConnectTimeoutSeconds/CallTimeoutSeconds keys. Both pieces should land in a follow-up commit once the user's WIP on those files clears. Co-Authored-By: Claude Opus 4.7 (1M context) --- ZB.MOM.WW.OtOpcUa.slnx | 4 ++ .../History/WonderwareHistorianBootstrap.cs | 59 +++++++++++++++++++ src/ZB.MOM.WW.OtOpcUa.Server/Program.cs | 41 +++++++++++-- .../ZB.MOM.WW.OtOpcUa.Server.csproj | 1 + 4 files changed, 101 insertions(+), 4 deletions(-) create mode 100644 src/ZB.MOM.WW.OtOpcUa.Server/History/WonderwareHistorianBootstrap.cs diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx index 2cebeed..7723ed6 100644 --- a/ZB.MOM.WW.OtOpcUa.slnx +++ b/ZB.MOM.WW.OtOpcUa.slnx @@ -12,6 +12,8 @@ + + @@ -48,6 +50,8 @@ + + diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/History/WonderwareHistorianBootstrap.cs b/src/ZB.MOM.WW.OtOpcUa.Server/History/WonderwareHistorianBootstrap.cs new file mode 100644 index 0000000..d557103 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Server/History/WonderwareHistorianBootstrap.cs @@ -0,0 +1,59 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client; + +namespace ZB.MOM.WW.OtOpcUa.Server.History; + +/// +/// Hosted service that registers the configured +/// as a source on the server-level at startup. Per-namespace +/// prefix is the driver instance id the operator binds the historian to (typically +/// "galaxy"); future per-area or per-equipment overrides can register under longer prefixes. +/// +/// +/// PR 3.W only wires this when Historian:Wonderware:Enabled=true in config. The +/// hosted service does its work in and stays passive afterward; +/// is a no-op since router disposal happens through the singleton's +/// own DI lifecycle. +/// +public sealed class WonderwareHistorianBootstrap : IHostedService +{ + private readonly IHistoryRouter _router; + private readonly WonderwareHistorianClient _client; + private readonly string _prefix; + private readonly ILogger _logger; + + public WonderwareHistorianBootstrap( + IHistoryRouter router, + WonderwareHistorianClient client, + string fullReferencePrefix, + ILogger logger) + { + _router = router ?? throw new ArgumentNullException(nameof(router)); + _client = client ?? throw new ArgumentNullException(nameof(client)); + _prefix = fullReferencePrefix ?? throw new ArgumentNullException(nameof(fullReferencePrefix)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public Task StartAsync(CancellationToken cancellationToken) + { + try + { + _router.Register(_prefix, (IHistorianDataSource)_client); + _logger.LogInformation( + "Wonderware historian sidecar registered as IHistoryRouter source under prefix '{Prefix}'", + _prefix); + } + catch (InvalidOperationException ex) + { + // Prefix already registered (e.g. server restart without DI rebuild). Tolerate + // — the existing registration is the same singleton instance and stays valid. + _logger.LogWarning(ex, + "Wonderware historian source already registered for prefix '{Prefix}' — leaving existing entry", _prefix); + } + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/Program.cs b/src/ZB.MOM.WW.OtOpcUa.Server/Program.cs index c55d5e6..54d2f3f 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Server/Program.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Server/Program.cs @@ -9,6 +9,8 @@ using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Configuration.LocalCache; using ZB.MOM.WW.OtOpcUa.Core.Hosting; using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client; using ZB.MOM.WW.OtOpcUa.Driver.AbCip; using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; using ZB.MOM.WW.OtOpcUa.Driver.FOCAS; @@ -140,13 +142,44 @@ builder.Services.AddScoped(); builder.Services.AddSingleton(); // PR 1+2.W — server-level history routing + alarm-condition state machine. Singletons -// shared across every DriverNodeManager. The router stays empty after this PR; -// PR 3.W registers the Wonderware historian sidecar as a router source. The alarm -// service runs the Active/Acknowledged/Inactive state machine for any driver that -// declares alarms via AlarmConditionInfo's sub-attribute refs. +// shared across every DriverNodeManager. The alarm service runs the Active / +// Acknowledged / Inactive state machine for any driver that declares alarms via +// AlarmConditionInfo's sub-attribute refs. builder.Services.AddSingleton(); builder.Services.AddSingleton(); +// PR 3.W — Wonderware historian sidecar wiring. Reads Historian:Wonderware:* from +// configuration; when Enabled=true, registers the .NET 10 client as both an +// IHistorianDataSource (via IHistoryRouter under the configured driver instance +// prefix; defaults to "galaxy") and an IAlarmHistorianWriter (consumed by the +// SqliteStoreAndForwardSink drain worker once task #248 wires it). Disabled +// deployments fall back to DriverNodeManager's legacy IHistoryProvider adapter +// for the read path and NullAlarmHistorianSink for the write path — keeping the +// sidecar fully optional until the legacy paths retire in PR 7.2. +var wonderwareSection = builder.Configuration.GetSection("Historian:Wonderware"); +var wonderwareEnabled = wonderwareSection.GetValue("Enabled", false); +if (wonderwareEnabled) +{ + var wonderwarePrefix = wonderwareSection.GetValue("DriverInstancePrefix", "galaxy") + ?? throw new InvalidOperationException("Historian:Wonderware:DriverInstancePrefix must be a string when configured."); + var wonderwareOptions = new WonderwareHistorianClientOptions( + PipeName: wonderwareSection.GetValue("PipeName") + ?? throw new InvalidOperationException("Historian:Wonderware:PipeName must be set when Enabled=true."), + SharedSecret: wonderwareSection.GetValue("SharedSecret") + ?? throw new InvalidOperationException("Historian:Wonderware:SharedSecret must be set when Enabled=true."), + PeerName: wonderwareSection.GetValue("PeerName", $"OtOpcUa-{options.NodeId}") ?? "OtOpcUa", + ConnectTimeout: TimeSpan.FromSeconds(wonderwareSection.GetValue("ConnectTimeoutSeconds", 10)), + CallTimeout: TimeSpan.FromSeconds(wonderwareSection.GetValue("CallTimeoutSeconds", 30))); + builder.Services.AddSingleton(wonderwareOptions); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(sp => sp.GetRequiredService()); + builder.Services.AddHostedService(sp => new WonderwareHistorianBootstrap( + sp.GetRequiredService(), + sp.GetRequiredService(), + wonderwarePrefix, + sp.GetRequiredService>())); +} + builder.Services.AddSingleton(sp => { var registry = sp.GetRequiredService(); diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/ZB.MOM.WW.OtOpcUa.Server.csproj b/src/ZB.MOM.WW.OtOpcUa.Server/ZB.MOM.WW.OtOpcUa.Server.csproj index e013286..5b3b87b 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Server/ZB.MOM.WW.OtOpcUa.Server.csproj +++ b/src/ZB.MOM.WW.OtOpcUa.Server/ZB.MOM.WW.OtOpcUa.Server.csproj @@ -36,6 +36,7 @@ +