using Opc.Ua; using Opc.Ua.Client; namespace OpcUaCli; /// /// Manages OPC UA client sessions with automatic failover across a set of redundant server endpoints. /// public sealed class OpcUaFailoverHelper : IDisposable { private readonly string[] _endpointUrls; private readonly string? _username; private readonly string? _password; private readonly string _security; private Session? _session; private int _currentIndex; private bool _disposed; /// /// Gets the active session, or null if not connected. /// public Session? Session => _session; /// /// Gets the endpoint URL the session is currently connected to. /// public string? CurrentEndpointUrl => _currentIndex < _endpointUrls.Length ? _endpointUrls[_currentIndex] : null; /// /// Raised when a failover occurs, providing the old and new endpoint URLs. /// public event Action? OnFailover; public OpcUaFailoverHelper(string[] endpointUrls, string? username = null, string? password = null, string security = "none") { if (endpointUrls.Length == 0) throw new ArgumentException("At least one endpoint URL is required."); _endpointUrls = endpointUrls; _username = username; _password = password; _security = security; _currentIndex = 0; } /// /// Connects to the first reachable server in the endpoint list. /// public async Task ConnectAsync() { for (int attempt = 0; attempt < _endpointUrls.Length; attempt++) { var idx = (_currentIndex + attempt) % _endpointUrls.Length; var url = _endpointUrls[idx]; try { Console.WriteLine($" [failover] Connecting to {url}..."); _session = await OpcUaHelper.ConnectAsync(url, _username, _password, _security); _currentIndex = idx; Console.WriteLine($" [failover] Connected to {url}"); return _session; } catch (Exception ex) { Console.WriteLine($" [failover] Failed to connect to {url}: {ex.Message}"); } } throw new InvalidOperationException("All redundant servers are unreachable."); } /// /// Attempts to fail over to the next available server in the set. /// Closes the old session if still open. /// /// The new session. public async Task FailoverAsync() { var oldUrl = CurrentEndpointUrl; // Close old session if (_session != null) { try { _session.Close(); } catch { } _session.Dispose(); _session = null; } // Try the next server first, then cycle through all _currentIndex = (_currentIndex + 1) % _endpointUrls.Length; var newSession = await ConnectAsync(); OnFailover?.Invoke(oldUrl, CurrentEndpointUrl!); return newSession; } /// /// Installs a KeepAlive handler that triggers automatic failover when the session drops. /// Returns a task that completes when the session is lost and failover is needed. /// public void InstallKeepAliveHandler(Action onReconnected) { if (_session == null) return; _session.KeepAlive += async (session, e) => { if (e.Status == null || ServiceResult.IsGood(e.Status)) return; Console.WriteLine($" [failover] Session lost (status={e.Status}). Attempting failover..."); try { var newSession = await FailoverAsync(); onReconnected(newSession); } catch (Exception ex) { Console.WriteLine($" [failover] All servers unreachable: {ex.Message}"); } }; } public void Dispose() { if (_disposed) return; _disposed = true; if (_session != null) { try { _session.Close(); } catch { } _session.Dispose(); } } } /// /// Parses the --failover-urls option into an array of endpoint URLs. /// If failover URLs are provided, the primary URL is prepended to form the full set. /// If not provided, returns only the primary URL (no failover). /// public static class FailoverUrlParser { public static string[] Parse(string primaryUrl, string? failoverUrls) { if (string.IsNullOrWhiteSpace(failoverUrls)) return new[] { primaryUrl }; var urls = new List { primaryUrl }; foreach (var url in failoverUrls.Split(',', StringSplitOptions.RemoveEmptyEntries)) { var trimmed = url.Trim(); if (!string.IsNullOrEmpty(trimmed) && trimmed != primaryUrl) urls.Add(trimmed); } return urls.ToArray(); } }