using Microsoft.Extensions.Logging; using ScadaLink.Commons.Interfaces.Protocol; 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(); public OpcUaDataConnection(IOpcUaClientFactory clientFactory, ILogger logger) { _clientFactory = clientFactory; _logger = logger; } public ConnectionHealth Status => _status; public async Task ConnectAsync(IDictionary connectionDetails, CancellationToken cancellationToken = default) { _endpointUrl = connectionDetails.TryGetValue("EndpointUrl", out var url) ? url : "opc.tcp://localhost:4840"; _status = ConnectionHealth.Connecting; _client = _clientFactory.Create(); await _client.ConnectAsync(_endpointUrl, cancellationToken); _status = ConnectionHealth.Connected; _logger.LogInformation("OPC UA connected to {Endpoint}", _endpointUrl); } public async Task DisconnectAsync(CancellationToken cancellationToken = default) { if (_client != null) { 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(); 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); } public async Task> ReadBatchAsync(IEnumerable tagPaths, CancellationToken cancellationToken = default) { var results = new Dictionary(); foreach (var tagPath in tagPaths) { results[tagPath] = await ReadAsync(tagPath, cancellationToken); } 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; } public async ValueTask DisposeAsync() { if (_client != null) { 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."); } /// /// 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; } }