using Microsoft.Extensions.Logging; using ScadaLink.Commons.Interfaces.Protocol; using ScadaLink.Commons.Serialization; using ScadaLink.Commons.Types; using ScadaLink.Commons.Types.DataConnections; using ScadaLink.Commons.Types.Enums; namespace ScadaLink.DataConnectionLayer.Adapters; /// /// WP-7: OPC UA adapter implementing IDataConnection. /// Maps IDataConnection methods to OPC UA concepts via IOpcUaClient abstraction. /// /// OPC UA mapping: /// - TagPath → NodeId (e.g., "ns=2;s=MyDevice.Temperature") /// - Subscribe → MonitoredItem with DataChangeNotification /// - Read/Write → Read/Write service calls /// - Quality → OPC UA StatusCode mapping /// public class OpcUaDataConnection : IDataConnection { private readonly IOpcUaClientFactory _clientFactory; private readonly ILogger _logger; private IOpcUaClient? _client; private string _endpointUrl = string.Empty; private ConnectionHealth _status = ConnectionHealth.Disconnected; /// /// Maps subscription IDs to their tag paths for cleanup. /// private readonly Dictionary _subscriptionHandles = new(); private StaleTagMonitor? _staleMonitor; private string? _heartbeatSubscriptionId; public OpcUaDataConnection(IOpcUaClientFactory clientFactory, ILogger logger) { _clientFactory = clientFactory; _logger = logger; } // DataConnectionLayer-013: an int flag toggled with Interlocked.Exchange so the // "only the first caller fires Disconnected" guard in RaiseDisconnected is genuinely // atomic. A plain volatile bool gives visibility but not atomicity — two threads // (e.g. the keep-alive thread and a ReadAsync failure path) could both observe it // false and both raise the event. 0 = not fired, 1 = fired. private int _disconnectFired; public ConnectionHealth Status => _status; public event Action? Disconnected; public async Task ConnectAsync(IDictionary connectionDetails, CancellationToken cancellationToken = default) { var config = OpcUaEndpointConfigSerializer.FromFlatDict(connectionDetails); _endpointUrl = string.IsNullOrWhiteSpace(config.EndpointUrl) ? "opc.tcp://localhost:4840" : config.EndpointUrl; var options = new OpcUaConnectionOptions( SessionTimeoutMs: config.SessionTimeoutMs, OperationTimeoutMs: config.OperationTimeoutMs, PublishingIntervalMs: config.PublishingIntervalMs, KeepAliveCount: config.KeepAliveCount, LifetimeCount: config.LifetimeCount, MaxNotificationsPerPublish: config.MaxNotificationsPerPublish, SamplingIntervalMs: config.SamplingIntervalMs, QueueSize: config.QueueSize, SecurityMode: config.SecurityMode.ToString(), AutoAcceptUntrustedCerts: config.AutoAcceptUntrustedCerts, DiscardOldest: config.DiscardOldest, SubscriptionPriority: config.SubscriptionPriority, SubscriptionDisplayName: config.SubscriptionDisplayName, TimestampsToReturn: config.TimestampsToReturn.ToString(), Deadband: config.Deadband is { } db ? new OpcUaDeadbandOptions(db.Type.ToString(), db.Value) : null, UserIdentity: config.UserIdentity is { } ui ? new OpcUaUserIdentityOptions( ui.TokenType.ToString(), ui.Username, ui.Password, ui.CertificatePath, ui.CertificatePassword) : null); _status = ConnectionHealth.Connecting; _client = _clientFactory.Create(); _client.ConnectionLost += OnClientConnectionLost; await _client.ConnectAsync(_endpointUrl, options, cancellationToken); _status = ConnectionHealth.Connected; Interlocked.Exchange(ref _disconnectFired, 0); _logger.LogInformation("OPC UA connected to {Endpoint}", _endpointUrl); await StartHeartbeatMonitorAsync(config.Heartbeat, cancellationToken); } private async Task StartHeartbeatMonitorAsync(OpcUaHeartbeatConfig? heartbeat, CancellationToken cancellationToken) { if (heartbeat is null || string.IsNullOrWhiteSpace(heartbeat.TagPath)) return; _staleMonitor?.Dispose(); _staleMonitor = new StaleTagMonitor(TimeSpan.FromSeconds(heartbeat.MaxSilenceSeconds)); _staleMonitor.Stale += () => { _logger.LogWarning("OPC UA heartbeat tag '{Tag}' stale — no update in {Seconds}s", heartbeat.TagPath, heartbeat.MaxSilenceSeconds); RaiseDisconnected(); }; try { _heartbeatSubscriptionId = await SubscribeAsync(heartbeat.TagPath, (_, _) => _staleMonitor.OnValueReceived(), cancellationToken); _staleMonitor.Start(); _logger.LogInformation("OPC UA heartbeat monitor started for '{Tag}' with {Seconds}s max silence", heartbeat.TagPath, heartbeat.MaxSilenceSeconds); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to subscribe to heartbeat tag '{Tag}' — stale monitor not active", heartbeat.TagPath); _staleMonitor.Dispose(); _staleMonitor = null; } } private void OnClientConnectionLost() { RaiseDisconnected(); } public async Task DisconnectAsync(CancellationToken cancellationToken = default) { StopHeartbeatMonitor(); if (_client != null) { _client.ConnectionLost -= OnClientConnectionLost; await _client.DisconnectAsync(cancellationToken); _status = ConnectionHealth.Disconnected; _logger.LogInformation("OPC UA disconnected from {Endpoint}", _endpointUrl); } } public async Task SubscribeAsync(string tagPath, SubscriptionCallback callback, CancellationToken cancellationToken = default) { EnsureConnected(); var subscriptionId = await _client!.CreateSubscriptionAsync( tagPath, (nodeId, value, timestamp, statusCode) => { var quality = MapStatusCode(statusCode); callback(tagPath, new TagValue(value, quality, new DateTimeOffset(timestamp, TimeSpan.Zero))); }, cancellationToken); _subscriptionHandles[subscriptionId] = tagPath; return subscriptionId; } public async Task UnsubscribeAsync(string subscriptionId, CancellationToken cancellationToken = default) { if (_client != null) { await _client.RemoveSubscriptionAsync(subscriptionId, cancellationToken); _subscriptionHandles.Remove(subscriptionId); } } public async Task ReadAsync(string tagPath, CancellationToken cancellationToken = default) { EnsureConnected(); try { var (value, timestamp, statusCode) = await _client!.ReadValueAsync(tagPath, cancellationToken); var quality = MapStatusCode(statusCode); if (quality == QualityCode.Bad) return new ReadResult(false, null, $"OPC UA read returned bad status: 0x{statusCode:X8}"); return new ReadResult(true, new TagValue(value, quality, new DateTimeOffset(timestamp, TimeSpan.Zero)), null); } catch (Exception ex) when (ex is not OperationCanceledException) { _logger.LogWarning(ex, "OPC UA read failed for {TagPath} — connection may be lost", tagPath); RaiseDisconnected(); throw; } } public async Task> ReadBatchAsync(IEnumerable tagPaths, CancellationToken cancellationToken = default) { // DataConnectionLayer-007: a single failing tag must not abort the whole batch. // ReadAsync re-throws non-cancellation exceptions; catch them per tag and record // a failed ReadResult so the caller receives a complete result map for every // requested tag (the ReadResult shape already carries per-tag Success/error). var results = new Dictionary(); foreach (var tagPath in tagPaths) { try { results[tagPath] = await ReadAsync(tagPath, cancellationToken); } catch (OperationCanceledException) { // Cancellation aborts the whole batch — propagate it. throw; } catch (Exception ex) { results[tagPath] = new ReadResult(false, null, ex.Message); } } return results; } public async Task WriteAsync(string tagPath, object? value, CancellationToken cancellationToken = default) { EnsureConnected(); var statusCode = await _client!.WriteValueAsync(tagPath, value, cancellationToken); if (statusCode != 0) return new WriteResult(false, $"OPC UA write failed with status: 0x{statusCode:X8}"); return new WriteResult(true, null); } public async Task> WriteBatchAsync(IDictionary values, CancellationToken cancellationToken = default) { var results = new Dictionary(); foreach (var (tagPath, value) in values) { results[tagPath] = await WriteAsync(tagPath, value, cancellationToken); } return results; } public async Task WriteBatchAndWaitAsync( IDictionary values, string flagPath, object? flagValue, string responsePath, object? responseValue, TimeSpan timeout, CancellationToken cancellationToken = default) { // Write all values including the flag var allValues = new Dictionary(values) { [flagPath] = flagValue }; var writeResults = await WriteBatchAsync(allValues, cancellationToken); if (writeResults.Values.Any(r => !r.Success)) return false; // Poll for response value within timeout var deadline = DateTimeOffset.UtcNow + timeout; while (DateTimeOffset.UtcNow < deadline) { cancellationToken.ThrowIfCancellationRequested(); var readResult = await ReadAsync(responsePath, cancellationToken); if (readResult.Success && readResult.Value != null && Equals(readResult.Value.Value, responseValue)) return true; await Task.Delay(100, cancellationToken); } return false; } private void StopHeartbeatMonitor() { _staleMonitor?.Dispose(); _staleMonitor = null; _heartbeatSubscriptionId = null; } public async ValueTask DisposeAsync() { StopHeartbeatMonitor(); if (_client != null) { _client.ConnectionLost -= OnClientConnectionLost; await _client.DisposeAsync(); _client = null; } _status = ConnectionHealth.Disconnected; } private void EnsureConnected() { if (_client == null || !_client.IsConnected) throw new InvalidOperationException("OPC UA client is not connected."); } /// /// Marks the connection as disconnected and fires the Disconnected event once. /// Thread-safe: the firing guard is an atomic compare-and-set /// (), so when several threads race /// here — e.g. the keep-alive thread via and a /// ReadAsync failure path — exactly one of them observes the 0→1 transition /// and invokes . /// private void RaiseDisconnected() { if (Interlocked.Exchange(ref _disconnectFired, 1) != 0) return; _status = ConnectionHealth.Disconnected; _logger.LogWarning("OPC UA connection to {Endpoint} lost", _endpointUrl); Disconnected?.Invoke(); } /// /// Maps OPC UA StatusCode to QualityCode. /// StatusCode 0 = Good, high bit set = Bad, otherwise Uncertain. /// private static QualityCode MapStatusCode(uint statusCode) { if (statusCode == 0) return QualityCode.Good; if ((statusCode & 0x80000000) != 0) return QualityCode.Bad; return QualityCode.Uncertain; } }