diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OpcUaApplicationHost.cs b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OpcUaApplicationHost.cs index c53b774..2dc9c4f 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OpcUaApplicationHost.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OpcUaApplicationHost.cs @@ -34,9 +34,11 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable // Phase 7 Stream G follow-up (task #239). When composed with the VirtualTagEngine + // ScriptedAlarmEngine sources these route node reads to the engines instead of the // driver. Null = Phase 7 engines not enabled for this deployment (identical to pre- - // Phase-7 behaviour). - private readonly ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable? _virtualReadable; - private readonly ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable? _scriptedAlarmReadable; + // Phase-7 behaviour). Late-bindable via SetPhase7Sources because the engines need + // the bootstrapped generation id before they can compose, which is only known after + // the host has been DI-constructed (task #246). + private ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable? _virtualReadable; + private ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable? _scriptedAlarmReadable; private readonly ILoggerFactory _loggerFactory; private readonly ILogger _logger; @@ -75,6 +77,24 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable public OtOpcUaServer? Server => _server; + /// + /// Late-bind the Phase 7 engine-backed IReadable sources. Must be + /// called BEFORE — once the OPC UA server starts, the + /// ctor captures the field values + per-node + /// s are constructed. Calling this after start has + /// no effect on already-materialized node managers. + /// + public void SetPhase7Sources( + ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable? virtualReadable, + ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable? scriptedAlarmReadable) + { + if (_server is not null) + throw new InvalidOperationException( + "Phase 7 sources must be set before OpcUaApplicationHost.StartAsync; the OtOpcUaServer + DriverNodeManagers have already captured the previous values."); + _virtualReadable = virtualReadable; + _scriptedAlarmReadable = scriptedAlarmReadable; + } + /// /// Builds the , validates/creates the application /// certificate, constructs + starts the , then drives diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/OpcUaServerService.cs b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUaServerService.cs index c090bef..8b7705a 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Server/OpcUaServerService.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUaServerService.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using ZB.MOM.WW.OtOpcUa.Core.Hosting; using ZB.MOM.WW.OtOpcUa.Server.OpcUa; +using ZB.MOM.WW.OtOpcUa.Server.Phase7; namespace ZB.MOM.WW.OtOpcUa.Server; @@ -17,6 +18,7 @@ public sealed class OpcUaServerService( DriverHost driverHost, OpcUaApplicationHost applicationHost, DriverEquipmentContentRegistry equipmentContentRegistry, + Phase7Composer phase7Composer, IServiceScopeFactory scopeFactory, ILogger logger) : BackgroundService { @@ -34,12 +36,19 @@ public sealed class OpcUaServerService( // Skipped when no generation is Published yet — the fleet boots into a UNS-less // address space until the first publish, then the registry fills on next restart. if (result.GenerationId is { } gen) + { await PopulateEquipmentContentAsync(gen, stoppingToken); - // PR 17: stand up the OPC UA server + drive discovery per registered driver. Driver - // registration itself (RegisterAsync on DriverHost) happens during an earlier DI - // extension once the central config DB query + per-driver factory land; for now the - // server comes up with whatever drivers are in DriverHost at start time. + // Phase 7 follow-up #246 — load Script + VirtualTag + ScriptedAlarm rows, + // compose VirtualTagEngine + ScriptedAlarmEngine, start the driver-bridge + // feed. SetPhase7Sources MUST run before applicationHost.StartAsync because + // OtOpcUaServer + DriverNodeManager construction captures the field values + // — late binding after server start is rejected with InvalidOperationException. + // No-op when the generation has no virtual tags or scripted alarms. + var phase7 = await phase7Composer.PrepareAsync(gen, stoppingToken); + applicationHost.SetPhase7Sources(phase7.VirtualReadable, phase7.ScriptedAlarmReadable); + } + await applicationHost.StartAsync(stoppingToken); logger.LogInformation("OtOpcUa.Server running. Hosted drivers: {Count}", driverHost.RegisteredDriverIds.Count); @@ -57,6 +66,11 @@ public sealed class OpcUaServerService( public override async Task StopAsync(CancellationToken cancellationToken) { await base.StopAsync(cancellationToken); + // Dispose Phase 7 first so the bridge stops feeding the cache + the engines + // stop firing alarm/historian events before the OPC UA server tears down its + // node managers. Otherwise an in-flight cascade could try to push through a + // disposed source and surface as a noisy shutdown warning. + await phase7Composer.DisposeAsync(); await applicationHost.DisposeAsync(); await driverHost.DisposeAsync(); } diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/Phase7/Phase7Composer.cs b/src/ZB.MOM.WW.OtOpcUa.Server/Phase7/Phase7Composer.cs new file mode 100644 index 0000000..8fe81c0 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Server/Phase7/Phase7Composer.cs @@ -0,0 +1,183 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using ZB.MOM.WW.OtOpcUa.Configuration; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian; +using ZB.MOM.WW.OtOpcUa.Core.Hosting; +using ZB.MOM.WW.OtOpcUa.Core.OpcUa; +using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms; +using ZB.MOM.WW.OtOpcUa.Server.OpcUa; + +namespace ZB.MOM.WW.OtOpcUa.Server.Phase7; + +/// +/// Phase 7 follow-up (task #246) — orchestrates the runtime composition of virtual +/// tags + scripted alarms + the historian sink + the driver-bridge that feeds the +/// engines. Called by after the bootstrap generation +/// loads + before . +/// +/// +/// +/// reads Script / VirtualTag / ScriptedAlarm rows from +/// the central config DB at the bootstrapped generation, instantiates a +/// , runs , +/// starts a per registered driver feeding +/// 's tag rows into the cache, and returns +/// the engine-backed sources for +/// . +/// +/// +/// tears down the bridge first (so no more events +/// arrive at the cache), then the engines (so cascades + timer ticks stop), then +/// the SQLite sink (which flushes any in-flight drain). Lifetime is owned by the +/// host; calls dispose during graceful +/// shutdown. +/// +/// +public sealed class Phase7Composer : IAsyncDisposable +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly DriverHost _driverHost; + private readonly DriverEquipmentContentRegistry _equipmentRegistry; + private readonly IAlarmHistorianSink _historianSink; + private readonly ILoggerFactory _loggerFactory; + private readonly Serilog.ILogger _scriptLogger; + private readonly ILogger _logger; + + private DriverSubscriptionBridge? _bridge; + private Phase7ComposedSources _sources = Phase7ComposedSources.Empty; + private bool _disposed; + + public Phase7Composer( + IServiceScopeFactory scopeFactory, + DriverHost driverHost, + DriverEquipmentContentRegistry equipmentRegistry, + IAlarmHistorianSink historianSink, + ILoggerFactory loggerFactory, + Serilog.ILogger scriptLogger, + ILogger logger) + { + _scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory)); + _driverHost = driverHost ?? throw new ArgumentNullException(nameof(driverHost)); + _equipmentRegistry = equipmentRegistry ?? throw new ArgumentNullException(nameof(equipmentRegistry)); + _historianSink = historianSink ?? throw new ArgumentNullException(nameof(historianSink)); + _loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory)); + _scriptLogger = scriptLogger ?? throw new ArgumentNullException(nameof(scriptLogger)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public Phase7ComposedSources Sources => _sources; + + public async Task PrepareAsync(long generationId, CancellationToken ct) + { + if (_disposed) throw new ObjectDisposedException(nameof(Phase7Composer)); + + // Load the three Phase 7 row sets in one DB scope. + List