using Microsoft.Extensions.DependencyInjection; 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; /// /// BackgroundService that owns the OPC UA server lifecycle (decision #30, replacing TopShelf). /// Bootstraps config, starts the , starts the OPC UA server via /// , drives each driver's discovery into the address space, /// runs until stopped. /// public sealed class OpcUaServerService( NodeBootstrap bootstrap, DriverHost driverHost, OpcUaApplicationHost applicationHost, DriverEquipmentContentRegistry equipmentContentRegistry, DriverInstanceBootstrapper driverBootstrapper, Phase7Composer phase7Composer, IServiceScopeFactory scopeFactory, ILogger logger) : BackgroundService { protected override async Task ExecuteAsync(CancellationToken stoppingToken) { logger.LogInformation("OtOpcUa.Server starting"); var result = await bootstrap.LoadCurrentGenerationAsync(stoppingToken); logger.LogInformation("Bootstrap complete: source={Source} generation={Gen}", result.Source, result.GenerationId); // ADR-001 Option A — populate per-driver Equipment namespace snapshots into the // registry before StartAsync walks the address space. The walker on the OPC UA side // reads synchronously from the registry; pre-loading here means the hot path stays // non-blocking + each driver pays at most one Config-DB query at bootstrap time. // 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) { // Task #248 — register IDriver instances from the published DriverInstance // rows BEFORE the equipment-content load + Phase 7 compose, so the rest of // the pipeline sees a populated DriverHost. Without this step Phase 7's // CachedTagUpstreamSource has no upstream feed + virtual-tag scripts read // BadNodeIdUnknown for every tag path (gap surfaced by task #240 smoke). await driverBootstrapper.RegisterDriversFromGenerationAsync(gen, stoppingToken); await PopulateEquipmentContentAsync(gen, stoppingToken); // 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); try { await Task.Delay(Timeout.InfiniteTimeSpan, stoppingToken); } catch (OperationCanceledException) { logger.LogInformation("OtOpcUa.Server stopping"); } } 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(); } /// /// Pre-load an EquipmentNamespaceContent snapshot for each registered driver at /// the bootstrapped generation. Null results (driver has no Equipment rows — /// Modbus/AB CIP/TwinCAT/FOCAS today per decisions #116–#121) are skipped: the walker /// wire-in sees Get(driverId) return null + falls back to DiscoverAsync-owns-it. /// Opens one scope so the scoped OtOpcUaConfigDbContext is shared across all /// per-driver queries rather than paying scope-setup overhead per driver. /// private async Task PopulateEquipmentContentAsync(long generationId, CancellationToken ct) { using var scope = scopeFactory.CreateScope(); var loader = scope.ServiceProvider.GetRequiredService(); var loaded = 0; foreach (var driverId in driverHost.RegisteredDriverIds) { var content = await loader.LoadAsync(driverId, generationId, ct).ConfigureAwait(false); if (content is null) continue; equipmentContentRegistry.Set(driverId, content); loaded++; } logger.LogInformation( "Equipment namespace snapshots loaded for {Count}/{Total} driver(s) at generation {Gen}", loaded, driverHost.RegisteredDriverIds.Count, generationId); } }