using System.Collections.Concurrent; using Microsoft.Extensions.Logging; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol; using ZB.MOM.WW.ScadaBridge.Commons.Serialization; using ZB.MOM.WW.ScadaBridge.Commons.Types; using ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters; /// /// OPC UA adapter implementing IDataConnection. /// Maps IDataConnection methods to OPC UA concepts via IOpcUaClient abstraction. /// /// OPC UA mapping: /// - TagPath → NodeId (durable form, e.g. "nsu=https://server/ns;s=MyDevice.Temperature"; /// the legacy "ns=2;s=..." index form is still accepted — see OpcUaNodeReference) /// - Subscribe → MonitoredItem with DataChangeNotification /// - Read/Write → Read/Write service calls /// - Quality → OPC UA StatusCode mapping /// public class OpcUaDataConnection : IDataConnection, IBrowsableDataConnection, IAlarmSubscribableConnection, IAddressSpaceSearchable, IBatchSubscribableConnection { private readonly IOpcUaClientFactory _clientFactory; private readonly ILogger _logger; private IOpcUaClient? _client; private string _endpointUrl = string.Empty; private ConnectionHealth _status = ConnectionHealth.Disconnected; // The previous _subscriptionHandles Dictionary was // dead state — written by SubscribeAsync, removed by UnsubscribeAsync, but // never read anywhere. Plain Dictionary writes from thread-pool continuations // after await are racy (concurrent resize is undefined: InvalidOperationException, // bucket corruption, or silently lost entries). Bookkeeping for subscriptions // lives at two genuine layers: RealOpcUaClient._monitoredItems/_callbacks // (already ConcurrentDictionary) at the device adapter, and // DataConnectionActor._subscriptionIds at the actor — both authoritative. // The adapter has no need for a third copy; the field is removed rather than // converted to ConcurrentDictionary because there is no reader. private StaleTagMonitor? _staleMonitor; private string? _heartbeatSubscriptionId; /// /// Tags whose latest notification is BadResourceUnavailable (ScadaBridge#112), so the /// distinct "source temporarily unable to compute" log line fires once per entry and once per /// recovery rather than per notification. Written from the SDK publish threads, hence /// concurrent (DCL-019). Bounded by the subscribed tag set; cleared on (re)connect. /// private readonly ConcurrentDictionary _resourceUnavailableTags = new(); /// /// Initializes a new instance of . /// /// Factory used to create OPC UA client instances. /// Logger instance. public OpcUaDataConnection(IOpcUaClientFactory clientFactory, ILogger logger) { _clientFactory = clientFactory; _logger = logger; } // 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; /// Raised when the OPC UA connection is lost. 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, MaxMonitoredItemsPerSubscription: config.MaxMonitoredItemsPerSubscription); _status = ConnectionHealth.Connecting; _resourceUnavailableTags.Clear(); // Reconnect reuses this adapter instance (DataConnectionActor re-calls // ConnectAsync on auto-reconnect): detach + dispose the previous client so // (a) its session/keep-alive/socket do not leak per flap and (b) its late // ConnectionLost cannot flap the NEW healthy session after _disconnectFired // is reset below. Also stop the previous heartbeat monitor so the old // StaleTagMonitor cannot fire against the new session. Dispose is best-effort // fire-and-forget (CloseAsync against a dead server can block for the // operation timeout); faults are logged, never thrown into the connect path. StopHeartbeatMonitor(); if (_client is { } previousClient) { previousClient.ConnectionLost -= OnClientConnectionLost; _ = previousClient.DisposeAsync().AsTask().ContinueWith( t => _logger.LogWarning(t.Exception?.GetBaseException(), "Disposing previous OPC UA client failed for {Endpoint}", _endpointUrl), TaskContinuationOptions.OnlyOnFaulted); } _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(); // SubscriptionId is returned directly to the // caller (DataConnectionActor stores it in _subscriptionIds). No local // bookkeeping is kept here — see the field-deletion note above. return await _client!.CreateSubscriptionAsync( tagPath, (nodeId, value, timestamp, statusCode) => { var quality = MapStatusCode(statusCode); TrackResourceUnavailable(tagPath, statusCode); callback(tagPath, new TagValue(value, quality, new DateTimeOffset(timestamp, TimeSpan.Zero))); }, cancellationToken); } /// public async Task> SubscribeBatchAsync( IReadOnlyList tagPaths, SubscriptionCallback callback, CancellationToken cancellationToken = default) { EnsureConnected(); // ONE shared callback for the whole batch — the client hands back the node id // (== tag path for OPC UA), so no per-tag closure is needed. var outcomes = await _client!.CreateSubscriptionsAsync( tagPaths, (nodeId, value, timestamp, statusCode) => { var quality = MapStatusCode(statusCode); TrackResourceUnavailable(nodeId, statusCode); callback(nodeId, new TagValue(value, quality, new DateTimeOffset(timestamp, TimeSpan.Zero))); }, cancellationToken); return outcomes .Select(o => new TagSubscribeResult(o.NodeId, o.Success, o.SubscriptionHandle, o.Error)) .ToList(); } /// public async Task UnsubscribeBatchAsync( IReadOnlyList subscriptionIds, CancellationToken cancellationToken = default) { if (_client != null) await _client.RemoveSubscriptionsAsync(subscriptionIds, cancellationToken); } /// public async Task SubscribeAlarmsAsync( string sourceReference, string? conditionFilter, AlarmTransitionCallback callback, CancellationToken cancellationToken = default) { EnsureConnected(); // The client maps OPC UA A&C event fields → NativeAlarmTransition via // OpcUaAlarmMapper and replays a snapshot on (re)subscribe. return await _client!.CreateAlarmSubscriptionAsync( sourceReference, conditionFilter, transition => callback(transition), cancellationToken); } /// public async Task UnsubscribeAlarmsAsync(string subscriptionId, CancellationToken cancellationToken = default) { if (_client != null) await _client.RemoveAlarmSubscriptionAsync(subscriptionId, cancellationToken); } /// public async Task UnsubscribeAsync(string subscriptionId, CancellationToken cancellationToken = default) { if (_client != null) { await _client.RemoveSubscriptionAsync(subscriptionId, cancellationToken); } } /// 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, DescribeBadRead(statusCode)); 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) { EnsureConnected(); // TRUE bulk: one OPC UA Read service call (chunked inside the client against the // server's operation limits), not a loop over ReadAsync. A single failing tag // still comes back as a failed ReadResult row rather than aborting the batch; // OperationCanceledException still aborts the whole batch. var requested = tagPaths as IReadOnlyList ?? tagPaths.ToList(); var results = new Dictionary(requested.Count); if (requested.Count == 0) return results; try { var outcomes = await _client!.ReadValuesAsync(requested, cancellationToken); foreach (var outcome in outcomes) { if (outcome.Error != null) { results[outcome.NodeId] = new ReadResult(false, null, outcome.Error); continue; } var quality = MapStatusCode(outcome.StatusCode); results[outcome.NodeId] = quality == QualityCode.Bad ? new ReadResult(false, null, DescribeBadRead(outcome.StatusCode)) : new ReadResult(true, new TagValue(outcome.Value, quality, new DateTimeOffset(outcome.SourceTimestamp, TimeSpan.Zero)), null); } } catch (OperationCanceledException) { // Cancellation aborts the whole batch — propagate it. throw; } catch (Exception ex) { // A batch-level fault (session dropped mid-call) mirrors ReadAsync: signal the // disconnect, then report every requested tag as failed so the caller still // receives a complete map instead of an exception. _logger.LogWarning(ex, "OPC UA batch read failed — connection may be lost"); RaiseDisconnected(); foreach (var tagPath in requested) results[tagPath] = new ReadResult(false, null, ex.Message); } // Defensive: a non-conformant client/server that omits a requested tag. foreach (var tagPath in requested) { if (!results.ContainsKey(tagPath)) results[tagPath] = new ReadResult(false, null, "Tag missing from OPC UA read result."); } 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) { EnsureConnected(); // TRUE bulk: one OPC UA Write service call (chunked inside the client). A mid-batch // fault must not abort the batch — every requested tag gets a WriteResult row — // while OperationCanceledException still aborts as a whole. var requested = values.ToList(); var results = new Dictionary(requested.Count); if (requested.Count == 0) return results; try { var outcomes = await _client!.WriteValuesAsync( requested.Select(kv => (kv.Key, kv.Value)).ToList(), cancellationToken); foreach (var outcome in outcomes) { results[outcome.NodeId] = outcome.Error != null ? new WriteResult(false, outcome.Error) : outcome.StatusCode != 0 ? new WriteResult(false, $"OPC UA write failed with status: 0x{outcome.StatusCode:X8}") : new WriteResult(true, null); } } catch (OperationCanceledException) { // Cancellation aborts the whole batch — propagate it. throw; } catch (Exception ex) { foreach (var (tagPath, _) in requested) results[tagPath] = new WriteResult(false, ex.Message); } foreach (var (tagPath, _) in requested) { if (!results.ContainsKey(tagPath)) results[tagPath] = new WriteResult(false, "Tag missing from OPC UA write result."); } return results; } /// public Task BrowseChildrenAsync( string? parentNodeId, string? continuationToken = null, CancellationToken cancellationToken = default) => _client!.BrowseChildrenAsync(parentNodeId, continuationToken, cancellationToken); /// public Task SearchAddressSpaceAsync( string query, int maxDepth, int maxResults, CancellationToken cancellationToken = default) { // Guard connection state with the typed ConnectionNotConnectedException so // the site-side handler can map it uniformly (mirrors BrowseChildrenAsync, // whose underlying client throws the same type when the session is down). if (_client == null || !_client.IsConnected) throw new ConnectionNotConnectedException("OPC UA client is not connected."); return _client.SearchAddressSpaceAsync(query, maxDepth, maxResults, cancellationToken); } private void StopHeartbeatMonitor() { _staleMonitor?.Dispose(); _staleMonitor = null; _heartbeatSubscriptionId = null; } /// /// Asynchronously disposes the OPC UA connection, releasing the client and stopping the heartbeat monitor. /// /// A that completes when the client has been released and the heartbeat monitor stopped. 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(); } /// /// OPC UA BadResourceUnavailable — the code part (high 16 bits) of /// . OtOpcUa's out-of-process script /// runner publishes it on script-computed nodes when the script worker died under the /// evaluation or no worker is ready (ScadaBridge#112, lmxopcua#780). It is a per-node, /// transient "cannot compute this value right now": a plain Bad quality on the tag — never a /// connection fault, so it must not reach RaiseDisconnected or the connection-level /// status set the DataConnectionActor reconnects on. /// internal const uint BadResourceUnavailableCode = Opc.Ua.StatusCodes.BadResourceUnavailable; /// True when 's code part is BadResourceUnavailable /// (the low 16 bits are info bits and do not change the meaning). internal static bool IsResourceUnavailable(uint statusCode) => (statusCode & 0xFFFF0000u) == BadResourceUnavailableCode; /// Read-failure text naming the symbolic status, e.g. /// 0x80040000 BadResourceUnavailable, so a script/diagnostic reader can tell a /// transient source-side condition from a configuration or security error. internal static string DescribeBadRead(uint statusCode) { var name = Opc.Ua.StatusCodes.LookupSymbolicId(statusCode & 0xFFFF0000u); var text = string.IsNullOrEmpty(name) ? $"OPC UA read returned bad status: 0x{statusCode:X8}" : $"OPC UA read returned bad status: 0x{statusCode:X8} {name}"; return IsResourceUnavailable(statusCode) ? text + " (source node temporarily unable to compute the value)" : text; } /// /// Logs the entry into / recovery from BadResourceUnavailable once per transition per /// tag. Hot path: a tag that is not and was not unavailable costs one status compare and an /// empty-dictionary check. /// private void TrackResourceUnavailable(string tagPath, uint statusCode) { if (IsResourceUnavailable(statusCode)) { if (_resourceUnavailableTags.TryAdd(tagPath, 0)) _logger.LogInformation( "OPC UA source {Endpoint} reports BadResourceUnavailable (0x{Status:X8}) for {TagPath} — " + "the source node is temporarily unable to compute this value; tag quality is Bad, " + "last-known value kept, connection unaffected", _endpointUrl, statusCode, tagPath); } else if (!_resourceUnavailableTags.IsEmpty && _resourceUnavailableTags.TryRemove(tagPath, out _)) { _logger.LogInformation( "OPC UA source {Endpoint} recovered {TagPath} from BadResourceUnavailable (now 0x{Status:X8})", _endpointUrl, tagPath, statusCode); } } /// /// Maps an OPC UA StatusCode to QualityCode by its severity bits (top two bits: 00 Good, /// 01 Uncertain, 10/11 Bad), so every Good subcode (GoodLocalOverride, GoodClamped, info-bit /// variants, ...) is Good (ScadaBridge#117). BadResourceUnavailable is a plain Bad here /// (ScadaBridge#112 handles it separately and never as a connection fault). /// internal static QualityCode MapStatusCode(uint statusCode) { // Explicit severity mask: the SDK's StatusCode.IsGood/IsUncertain(uint) helpers did not // treat Good subcodes / info-bit variants as Good in the pinned SDK (tests prove it). return (statusCode & 0xC0000000u) switch { 0x00000000u => QualityCode.Good, 0x40000000u => QualityCode.Uncertain, _ => QualityCode.Bad, }; } }