Implement LmxOpcUa server — all 6 phases complete
Full OPC UA server on .NET Framework 4.8 (x86) exposing AVEVA System Platform Galaxy tags via MXAccess. Mirrors Galaxy object hierarchy as OPC UA address space, translating contained-name browse paths to tag-name runtime references. Components implemented: - Configuration: AppConfiguration with 4 sections, validator - Domain: ConnectionState, Quality, Vtq, MxDataTypeMapper, error codes - MxAccess: StaComThread, MxAccessClient (partial classes), MxProxyAdapter using strongly-typed ArchestrA.MxAccess COM interop - Galaxy Repository: SQL queries (hierarchy, attributes, change detection), ChangeDetectionService with auto-rebuild on deploy - OPC UA Server: LmxNodeManager (CustomNodeManager2), LmxOpcUaServer, OpcUaServerHost with programmatic config, SecurityPolicy None - Status Dashboard: HTTP server with HTML/JSON/health endpoints - Integration: Full 14-step startup, graceful shutdown, component wiring 175 tests (174 unit + 1 integration), all passing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
285
src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaService.cs
Normal file
285
src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaService.cs
Normal file
@@ -0,0 +1,285 @@
|
||||
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
|
||||
{
|
||||
/// <summary>
|
||||
/// Full service implementation wiring all components together. (SVC-004, SVC-005, SVC-006)
|
||||
/// </summary>
|
||||
internal sealed class OpcUaService
|
||||
{
|
||||
private static readonly ILogger Log = Serilog.Log.ForContext<OpcUaService>();
|
||||
|
||||
private readonly AppConfiguration _config;
|
||||
private readonly IMxProxy? _mxProxy;
|
||||
private readonly IGalaxyRepository? _galaxyRepository;
|
||||
|
||||
private CancellationTokenSource? _cts;
|
||||
private PerformanceMetrics? _metrics;
|
||||
private StaComThread? _staThread;
|
||||
private MxAccessClient? _mxAccessClient;
|
||||
private ChangeDetectionService? _changeDetection;
|
||||
private OpcUaServerHost? _serverHost;
|
||||
private LmxNodeManager? _nodeManager;
|
||||
private HealthCheckService? _healthCheck;
|
||||
private StatusReportService? _statusReport;
|
||||
private StatusWebServer? _statusWebServer;
|
||||
private GalaxyRepositoryStats? _galaxyStats;
|
||||
|
||||
/// <summary>
|
||||
/// Production constructor. Loads configuration from appsettings.json.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test constructor. Accepts injected dependencies.
|
||||
/// </summary>
|
||||
internal OpcUaService(AppConfiguration config, IMxProxy? mxProxy, IGalaxyRepository? galaxyRepository)
|
||||
{
|
||||
_config = config;
|
||||
_mxProxy = mxProxy;
|
||||
_galaxyRepository = galaxyRepository;
|
||||
}
|
||||
|
||||
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 (_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
|
||||
IMxAccessClient mxClient = _mxAccessClient ?? (IMxAccessClient)new NullMxAccessClient();
|
||||
_serverHost = new OpcUaServerHost(_config.OpcUa, mxClient, _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(_mxAccessClient, _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 => _mxAccessClient;
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Null implementation of IMxAccessClient for when MXAccess is not available.
|
||||
/// </summary>
|
||||
internal sealed class NullMxAccessClient : IMxAccessClient
|
||||
{
|
||||
public ConnectionState State => ConnectionState.Disconnected;
|
||||
public int ActiveSubscriptionCount => 0;
|
||||
public int ReconnectCount => 0;
|
||||
public event EventHandler<ConnectionStateChangedEventArgs>? ConnectionStateChanged;
|
||||
public event Action<string, Vtq>? 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<string, Vtq> callback) => System.Threading.Tasks.Task.CompletedTask;
|
||||
public System.Threading.Tasks.Task UnsubscribeAsync(string fullTagReference) => System.Threading.Tasks.Task.CompletedTask;
|
||||
public System.Threading.Tasks.Task<Vtq> ReadAsync(string fullTagReference, CancellationToken ct = default) => System.Threading.Tasks.Task.FromResult(Vtq.Bad());
|
||||
public System.Threading.Tasks.Task<bool> WriteAsync(string fullTagReference, object value, CancellationToken ct = default) => System.Threading.Tasks.Task.FromResult(false);
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user