using System.Text;
using Opc.Ua;
using Serilog;
using ZB.MOM.WW.OtOpcUa.Client.Shared.Adapters;
using ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
using ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
using BrowseResult = ZB.MOM.WW.OtOpcUa.Client.Shared.Models.BrowseResult;
namespace ZB.MOM.WW.OtOpcUa.Client.Shared;
///
/// Full implementation of using adapter abstractions for testability.
///
public sealed class OpcUaClientService : IOpcUaClientService
{
private static readonly ILogger Logger = Log.ForContext();
// Guards all access to the subscription state below (_activeDataSubscriptions, _activeAlarmSubscription,
// _dataSubscription, _alarmSubscription). They are touched from the caller thread, the keep-alive failover path
// and DisconnectAsync, so every read/write is inside this lock — including failover's null-outs.
private readonly object _subscriptionLock = new();
// Serialises every subscription operation that awaits I/O (Subscribe/Unsubscribe, SubscribeAlarms/
// UnsubscribeAlarms) AND the failover's teardown + reconnect + replay. A subscribe is therefore either
// wholly before a failover (recorded, then replayed onto the new session) or wholly after it (on the new
// session) — never half on the dying session. Async-friendly (no thread block). Deliberately never disposed:
// async-only waits hold no kernel handle, and a failover still running at Dispose releases it afterwards.
private readonly SemaphoreSlim _subscriptionGate = new(1, 1);
// Track active data subscriptions for replay after failover
private readonly Dictionary _activeDataSubscriptions = new();
// Re-entry guard for HandleKeepAliveFailureAsync. The OPC UA stack raises
// KeepAlive repeatedly while a session is down; only one failover loop may
// run at a time. 0 = idle, 1 = failover in progress (Interlocked-managed).
private int _failoverInProgress;
// The connection epoch (review I1): ConnectAsync and DisconnectAsync each start a new one (under
// _subscriptionLock). A failover runs under the epoch it started in and abandons itself — disposing any session it
// opened, making no state transition — the moment the epoch moves, so a user Disconnect + Connect during a
// failover is never undone by it. A keep-alive from a previous connection's session cannot start one either.
private long _connectionEpoch;
/// The most recent keep-alive-triggered failover task (test seam: lets a test await it).
internal Task? LastFailover { get; private set; }
private readonly IApplicationConfigurationFactory _configFactory;
private readonly IEndpointDiscovery _endpointDiscovery;
private readonly ISessionFactory _sessionFactory;
// Track alarm subscription state for replay after failover
private (NodeId? SourceNodeId, int IntervalMs)? _activeAlarmSubscription;
private ISubscriptionAdapter? _alarmSubscription;
private string[]? _allEndpointUrls;
private int _currentEndpointIndex;
private ISubscriptionAdapter? _dataSubscription;
private bool _disposed;
private ISessionAdapter? _session;
private ConnectionSettings? _settings;
private ConnectionState _state = ConnectionState.Disconnected;
///
/// Creates a new OpcUaClientService with the specified adapter dependencies.
///
/// Builds the application configuration and certificate settings for the client session.
/// Selects the best matching endpoint for the requested URL and security mode.
/// Creates the underlying OPC UA session used for runtime operations.
internal OpcUaClientService(
IApplicationConfigurationFactory configFactory,
IEndpointDiscovery endpointDiscovery,
ISessionFactory sessionFactory)
{
_configFactory = configFactory;
_endpointDiscovery = endpointDiscovery;
_sessionFactory = sessionFactory;
}
///
/// Creates a new OpcUaClientService with default production adapters.
///
public OpcUaClientService()
: this(
new DefaultApplicationConfigurationFactory(),
new DefaultEndpointDiscovery(),
new DefaultSessionFactory())
{
}
///
public event EventHandler? DataChanged;
///
public event EventHandler? AlarmEvent;
///
public event EventHandler? ConnectionStateChanged;
///
public bool IsConnected => _state == ConnectionState.Connected && _session?.Connected == true;
///
public ConnectionInfo? CurrentConnectionInfo { get; private set; }
///
public async Task ConnectAsync(ConnectionSettings settings, CancellationToken ct = default)
{
ThrowIfDisposed();
settings.Validate();
// Overwriting a live (or reconnecting) session would leak it and orphan its subscriptions and keep-alive
// handler. The caller must disconnect first.
if (_state != ConnectionState.Disconnected || _session != null)
throw new InvalidOperationException(
$"Already {_state.ToString().ToLowerInvariant()}; call DisconnectAsync before connecting again.");
long epoch;
lock (_subscriptionLock) epoch = ++_connectionEpoch;
_settings = settings;
_allEndpointUrls = FailoverUrlParser.Parse(settings.EndpointUrl, settings.FailoverUrls);
_currentEndpointIndex = 0;
TransitionState(ConnectionState.Connecting, settings.EndpointUrl);
try
{
var session = await ConnectToEndpointAsync(settings, _allEndpointUrls[0], ct);
lock (_subscriptionLock) _session = session;
session.RegisterKeepAliveHandler(isGood =>
{
if (!isGood) OnKeepAliveFailed(epoch);
});
CurrentConnectionInfo = BuildConnectionInfo(session);
TransitionState(ConnectionState.Connected, session.EndpointUrl);
Logger.Information("Connected to {EndpointUrl}", session.EndpointUrl);
return CurrentConnectionInfo;
}
catch
{
TransitionState(ConnectionState.Disconnected, settings.EndpointUrl);
throw;
}
}
///
public async Task DisconnectAsync(CancellationToken ct = default)
{
if (_state == ConnectionState.Disconnected)
return;
// A new epoch first: a failover still connecting abandons itself instead of resurrecting the connection.
lock (_subscriptionLock) _connectionEpoch++;
var endpointUrl = _session?.EndpointUrl ?? _settings?.EndpointUrl ?? string.Empty;
// Snapshot the subscription adapter references under the lock so a concurrent
// RunFailoverAsync that nulls _dataSubscription / _alarmSubscription
// cannot invalidate the references between the null-check and the DeleteAsync call.
ISubscriptionAdapter? dataSubscription;
ISubscriptionAdapter? alarmSubscription;
lock (_subscriptionLock)
{
dataSubscription = _dataSubscription;
alarmSubscription = _alarmSubscription;
_dataSubscription = null;
_alarmSubscription = null;
}
try
{
if (dataSubscription != null)
await dataSubscription.DeleteAsync(ct);
if (alarmSubscription != null)
await alarmSubscription.DeleteAsync(ct);
if (_session != null)
{
await _session.CloseAsync(ct);
_session.Dispose();
_session = null;
}
}
catch (Exception ex)
{
Logger.Warning(ex, "Error during disconnect");
}
finally
{
lock (_subscriptionLock)
{
_activeDataSubscriptions.Clear();
_activeAlarmSubscription = null;
}
CurrentConnectionInfo = null;
TransitionState(ConnectionState.Disconnected, endpointUrl);
}
}
///
public async Task ReadValueAsync(NodeId nodeId, CancellationToken ct = default)
{
ThrowIfDisposed();
ThrowIfNotConnected();
return await _session!.ReadValueAsync(nodeId, ct);
}
///
public async Task WriteValueAsync(NodeId nodeId, object value, CancellationToken ct = default)
{
ThrowIfDisposed();
ThrowIfNotConnected();
// Read current value for type coercion when value is a string
var typedValue = value;
if (value is string rawString)
{
var currentDataValue = await _session!.ReadValueAsync(nodeId, ct);
if (!StatusCode.IsGood(currentDataValue.StatusCode) || ValueConverter.ToClrObject(currentDataValue.WrappedValue) == null)
throw new InvalidOperationException(
$"Cannot infer target type for node {nodeId}: read returned status {currentDataValue.StatusCode} with no value. Provide a typed value instead of a string.");
typedValue = ValueConverter.ConvertValue(rawString, ValueConverter.ToClrObject(currentDataValue.WrappedValue));
}
var dataValue = new DataValue(ValueConverter.ToVariant(typedValue));
return await _session!.WriteValueAsync(nodeId, dataValue, ct);
}
///
public async Task> BrowseAsync(NodeId? parentNodeId = null,
CancellationToken ct = default)
{
ThrowIfDisposed();
ThrowIfNotConnected();
var startNode = parentNodeId ?? ObjectIds.ObjectsFolder;
var nodeClassMask = (uint)NodeClass.Object | (uint)NodeClass.Variable | (uint)NodeClass.Method;
var results = new List();
// A cursor belongs to the session that issued it, including while failover swaps _session.
var session = _session!;
byte[]? continuationPoint = null;
try
{
ArrayOf references;
(continuationPoint, references) = await session.BrowseAsync(startNode, nodeClassMask, ct);
while (references.Count > 0)
{
foreach (var reference in references)
results.Add(new BrowseResult(
reference.NodeId.ToString(),
reference.DisplayName.Text ?? string.Empty,
reference.NodeClass.ToString(),
reference.NodeClass == NodeClass.Object));
if (continuationPoint is not { Length: > 0 } pending) break;
ct.ThrowIfCancellationRequested();
(continuationPoint, references) = await session.BrowseNextAsync(pending, ct);
}
}
finally
{
if (continuationPoint is { Length: > 0 })
{
try
{
using var release = new CancellationTokenSource(HistoryReadPaging.ReleaseTimeout);
await session.ReleaseBrowseContinuationPointAsync(continuationPoint, release.Token)
.WaitAsync(release.Token).ConfigureAwait(false);
}
catch (Exception ex) { Logger.Warning(ex, "Releasing browse continuation point failed"); }
}
}
return results;
}
///
public async Task SubscribeAsync(NodeId nodeId, int intervalMs = 1000, CancellationToken ct = default)
{
ThrowIfDisposed();
ThrowIfNotConnected();
var nodeIdStr = nodeId.ToString();
await _subscriptionGate.WaitAsync(ct);
try
{
// A failover may have run (and failed) while this call waited for the gate.
ThrowIfNotConnected();
ISubscriptionAdapter? subscription;
ISessionAdapter session;
lock (_subscriptionLock)
{
if (_activeDataSubscriptions.ContainsKey(nodeIdStr))
return; // Already subscribed
subscription = _dataSubscription;
session = _session!;
}
if (subscription == null)
{
subscription = await session.CreateSubscriptionAsync(intervalMs, ct);
lock (_subscriptionLock) _dataSubscription = subscription;
}
var handle = await subscription.AddDataChangeMonitoredItemAsync(
nodeId, intervalMs, DataCallback(session, subscription), ct);
lock (_subscriptionLock)
{
_activeDataSubscriptions[nodeIdStr] = (nodeId, intervalMs, handle);
}
}
finally
{
_subscriptionGate.Release();
}
Logger.Debug("Subscribed to data changes on {NodeId}", nodeId);
}
///
public async Task UnsubscribeAsync(NodeId nodeId, CancellationToken ct = default)
{
ThrowIfDisposed();
var nodeIdStr = nodeId.ToString();
await _subscriptionGate.WaitAsync(ct);
try
{
(NodeId NodeId, int IntervalMs, uint Handle) sub;
ISubscriptionAdapter? subscription;
lock (_subscriptionLock)
{
if (!_activeDataSubscriptions.TryGetValue(nodeIdStr, out sub))
return; // Not subscribed, safe to ignore
subscription = _dataSubscription;
}
try
{
if (subscription != null) await subscription.RemoveMonitoredItemAsync(sub.Handle, ct);
}
finally
{
// The caller asked to stop: drop the bookkeeping even when the server-side remove failed (a session
// that died under us), or a later failover would replay the item onto the new session (review m5).
lock (_subscriptionLock)
{
_activeDataSubscriptions.Remove(nodeIdStr);
}
}
}
finally
{
_subscriptionGate.Release();
}
Logger.Debug("Unsubscribed from data changes on {NodeId}", nodeId);
}
///
public async Task SubscribeAlarmsAsync(NodeId? sourceNodeId = null, int intervalMs = 1000,
CancellationToken ct = default)
{
ThrowIfDisposed();
ThrowIfNotConnected();
// Serialise check-and-create under the semaphore: concurrent
// callers cannot both pass the null-check and create duplicate alarm subscriptions.
// The semaphore is async-friendly; the subscription I/O runs inside the critical section.
await _subscriptionGate.WaitAsync(ct);
try
{
ThrowIfNotConnected();
ISessionAdapter session;
lock (_subscriptionLock)
{
if (_alarmSubscription != null)
return; // Already subscribed to alarms
session = _session!;
}
var monitorNode = sourceNodeId ?? ObjectIds.Server;
var subscription = await session.CreateSubscriptionAsync(intervalMs, ct);
lock (_subscriptionLock) _alarmSubscription = subscription;
var filter = CreateAlarmEventFilter();
await subscription.AddEventMonitoredItemAsync(
monitorNode, intervalMs, filter, AlarmCallback(session, subscription), ct);
lock (_subscriptionLock)
{
_activeAlarmSubscription = (sourceNodeId, intervalMs);
}
Logger.Debug("Subscribed to alarm events on {NodeId}", monitorNode);
}
finally
{
_subscriptionGate.Release();
}
}
///
public async Task UnsubscribeAlarmsAsync(CancellationToken ct = default)
{
ThrowIfDisposed();
await _subscriptionGate.WaitAsync(ct);
try
{
ISubscriptionAdapter? subscription;
lock (_subscriptionLock)
{
subscription = _alarmSubscription;
if (subscription == null) return;
}
await subscription.DeleteAsync(ct);
lock (_subscriptionLock)
{
_alarmSubscription = null;
_activeAlarmSubscription = null;
}
}
finally
{
_subscriptionGate.Release();
}
Logger.Debug("Unsubscribed from alarm events");
}
///
public async Task RequestConditionRefreshAsync(CancellationToken ct = default)
{
ThrowIfDisposed();
ThrowIfNotConnected();
ISubscriptionAdapter? subscription;
lock (_subscriptionLock) subscription = _alarmSubscription;
if (subscription == null)
throw new InvalidOperationException("No alarm subscription is active.");
await subscription.ConditionRefreshAsync(ct);
Logger.Debug("Condition refresh requested");
}
///
public async Task AcknowledgeAlarmAsync(string conditionNodeId, byte[] eventId, string comment,
CancellationToken ct = default)
{
ThrowIfDisposed();
ThrowIfNotConnected();
// OtOpcUa conditions are their own event source (SourceNode == ConditionId), so the Part 9 methods are
// invoked on that node directly. There is no ".Condition" child to append.
var conditionObjId = NodeId.Parse(conditionNodeId);
var acknowledgeMethodId = MethodIds.AcknowledgeableConditionType_Acknowledge;
// CallMethodAsync throws ServiceResultException on a bad call result;
// surface that as the returned StatusCode so callers using the documented
// `Task` contract (e.g. `if (StatusCode.IsBad(result))`) see
// the failure instead of an uncaught exception they did not anticipate.
try
{
await _session!.CallMethodAsync(
conditionObjId,
acknowledgeMethodId,
[eventId, new LocalizedText(comment)],
ct);
}
catch (ServiceResultException ex)
{
Logger.Warning(ex, "Failed to acknowledge alarm on {ConditionId} (status {Status})",
conditionNodeId, ex.StatusCode);
return ex.StatusCode;
}
Logger.Debug("Acknowledged alarm on {ConditionId}", conditionNodeId);
return StatusCodes.Good;
}
///
public async Task ConfirmAlarmAsync(string conditionNodeId, byte[] eventId, string comment,
CancellationToken ct = default)
{
ThrowIfDisposed();
ThrowIfNotConnected();
// Confirm is invoked on the condition node itself, like Acknowledge.
var conditionObjId = NodeId.Parse(conditionNodeId);
var confirmMethodId = MethodIds.AcknowledgeableConditionType_Confirm;
try
{
await _session!.CallMethodAsync(
conditionObjId,
confirmMethodId,
[eventId, new LocalizedText(comment)],
ct);
}
catch (ServiceResultException ex)
{
Logger.Warning(ex, "Failed to confirm alarm on {ConditionId} (status {Status})",
conditionNodeId, ex.StatusCode);
return ex.StatusCode;
}
Logger.Debug("Confirmed alarm on {ConditionId}", conditionNodeId);
return StatusCodes.Good;
}
///
public async Task AddCommentAsync(string conditionNodeId, byte[] eventId, string comment,
CancellationToken ct = default)
{
ThrowIfDisposed();
ThrowIfNotConnected();
// AddComment is a ConditionType method, invoked on the condition node itself.
var conditionObjId = NodeId.Parse(conditionNodeId);
try
{
await _session!.CallMethodAsync(
conditionObjId,
MethodIds.ConditionType_AddComment,
[eventId, new LocalizedText(comment)],
ct);
}
catch (ServiceResultException ex)
{
Logger.Warning(ex, "Failed to add comment on {ConditionId} (status {Status})",
conditionNodeId, ex.StatusCode);
return ex.StatusCode;
}
Logger.Debug("Added comment on {ConditionId}", conditionNodeId);
return StatusCodes.Good;
}
///
public async Task ShelveAlarmAsync(string conditionNodeId, ShelveKind kind,
double shelvingTimeSeconds = 0, CancellationToken ct = default)
{
ThrowIfDisposed();
ThrowIfNotConnected();
// The server's ShelvingState child is SDK-created with a numeric id in namespace 0, so a synthesized
// ".ShelvingState" node id never exists. The shelve methods are called on the condition
// object itself with the AlarmConditionType_ShelvingState_* method ids, which the server dispatches
// to the condition's ShelvingState methods.
var shelvingStateNodeId = NodeId.Parse(conditionNodeId);
NodeId methodId;
object[] inputArgs;
switch (kind)
{
case ShelveKind.OneShot:
methodId = MethodIds.AlarmConditionType_ShelvingState_OneShotShelve;
inputArgs = [];
break;
case ShelveKind.Timed:
if (shelvingTimeSeconds <= 0)
throw new ArgumentOutOfRangeException(nameof(shelvingTimeSeconds),
"Timed shelving requires a positive shelvingTimeSeconds value.");
methodId = MethodIds.AlarmConditionType_ShelvingState_TimedShelve;
// OPC UA Part 9 TimedShelve ShelvingTime input is a Duration (Double) in milliseconds.
inputArgs = [shelvingTimeSeconds * 1000.0];
break;
case ShelveKind.Unshelve:
methodId = MethodIds.AlarmConditionType_ShelvingState_Unshelve;
inputArgs = [];
break;
default:
throw new ArgumentOutOfRangeException(nameof(kind), kind, "Unknown ShelveKind value.");
}
try
{
await _session!.CallMethodAsync(shelvingStateNodeId, methodId, inputArgs, ct);
}
catch (ServiceResultException ex)
{
Logger.Warning(ex, "Failed to shelve alarm on {ConditionId} kind={Kind} (status {Status})",
conditionNodeId, kind, ex.StatusCode);
return ex.StatusCode;
}
Logger.Debug("Shelved alarm on {ConditionId} kind={Kind}", conditionNodeId, kind);
return StatusCodes.Good;
}
///
public Task EnableAsync(string conditionNodeId, CancellationToken ct = default) =>
CallEnableDisableAsync(conditionNodeId, enabling: true, ct);
///
public Task DisableAsync(string conditionNodeId, CancellationToken ct = default) =>
CallEnableDisableAsync(conditionNodeId, enabling: false, ct);
///
/// Shared body for the H4 Enable/Disable client path. Invokes the OPC UA Part 9 ConditionType
/// Enable/Disable method (no input arguments) on the condition object node, mirroring
/// the convention: the methods are invoked on the condition
/// node itself, and a bad call result surfaces as the returned rather than
/// escaping as an uncaught .
///
/// The condition node associated with the alarm.
/// true to invoke Enable; false to invoke Disable.
/// The cancellation token that aborts the request.
private async Task CallEnableDisableAsync(string conditionNodeId, bool enabling, CancellationToken ct)
{
ThrowIfDisposed();
ThrowIfNotConnected();
// Enable/Disable are invoked on the condition node itself, like Acknowledge/Confirm.
var conditionObjId = NodeId.Parse(conditionNodeId);
var methodId = enabling ? MethodIds.ConditionType_Enable : MethodIds.ConditionType_Disable;
try
{
await _session!.CallMethodAsync(conditionObjId, methodId, [], ct);
}
catch (ServiceResultException ex)
{
Logger.Warning(ex, "Failed to {Operation} alarm on {ConditionId} (status {Status})",
enabling ? "enable" : "disable", conditionNodeId, ex.StatusCode);
return ex.StatusCode;
}
Logger.Debug("{Operation} alarm on {ConditionId}", enabling ? "Enabled" : "Disabled", conditionNodeId);
return StatusCodes.Good;
}
///
public async Task> HistoryReadRawAsync(
NodeId nodeId, DateTime startTime, DateTime endTime, int maxValues = 1000, CancellationToken ct = default)
{
ThrowIfDisposed();
ThrowIfNotConnected();
return await _session!.HistoryReadRawAsync(nodeId, startTime, endTime, maxValues, ct);
}
///
public async Task> HistoryReadModifiedAsync(
NodeId nodeId, DateTime startTime, DateTime endTime, int maxValues = 1000, CancellationToken ct = default)
{
ThrowIfDisposed();
ThrowIfNotConnected();
return await _session!.HistoryReadModifiedAsync(nodeId, startTime, endTime, maxValues, ct);
}
///
public async Task> HistoryReadEventsAsync(
NodeId nodeId, DateTime startTime, DateTime endTime, EventFilter filter, int maxEvents = 1000,
CancellationToken ct = default)
{
ThrowIfDisposed();
ThrowIfNotConnected();
return await _session!.HistoryReadEventsAsync(nodeId, startTime, endTime, maxEvents, filter, ct);
}
///
public async Task HistoryUpdateAsync(HistoryUpdateDetails details, CancellationToken ct = default)
{
ThrowIfDisposed();
ThrowIfNotConnected();
return await _session!.HistoryUpdateAsync(details, ct);
}
///
public async Task> HistoryReadAggregateAsync(
NodeId nodeId, DateTime startTime, DateTime endTime, AggregateType aggregate,
double intervalMs = 3600000, CancellationToken ct = default)
{
ThrowIfDisposed();
ThrowIfNotConnected();
var aggregateNodeId = AggregateTypeMapper.ToNodeId(aggregate);
return await _session!.HistoryReadAggregateAsync(nodeId, startTime, endTime, aggregateNodeId, intervalMs, ct);
}
///
public async Task GetRedundancyInfoAsync(CancellationToken ct = default)
{
ThrowIfDisposed();
ThrowIfNotConnected();
var redundancySupportValue =
await _session!.ReadValueAsync(VariableIds.Server_ServerRedundancy_RedundancySupport, ct);
RedundancySupport redundancySupport;
if (StatusCode.IsGood(redundancySupportValue.StatusCode) && ValueConverter.ToClrObject(redundancySupportValue.WrappedValue) != null)
redundancySupport = (RedundancySupport)Convert.ToInt32(ValueConverter.ToClrObject(redundancySupportValue.WrappedValue));
else
redundancySupport = RedundancySupport.None;
var redundancyMode = redundancySupport.ToString();
var serviceLevelValue = await _session.ReadValueAsync(VariableIds.Server_ServiceLevel, ct);
var serviceLevel = StatusCode.IsGood(serviceLevelValue.StatusCode) && ValueConverter.ToClrObject(serviceLevelValue.WrappedValue) != null
? Convert.ToByte(ValueConverter.ToClrObject(serviceLevelValue.WrappedValue))
: (byte)0;
string[] serverUris = [];
try
{
var serverUriArrayValue =
await _session.ReadValueAsync(VariableIds.Server_ServerRedundancy_ServerUriArray, ct);
if (ValueConverter.ToClrObject(serverUriArrayValue.WrappedValue) is string[] uris)
serverUris = uris;
}
catch
{
// ServerUriArray may not be present when RedundancySupport is None
}
var applicationUri = string.Empty;
try
{
var serverArrayValue = await _session.ReadValueAsync(VariableIds.Server_ServerArray, ct);
if (ValueConverter.ToClrObject(serverArrayValue.WrappedValue) is string[] serverArray && serverArray.Length > 0)
applicationUri = serverArray[0];
}
catch
{
// Informational only
}
return new RedundancyInfo(redundancyMode, serviceLevel, serverUris, applicationUri);
}
///
/// Releases the current session and any active monitored-item subscriptions held by the client service.
///
public void Dispose()
{
lock (_subscriptionLock)
{
if (_disposed) return;
_disposed = true;
_connectionEpoch++; // a failover still running abandons itself
}
_dataSubscription?.Dispose();
_alarmSubscription?.Dispose();
_session?.Dispose();
lock (_subscriptionLock)
{
_activeDataSubscriptions.Clear();
_activeAlarmSubscription = null;
}
CurrentConnectionInfo = null;
_state = ConnectionState.Disconnected;
}
// --- Private helpers ---
private async Task ConnectToEndpointAsync(ConnectionSettings settings, string endpointUrl,
CancellationToken ct)
{
// Create a settings copy with the current endpoint URL
var effectiveSettings = new ConnectionSettings
{
EndpointUrl = endpointUrl,
SecurityMode = settings.SecurityMode,
SessionTimeoutSeconds = settings.SessionTimeoutSeconds,
AutoAcceptCertificates = settings.AutoAcceptCertificates,
ValidateServerHostName = settings.ValidateServerHostName,
CertificateStorePath = settings.CertificateStorePath,
Username = settings.Username,
Password = settings.Password
};
var config = await _configFactory.CreateAsync(effectiveSettings, ct);
try
{
var requestedMode = SecurityModeMapper.ToMessageSecurityMode(settings.SecurityMode);
var endpoint = await _endpointDiscovery.SelectEndpointAsync(config, endpointUrl, requestedMode, ct);
var identity = settings.Username != null
? new UserIdentity(settings.Username, Encoding.UTF8.GetBytes(settings.Password ?? ""))
: new UserIdentity();
var sessionTimeoutMs = (uint)(settings.SessionTimeoutSeconds * 1000);
// #816: never on a None endpoint — SDK 2.0.1 also checks the host at channel creation against the
// GetEndpoints certificate, before any security-mode test, and on an unauthenticated channel that proves
// nothing (same rule as OpcUaEndpointSelection.CheckDomainFor for the driver and the AdminUI browser).
var checkDomain = settings.ValidateServerHostName && endpoint.SecurityMode != MessageSecurityMode.None;
return await _sessionFactory.CreateSessionAsync(config, endpoint, "OtOpcUaClient", sessionTimeoutMs, identity,
checkDomain, ct);
}
catch
{
// The session adapter owns it after successful creation; a failed attempt has no adapter owner.
try { (config.CertificateManager as IDisposable)?.Dispose(); }
catch (Exception ex) { Logger.Warning(ex, "Disposing certificate manager after connection failure"); }
throw;
}
}
/// A bad keep-alive from the session of connection .
private void OnKeepAliveFailed(long epoch)
{
// A late keep-alive from a previous connection's session is not about the current one.
if (!IsCurrentEpoch(epoch)) return;
// Serialize failover: the OPC UA stack raises KeepAlive repeatedly while a session is down, so several bad
// keep-alives can fire before the first failover loop finishes. CompareExchange claims the failover slot; a
// re-entrant call sees 1 and returns, so exactly one loop runs — and only the winner sets the test seam
// (review m8: a re-entrant call used to overwrite it with an already-completed no-op).
if (Interlocked.CompareExchange(ref _failoverInProgress, 1, 0) != 0)
return;
LastFailover = HandleKeepAliveFailureAsync(epoch);
}
private async Task HandleKeepAliveFailureAsync(long epoch)
{
try
{
await RunFailoverAsync(epoch);
}
finally
{
Interlocked.Exchange(ref _failoverInProgress, 0);
}
}
private bool IsCurrentEpoch(long epoch)
{
lock (_subscriptionLock) return _connectionEpoch == epoch && !_disposed;
}
///
/// A state transition made only while is still current (checked and applied under
/// , the event raised outside it).
///
/// when the epoch has moved on (the caller must abandon).
private bool TransitionStateIfCurrent(long epoch, ConnectionState newState, string endpointUrl)
{
ConnectionState oldState;
lock (_subscriptionLock)
{
if (_connectionEpoch != epoch || _disposed) return false;
oldState = _state;
if (oldState == newState) return true;
_state = newState;
}
ConnectionStateChanged?.Invoke(this, new ConnectionStateChangedEventArgs(oldState, newState, endpointUrl));
return true;
}
private async Task RunFailoverAsync(long epoch)
{
if (_state == ConnectionState.Reconnecting || _state == ConnectionState.Disconnected)
return;
var oldSession = _session;
var oldEndpoint = oldSession?.EndpointUrl ?? string.Empty;
if (!TransitionStateIfCurrent(epoch, ConnectionState.Reconnecting, oldEndpoint)) return;
Logger.Warning("Session lost on {EndpointUrl}. Attempting failover...", oldEndpoint);
// Dispose the dead session BEFORE taking the gate: an in-flight subscribe holding the gate is blocked on
// that session, and disposing it is what makes the call fail fast and release the gate.
if (oldSession != null)
{
try
{
oldSession.Dispose();
}
catch
{
}
}
// The rest runs under the subscription gate (and its null-outs under _subscriptionLock): no subscribe can
// interleave with the teardown, the reconnect or the replay.
await _subscriptionGate.WaitAsync();
try
{
// The user's settings / endpoint list as of THIS epoch: a Connect during the failover replaces the fields.
ConnectionSettings? settings;
string[]? endpoints;
lock (_subscriptionLock)
{
if (_connectionEpoch != epoch || _disposed) return; // the user disconnected / reconnected meanwhile
if (ReferenceEquals(_session, oldSession)) _session = null;
_dataSubscription = null;
_alarmSubscription = null;
settings = _settings;
endpoints = _allEndpointUrls;
}
if (settings == null || endpoints == null)
{
TransitionStateIfCurrent(epoch, ConnectionState.Disconnected, oldEndpoint);
return;
}
// Try each endpoint
for (var attempt = 0; attempt < endpoints.Length; attempt++)
{
if (!IsCurrentEpoch(epoch)) return; // stop trying once the user has moved on (m5)
_currentEndpointIndex = (_currentEndpointIndex + 1) % endpoints.Length;
var url = endpoints[_currentEndpointIndex];
try
{
Logger.Information("Failover attempt to {EndpointUrl}", url);
var session = await ConnectToEndpointAsync(settings, url, CancellationToken.None);
// A Disconnect / Connect / Dispose that ran while this attempt was connecting wins: never
// resurrect the old connection or overwrite the user's new session. Checked and installed
// atomically against the epoch.
bool installed;
lock (_subscriptionLock)
{
installed = _connectionEpoch == epoch && !_disposed;
if (installed) _session = session;
}
if (!installed)
{
session.Dispose();
return;
}
session.RegisterKeepAliveHandler(isGood =>
{
if (!isGood) OnKeepAliveFailed(epoch);
});
CurrentConnectionInfo = BuildConnectionInfo(session);
if (!TransitionStateIfCurrent(epoch, ConnectionState.Connected, url)) return;
Logger.Information("Failover succeeded to {EndpointUrl}", url);
// Replay subscriptions
await ReplaySubscriptionsAsync(session);
return;
}
catch (Exception ex)
{
Logger.Warning(ex, "Failover to {EndpointUrl} failed", url);
}
}
if (TransitionStateIfCurrent(epoch, ConnectionState.Disconnected, oldEndpoint))
Logger.Error("All failover endpoints unreachable");
}
finally
{
_subscriptionGate.Release();
}
}
/// Re-creates the recorded subscriptions on . Runs under the subscription gate.
private async Task ReplaySubscriptionsAsync(ISessionAdapter session)
{
// Snapshot the bookkeeping state under the lock, then clear it so the
// replayed handles can be recorded fresh as each monitored item is
// re-created. Awaited calls run outside the lock.
List> subscriptions;
(NodeId? SourceNodeId, int IntervalMs)? alarmSubscription;
lock (_subscriptionLock)
{
subscriptions = _activeDataSubscriptions.ToList();
alarmSubscription = _activeAlarmSubscription;
_activeDataSubscriptions.Clear();
_activeAlarmSubscription = null;
}
// Replay data subscriptions
ISubscriptionAdapter? dataSubscription = null;
foreach (var (nodeIdStr, (nodeId, intervalMs, _)) in subscriptions)
try
{
if (dataSubscription == null)
{
dataSubscription = await session.CreateSubscriptionAsync(intervalMs, CancellationToken.None);
lock (_subscriptionLock) _dataSubscription = dataSubscription;
}
var handle = await dataSubscription.AddDataChangeMonitoredItemAsync(
nodeId, intervalMs, DataCallback(session, dataSubscription), CancellationToken.None);
lock (_subscriptionLock)
{
_activeDataSubscriptions[nodeIdStr] = (nodeId, intervalMs, handle);
}
}
catch (Exception ex)
{
Logger.Warning(ex, "Failed to replay data subscription for {NodeId}", nodeIdStr);
}
// Replay alarm subscription
if (alarmSubscription.HasValue)
{
var (sourceNodeId, intervalMs) = alarmSubscription.Value;
try
{
var monitorNode = sourceNodeId ?? ObjectIds.Server;
var replayedAlarms = await session.CreateSubscriptionAsync(intervalMs, CancellationToken.None);
lock (_subscriptionLock) _alarmSubscription = replayedAlarms;
var filter = CreateAlarmEventFilter();
await replayedAlarms.AddEventMonitoredItemAsync(
monitorNode, intervalMs, filter, AlarmCallback(session, replayedAlarms), CancellationToken.None);
lock (_subscriptionLock)
{
_activeAlarmSubscription = (sourceNodeId, intervalMs);
}
}
catch (Exception ex)
{
Logger.Warning(ex, "Failed to replay alarm subscription");
}
}
}
private bool IsCurrentNotification(ISessionAdapter session, ISubscriptionAdapter subscription, bool alarm)
{
lock (_subscriptionLock)
return !_disposed && _state == ConnectionState.Connected && ReferenceEquals(_session, session)
&& ReferenceEquals(alarm ? _alarmSubscription : _dataSubscription, subscription);
}
private Action DataCallback(ISessionAdapter session, ISubscriptionAdapter subscription) =>
(nodeId, value) =>
{
if (IsCurrentNotification(session, subscription, false)) OnDataChangeNotification(nodeId, value);
};
private Action AlarmCallback(ISessionAdapter session, ISubscriptionAdapter subscription) =>
fields =>
{
if (IsCurrentNotification(session, subscription, true)) OnAlarmEventNotification(fields);
};
private void OnDataChangeNotification(string nodeId, DataValue value)
{
DataChanged?.Invoke(this, new DataChangedEventArgs(nodeId, value));
}
private void OnAlarmEventNotification(EventFieldList eventFields)
{
var fields = eventFields.EventFields;
if (fields.Count < 1)
return;
var eventId = fields.Count > 0 ? ValueConverter.ToClrObject(fields[0]) as byte[] : null;
var sourceName = fields.Count > 2 ? ValueConverter.ToClrObject(fields[2]) as string ?? string.Empty : string.Empty;
var time = DateTime.MinValue;
if (fields.Count > 3 && ValueConverter.ToClrObject(fields[3]) != null)
{
if (ValueConverter.ToClrObject(fields[3]) is DateTime dt)
time = dt;
else if (DateTime.TryParse(ValueConverter.ToClrObject(fields[3])?.ToString(), out var parsed))
time = parsed;
}
var message = fields.Count > 4 ? (fields[4].TryGetValue(out LocalizedText text) ? text.Text : null) ?? string.Empty : string.Empty;
var severity = fields.Count > 5 ? Convert.ToUInt16(ValueConverter.ToClrObject(fields[5])) : (ushort)0;
var conditionName = fields.Count > 6 ? ValueConverter.ToClrObject(fields[6]) as string ?? string.Empty : string.Empty;
var retain = fields.Count > 7 && ParseBool(ValueConverter.ToClrObject(fields[7]));
var conditionNodeId = fields.Count > 12 ? ValueConverter.ToClrObject(fields[12])?.ToString() : null;
// Try standard OPC UA ActiveState/AckedState fields first
bool? ackedField = fields.Count > 8 && ValueConverter.ToClrObject(fields[8]) != null ? ParseBool(ValueConverter.ToClrObject(fields[8])) : null;
bool? activeField = fields.Count > 9 && ValueConverter.ToClrObject(fields[9]) != null ? ParseBool(ValueConverter.ToClrObject(fields[9])) : null;
var ackedState = ackedField ?? false;
var activeState = activeField ?? false;
// No Galaxy-era supplemental reads (.InAlarm/.Acked/...): the server publishes the
// standard Part 9 fields. A foreign server that leaves both null reports Active=false/Acked=false.
AlarmEvent?.Invoke(this, new AlarmEventArgs(
sourceName, conditionName, severity, message, retain, activeState, ackedState, time,
eventId, conditionNodeId));
}
private static bool ParseBool(object? value)
{
if (value == null) return false;
if (value is bool b) return b;
try { return Convert.ToBoolean(value); }
catch { return false; }
}
/// Builds the event filter (select clauses for the alarm and condition fields) used by alarm subscriptions.
/// The populated .
internal static EventFilter CreateAlarmEventFilter()
{
var filter = new EventFilter
{
SelectClauses =
[
new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.BaseEventType, AttributeId = Attributes.Value, BrowsePath = [new QualifiedName("EventId")] },
new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.BaseEventType, AttributeId = Attributes.Value, BrowsePath = [new QualifiedName("EventType")] },
new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.BaseEventType, AttributeId = Attributes.Value, BrowsePath = [new QualifiedName("SourceName")] },
new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.BaseEventType, AttributeId = Attributes.Value, BrowsePath = [new QualifiedName("Time")] },
new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.BaseEventType, AttributeId = Attributes.Value, BrowsePath = [new QualifiedName("Message")] },
new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.BaseEventType, AttributeId = Attributes.Value, BrowsePath = [new QualifiedName("Severity")] },
new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.ConditionType, AttributeId = Attributes.Value, BrowsePath = [new QualifiedName("ConditionName")] },
new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.ConditionType, AttributeId = Attributes.Value, BrowsePath = [new QualifiedName("Retain")] },
new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.AcknowledgeableConditionType, AttributeId = Attributes.Value, BrowsePath = [new QualifiedName("AckedState"), new QualifiedName("Id")] },
new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.AlarmConditionType, AttributeId = Attributes.Value, BrowsePath = [new QualifiedName("ActiveState"), new QualifiedName("Id")] },
new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.ConditionType, AttributeId = Attributes.Value, BrowsePath = [new QualifiedName("EnabledState"), new QualifiedName("Id")] },
new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.AlarmConditionType, AttributeId = Attributes.Value, BrowsePath = [new QualifiedName("SuppressedOrShelved")] },
new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.BaseEventType, AttributeId = Attributes.Value, BrowsePath = [new QualifiedName("SourceNode")] },
]
};
return filter;
}
private static ConnectionInfo BuildConnectionInfo(ISessionAdapter session)
{
return new ConnectionInfo(
session.EndpointUrl,
session.ServerName,
session.SecurityMode,
session.SecurityPolicyUri,
session.SessionId,
session.SessionName);
}
private void TransitionState(ConnectionState newState, string endpointUrl)
{
ConnectionState oldState;
lock (_subscriptionLock)
{
oldState = _state;
if (oldState == newState) return;
_state = newState;
}
ConnectionStateChanged?.Invoke(this, new ConnectionStateChangedEventArgs(oldState, newState, endpointUrl));
}
private void ThrowIfDisposed()
{
if (_disposed) throw new ObjectDisposedException(nameof(OpcUaClientService));
}
private void ThrowIfNotConnected()
{
if (_state != ConnectionState.Connected || _session == null)
throw new InvalidOperationException("Not connected to an OPC UA server.");
}
}