using System; using System.Threading; using Microsoft.Extensions.Configuration; using Serilog; using ZB.MOM.WW.LmxOpcUa.Host.Configuration; using ZB.MOM.WW.LmxOpcUa.Host.Domain; using ZB.MOM.WW.LmxOpcUa.Host.GalaxyRepository; using ZB.MOM.WW.LmxOpcUa.Host.Metrics; using ZB.MOM.WW.LmxOpcUa.Host.MxAccess; using ZB.MOM.WW.LmxOpcUa.Host.OpcUa; using ZB.MOM.WW.LmxOpcUa.Host.Status; namespace ZB.MOM.WW.LmxOpcUa.Host { /// /// Full service implementation wiring all components together. (SVC-004, SVC-005, SVC-006) /// internal sealed class OpcUaService { private static readonly ILogger Log = Serilog.Log.ForContext(); private readonly AppConfiguration _config; private readonly IMxProxy? _mxProxy; private readonly IGalaxyRepository? _galaxyRepository; private readonly IMxAccessClient? _mxAccessClientOverride; private readonly bool _hasMxAccessClientOverride; private CancellationTokenSource? _cts; private PerformanceMetrics? _metrics; private StaComThread? _staThread; private MxAccessClient? _mxAccessClient; private IMxAccessClient? _mxAccessClientForWiring; private ChangeDetectionService? _changeDetection; private OpcUaServerHost? _serverHost; private LmxNodeManager? _nodeManager; private HealthCheckService? _healthCheck; private StatusReportService? _statusReport; private StatusWebServer? _statusWebServer; private GalaxyRepositoryStats? _galaxyStats; /// /// Production constructor. Loads configuration from appsettings.json. /// public OpcUaService() { var configuration = new ConfigurationBuilder() .AddJsonFile("appsettings.json", optional: false) .AddJsonFile($"appsettings.{Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT") ?? "Production"}.json", optional: true) .AddEnvironmentVariables() .Build(); _config = new AppConfiguration(); configuration.GetSection("OpcUa").Bind(_config.OpcUa); configuration.GetSection("MxAccess").Bind(_config.MxAccess); configuration.GetSection("GalaxyRepository").Bind(_config.GalaxyRepository); configuration.GetSection("Dashboard").Bind(_config.Dashboard); _mxProxy = new MxProxyAdapter(); _galaxyRepository = new GalaxyRepositoryService(_config.GalaxyRepository); } /// /// Test constructor. Accepts injected dependencies. /// internal OpcUaService(AppConfiguration config, IMxProxy? mxProxy, IGalaxyRepository? galaxyRepository, IMxAccessClient? mxAccessClientOverride = null, bool hasMxAccessClientOverride = false) { _config = config; _mxProxy = mxProxy; _galaxyRepository = galaxyRepository; _mxAccessClientOverride = mxAccessClientOverride; _hasMxAccessClientOverride = hasMxAccessClientOverride; } public void Start() { Log.Information("LmxOpcUa service starting"); try { // Step 2: Validate config if (!ConfigurationValidator.ValidateAndLog(_config)) { Log.Error("Configuration validation failed"); throw new InvalidOperationException("Configuration validation failed"); } // Step 3: Register exception handler (SVC-006) AppDomain.CurrentDomain.UnhandledException += OnUnhandledException; // Step 4: Create PerformanceMetrics _cts = new CancellationTokenSource(); _metrics = new PerformanceMetrics(); // Step 5: Create MxAccessClient → Connect if (_hasMxAccessClientOverride) { // Test path: use injected IMxAccessClient directly (skips STA thread + COM) _mxAccessClientForWiring = _mxAccessClientOverride; if (_mxAccessClientForWiring != null && _mxAccessClientForWiring.State != ConnectionState.Connected) { _mxAccessClientForWiring.ConnectAsync(_cts.Token).GetAwaiter().GetResult(); } } else if (_mxProxy != null) { try { _staThread = new StaComThread(); _staThread.Start(); _mxAccessClient = new MxAccessClient(_staThread, _mxProxy, _config.MxAccess, _metrics); _mxAccessClient.ConnectAsync(_cts.Token).GetAwaiter().GetResult(); // Step 6: Start monitor loop _mxAccessClient.StartMonitor(); } catch (Exception ex) { Log.Warning(ex, "MxAccess connection failed — continuing without runtime data access"); _mxAccessClient?.Dispose(); _mxAccessClient = null; _staThread?.Dispose(); _staThread = null; } } // Step 7: Create GalaxyRepositoryService → TestConnection _galaxyStats = new GalaxyRepositoryStats { GalaxyName = _config.OpcUa.GalaxyName }; if (_galaxyRepository != null) { var dbOk = _galaxyRepository.TestConnectionAsync(_cts.Token).GetAwaiter().GetResult(); _galaxyStats.DbConnected = dbOk; if (!dbOk) Log.Warning("Galaxy repository database connection failed — continuing without initial data"); } // Step 8: Create OPC UA server host + node manager var effectiveMxClient = (IMxAccessClient?)_mxAccessClient ?? _mxAccessClientForWiring ?? new NullMxAccessClient(); _serverHost = new OpcUaServerHost(_config.OpcUa, effectiveMxClient, _metrics); // Step 9-10: Query hierarchy, start server, build address space if (_galaxyRepository != null && _galaxyStats.DbConnected) { try { var hierarchy = _galaxyRepository.GetHierarchyAsync(_cts.Token).GetAwaiter().GetResult(); var attributes = _galaxyRepository.GetAttributesAsync(_cts.Token).GetAwaiter().GetResult(); _galaxyStats.ObjectCount = hierarchy.Count; _galaxyStats.AttributeCount = attributes.Count; _serverHost.StartAsync().GetAwaiter().GetResult(); _nodeManager = _serverHost.NodeManager; if (_nodeManager != null) { _nodeManager.BuildAddressSpace(hierarchy, attributes); _galaxyStats.LastRebuildTime = DateTime.UtcNow; } } catch (Exception ex) { Log.Warning(ex, "Failed to build initial address space"); if (!_serverHost.IsRunning) { _serverHost.StartAsync().GetAwaiter().GetResult(); _nodeManager = _serverHost.NodeManager; } } } else { _serverHost.StartAsync().GetAwaiter().GetResult(); _nodeManager = _serverHost.NodeManager; } // Step 11-12: Change detection wired to rebuild if (_galaxyRepository != null) { _changeDetection = new ChangeDetectionService(_galaxyRepository, _config.GalaxyRepository.ChangeDetectionIntervalSeconds); _changeDetection.OnGalaxyChanged += OnGalaxyChanged; _changeDetection.Start(); } // Step 13: Dashboard _healthCheck = new HealthCheckService(); _statusReport = new StatusReportService(_healthCheck, _config.Dashboard.RefreshIntervalSeconds); _statusReport.SetComponents(effectiveMxClient, _metrics, _galaxyStats, _serverHost); if (_config.Dashboard.Enabled) { _statusWebServer = new StatusWebServer(_statusReport, _config.Dashboard.Port); _statusWebServer.Start(); } // Step 14 Log.Information("LmxOpcUa service started successfully"); } catch (Exception ex) { Log.Fatal(ex, "LmxOpcUa service failed to start"); throw; } } public void Stop() { Log.Information("LmxOpcUa service stopping"); try { _cts?.Cancel(); _changeDetection?.Stop(); _serverHost?.Stop(); if (_mxAccessClient != null) { _mxAccessClient.StopMonitor(); _mxAccessClient.DisconnectAsync().GetAwaiter().GetResult(); _mxAccessClient.Dispose(); } _staThread?.Dispose(); _statusWebServer?.Dispose(); _metrics?.Dispose(); _changeDetection?.Dispose(); _cts?.Dispose(); AppDomain.CurrentDomain.UnhandledException -= OnUnhandledException; } catch (Exception ex) { Log.Warning(ex, "Error during service shutdown"); } Log.Information("Service shutdown complete"); } private void OnGalaxyChanged() { Log.Information("Galaxy change detected — rebuilding address space"); try { if (_galaxyRepository == null || _nodeManager == null) return; var hierarchy = _galaxyRepository.GetHierarchyAsync().GetAwaiter().GetResult(); var attributes = _galaxyRepository.GetAttributesAsync().GetAwaiter().GetResult(); _nodeManager.RebuildAddressSpace(hierarchy, attributes); if (_galaxyStats != null) { _galaxyStats.ObjectCount = hierarchy.Count; _galaxyStats.AttributeCount = attributes.Count; _galaxyStats.LastRebuildTime = DateTime.UtcNow; _galaxyStats.LastDeployTime = _changeDetection?.LastKnownDeployTime; } } catch (Exception ex) { Log.Error(ex, "Failed to rebuild address space"); } } private static void OnUnhandledException(object sender, UnhandledExceptionEventArgs e) { Log.Fatal(e.ExceptionObject as Exception, "Unhandled exception (IsTerminating={IsTerminating})", e.IsTerminating); } // Accessors for testing internal IMxAccessClient? MxClient => (IMxAccessClient?)_mxAccessClient ?? _mxAccessClientForWiring; internal PerformanceMetrics? Metrics => _metrics; internal OpcUaServerHost? ServerHost => _serverHost; internal LmxNodeManager? NodeManagerInstance => _nodeManager; internal ChangeDetectionService? ChangeDetectionInstance => _changeDetection; internal StatusWebServer? StatusWeb => _statusWebServer; internal StatusReportService? StatusReportInstance => _statusReport; internal GalaxyRepositoryStats? GalaxyStatsInstance => _galaxyStats; } /// /// Null implementation of IMxAccessClient for when MXAccess is not available. /// internal sealed class NullMxAccessClient : IMxAccessClient { public ConnectionState State => ConnectionState.Disconnected; public int ActiveSubscriptionCount => 0; public int ReconnectCount => 0; public event EventHandler? ConnectionStateChanged; public event Action? OnTagValueChanged; public System.Threading.Tasks.Task ConnectAsync(CancellationToken ct = default) => System.Threading.Tasks.Task.CompletedTask; public System.Threading.Tasks.Task DisconnectAsync() => System.Threading.Tasks.Task.CompletedTask; public System.Threading.Tasks.Task SubscribeAsync(string fullTagReference, Action callback) => System.Threading.Tasks.Task.CompletedTask; public System.Threading.Tasks.Task UnsubscribeAsync(string fullTagReference) => System.Threading.Tasks.Task.CompletedTask; public System.Threading.Tasks.Task ReadAsync(string fullTagReference, CancellationToken ct = default) => System.Threading.Tasks.Task.FromResult(Vtq.Bad()); public System.Threading.Tasks.Task WriteAsync(string fullTagReference, object value, CancellationToken ct = default) => System.Threading.Tasks.Task.FromResult(false); public void Dispose() { } } }