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();
}
}