Files
lmxopcua/src/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs
Joseph Doherty 3b2defd94f Phase 0 — mechanical rename ZB.MOM.WW.LmxOpcUa.* → ZB.MOM.WW.OtOpcUa.*
Renames all 11 projects (5 src + 6 tests), the .slnx solution file, all source-file namespaces, all axaml namespace references, and all v1 documentation references in CLAUDE.md and docs/*.md (excluding docs/v2/ which is already in OtOpcUa form). Also updates the TopShelf service registration name from "LmxOpcUa" to "OtOpcUa" per Phase 0 Task 0.6.

Preserves runtime identifiers per Phase 0 Out-of-Scope rules to avoid breaking v1/v2 client trust during coexistence: OPC UA `ApplicationUri` defaults (`urn:{GalaxyName}:LmxOpcUa`), server `EndpointPath` (`/LmxOpcUa`), `ServerName` default (feeds cert subject CN), `MxAccessConfiguration.ClientName` default (defensive — stays "LmxOpcUa" for MxAccess audit-trail consistency), client OPC UA identifiers (`ApplicationName = "LmxOpcUaClient"`, `ApplicationUri = "urn:localhost:LmxOpcUaClient"`, cert directory `%LocalAppData%\LmxOpcUaClient\pki\`), and the `LmxOpcUaServer` class name (class rename out of Phase 0 scope per Task 0.5 sed pattern; happens in Phase 1 alongside `LmxNodeManager → GenericDriverNodeManager` Core extraction). 23 LmxOpcUa references retained, all enumerated and justified in `docs/v2/implementation/exit-gate-phase-0.md`.

Build clean: 0 errors, 30 warnings (lower than baseline 167). Tests at strict improvement over baseline: 821 passing / 1 failing vs baseline 820 / 2 (one flaky pre-existing failure passed this run; the other still fails — both pre-existing and unrelated to the rename). `Client.UI.Tests`, `Historian.Aveva.Tests`, `Client.Shared.Tests`, `IntegrationTests` all match baseline exactly. Exit gate compliance results recorded in `docs/v2/implementation/exit-gate-phase-0.md` with all 7 checks PASS or DEFERRED-to-PR-review (#7 service install verification needs Windows service permissions on the reviewer's box).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 13:57:47 -04:00

699 lines
26 KiB
C#

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;
/// <summary>
/// Full implementation of <see cref="IOpcUaClientService" /> using adapter abstractions for testability.
/// </summary>
public sealed class OpcUaClientService : IOpcUaClientService
{
private static readonly ILogger Logger = Log.ForContext<OpcUaClientService>();
// Track active data subscriptions for replay after failover
private readonly Dictionary<string, (NodeId NodeId, int IntervalMs, uint Handle)> _activeDataSubscriptions = new();
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;
/// <summary>
/// Creates a new OpcUaClientService with the specified adapter dependencies.
/// </summary>
/// <param name="configFactory">Builds the application configuration and certificate settings for the client session.</param>
/// <param name="endpointDiscovery">Selects the best matching endpoint for the requested URL and security mode.</param>
/// <param name="sessionFactory">Creates the underlying OPC UA session used for runtime operations.</param>
internal OpcUaClientService(
IApplicationConfigurationFactory configFactory,
IEndpointDiscovery endpointDiscovery,
ISessionFactory sessionFactory)
{
_configFactory = configFactory;
_endpointDiscovery = endpointDiscovery;
_sessionFactory = sessionFactory;
}
/// <summary>
/// Creates a new OpcUaClientService with default production adapters.
/// </summary>
public OpcUaClientService()
: this(
new DefaultApplicationConfigurationFactory(),
new DefaultEndpointDiscovery(),
new DefaultSessionFactory())
{
}
/// <inheritdoc />
public event EventHandler<DataChangedEventArgs>? DataChanged;
/// <inheritdoc />
public event EventHandler<AlarmEventArgs>? AlarmEvent;
/// <inheritdoc />
public event EventHandler<ConnectionStateChangedEventArgs>? ConnectionStateChanged;
/// <inheritdoc />
public bool IsConnected => _state == ConnectionState.Connected && _session?.Connected == true;
/// <inheritdoc />
public ConnectionInfo? CurrentConnectionInfo { get; private set; }
/// <inheritdoc />
public async Task<ConnectionInfo> ConnectAsync(ConnectionSettings settings, CancellationToken ct = default)
{
ThrowIfDisposed();
settings.Validate();
_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);
_session = session;
session.RegisterKeepAliveHandler(isGood =>
{
if (!isGood) _ = HandleKeepAliveFailureAsync();
});
CurrentConnectionInfo = BuildConnectionInfo(session);
TransitionState(ConnectionState.Connected, session.EndpointUrl);
Logger.Information("Connected to {EndpointUrl}", session.EndpointUrl);
return CurrentConnectionInfo;
}
catch
{
TransitionState(ConnectionState.Disconnected, settings.EndpointUrl);
throw;
}
}
/// <inheritdoc />
public async Task DisconnectAsync(CancellationToken ct = default)
{
if (_state == ConnectionState.Disconnected)
return;
var endpointUrl = _session?.EndpointUrl ?? _settings?.EndpointUrl ?? string.Empty;
try
{
if (_dataSubscription != null)
{
await _dataSubscription.DeleteAsync(ct);
_dataSubscription = null;
}
if (_alarmSubscription != null)
{
await _alarmSubscription.DeleteAsync(ct);
_alarmSubscription = null;
}
if (_session != null)
{
await _session.CloseAsync(ct);
_session.Dispose();
_session = null;
}
}
catch (Exception ex)
{
Logger.Warning(ex, "Error during disconnect");
}
finally
{
_activeDataSubscriptions.Clear();
_activeAlarmSubscription = null;
CurrentConnectionInfo = null;
TransitionState(ConnectionState.Disconnected, endpointUrl);
}
}
/// <inheritdoc />
public async Task<DataValue> ReadValueAsync(NodeId nodeId, CancellationToken ct = default)
{
ThrowIfDisposed();
ThrowIfNotConnected();
return await _session!.ReadValueAsync(nodeId, ct);
}
/// <inheritdoc />
public async Task<StatusCode> 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);
typedValue = ValueConverter.ConvertValue(rawString, currentDataValue.Value);
}
var dataValue = new DataValue(new Variant(typedValue));
return await _session!.WriteValueAsync(nodeId, dataValue, ct);
}
/// <inheritdoc />
public async Task<IReadOnlyList<BrowseResult>> 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<BrowseResult>();
var (continuationPoint, references) = await _session!.BrowseAsync(startNode, nodeClassMask, ct);
while (references.Count > 0)
{
foreach (var reference in references)
{
var childNodeId = ExpandedNodeId.ToNodeId(reference.NodeId, _session.NamespaceUris);
var hasChildren = reference.NodeClass == NodeClass.Object &&
await _session.HasChildrenAsync(childNodeId, ct);
results.Add(new BrowseResult(
reference.NodeId.ToString(),
reference.DisplayName?.Text ?? string.Empty,
reference.NodeClass.ToString(),
hasChildren));
}
if (continuationPoint != null && continuationPoint.Length > 0)
(continuationPoint, references) = await _session.BrowseNextAsync(continuationPoint, ct);
else
break;
}
return results;
}
/// <inheritdoc />
public async Task SubscribeAsync(NodeId nodeId, int intervalMs = 1000, CancellationToken ct = default)
{
ThrowIfDisposed();
ThrowIfNotConnected();
var nodeIdStr = nodeId.ToString();
if (_activeDataSubscriptions.ContainsKey(nodeIdStr))
return; // Already subscribed
if (_dataSubscription == null) _dataSubscription = await _session!.CreateSubscriptionAsync(intervalMs, ct);
var handle = await _dataSubscription.AddDataChangeMonitoredItemAsync(
nodeId, intervalMs, OnDataChangeNotification, ct);
_activeDataSubscriptions[nodeIdStr] = (nodeId, intervalMs, handle);
Logger.Debug("Subscribed to data changes on {NodeId}", nodeId);
}
/// <inheritdoc />
public async Task UnsubscribeAsync(NodeId nodeId, CancellationToken ct = default)
{
ThrowIfDisposed();
var nodeIdStr = nodeId.ToString();
if (!_activeDataSubscriptions.TryGetValue(nodeIdStr, out var sub))
return; // Not subscribed, safe to ignore
if (_dataSubscription != null) await _dataSubscription.RemoveMonitoredItemAsync(sub.Handle, ct);
_activeDataSubscriptions.Remove(nodeIdStr);
Logger.Debug("Unsubscribed from data changes on {NodeId}", nodeId);
}
/// <inheritdoc />
public async Task SubscribeAlarmsAsync(NodeId? sourceNodeId = null, int intervalMs = 1000,
CancellationToken ct = default)
{
ThrowIfDisposed();
ThrowIfNotConnected();
if (_alarmSubscription != null)
return; // Already subscribed to alarms
var monitorNode = sourceNodeId ?? ObjectIds.Server;
_alarmSubscription = await _session!.CreateSubscriptionAsync(intervalMs, ct);
var filter = CreateAlarmEventFilter();
await _alarmSubscription.AddEventMonitoredItemAsync(
monitorNode, intervalMs, filter, OnAlarmEventNotification, ct);
_activeAlarmSubscription = (sourceNodeId, intervalMs);
Logger.Debug("Subscribed to alarm events on {NodeId}", monitorNode);
}
/// <inheritdoc />
public async Task UnsubscribeAlarmsAsync(CancellationToken ct = default)
{
ThrowIfDisposed();
if (_alarmSubscription == null)
return;
await _alarmSubscription.DeleteAsync(ct);
_alarmSubscription = null;
_activeAlarmSubscription = null;
Logger.Debug("Unsubscribed from alarm events");
}
/// <inheritdoc />
public async Task RequestConditionRefreshAsync(CancellationToken ct = default)
{
ThrowIfDisposed();
ThrowIfNotConnected();
if (_alarmSubscription == null)
throw new InvalidOperationException("No alarm subscription is active.");
await _alarmSubscription.ConditionRefreshAsync(ct);
Logger.Debug("Condition refresh requested");
}
/// <inheritdoc />
public async Task<StatusCode> AcknowledgeAlarmAsync(string conditionNodeId, byte[] eventId, string comment,
CancellationToken ct = default)
{
ThrowIfDisposed();
ThrowIfNotConnected();
// The Acknowledge method lives on the .Condition child node, not the source node itself
var conditionObjId = conditionNodeId.EndsWith(".Condition")
? NodeId.Parse(conditionNodeId)
: NodeId.Parse(conditionNodeId + ".Condition");
var acknowledgeMethodId = MethodIds.AcknowledgeableConditionType_Acknowledge;
await _session!.CallMethodAsync(
conditionObjId,
acknowledgeMethodId,
[eventId, new LocalizedText(comment)],
ct);
Logger.Debug("Acknowledged alarm on {ConditionId}", conditionNodeId);
return StatusCodes.Good;
}
/// <inheritdoc />
public async Task<IReadOnlyList<DataValue>> HistoryReadRawAsync(
NodeId nodeId, DateTime startTime, DateTime endTime, int maxValues = 1000, CancellationToken ct = default)
{
ThrowIfDisposed();
ThrowIfNotConnected();
return await _session!.HistoryReadRawAsync(nodeId, startTime, endTime, maxValues, ct);
}
/// <inheritdoc />
public async Task<IReadOnlyList<DataValue>> 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);
}
/// <inheritdoc />
public async Task<RedundancyInfo> GetRedundancyInfoAsync(CancellationToken ct = default)
{
ThrowIfDisposed();
ThrowIfNotConnected();
var redundancySupportValue =
await _session!.ReadValueAsync(VariableIds.Server_ServerRedundancy_RedundancySupport, ct);
var redundancyMode = ((RedundancySupport)(int)redundancySupportValue.Value).ToString();
var serviceLevelValue = await _session.ReadValueAsync(VariableIds.Server_ServiceLevel, ct);
var serviceLevel = (byte)serviceLevelValue.Value;
string[] serverUris = [];
try
{
var serverUriArrayValue =
await _session.ReadValueAsync(VariableIds.Server_ServerRedundancy_ServerUriArray, ct);
if (serverUriArrayValue.Value 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 (serverArrayValue.Value is string[] serverArray && serverArray.Length > 0)
applicationUri = serverArray[0];
}
catch
{
// Informational only
}
return new RedundancyInfo(redundancyMode, serviceLevel, serverUris, applicationUri);
}
/// <summary>
/// Releases the current session and any active monitored-item subscriptions held by the client service.
/// </summary>
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_dataSubscription?.Dispose();
_alarmSubscription?.Dispose();
_session?.Dispose();
_activeDataSubscriptions.Clear();
_activeAlarmSubscription = null;
CurrentConnectionInfo = null;
_state = ConnectionState.Disconnected;
}
// --- Private helpers ---
private async Task<ISessionAdapter> 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,
CertificateStorePath = settings.CertificateStorePath,
Username = settings.Username,
Password = settings.Password
};
var config = await _configFactory.CreateAsync(effectiveSettings, ct);
var requestedMode = SecurityModeMapper.ToMessageSecurityMode(settings.SecurityMode);
var endpoint = _endpointDiscovery.SelectEndpoint(config, endpointUrl, requestedMode);
var identity = settings.Username != null
? new UserIdentity(settings.Username, Encoding.UTF8.GetBytes(settings.Password ?? ""))
: new UserIdentity();
var sessionTimeoutMs = (uint)(settings.SessionTimeoutSeconds * 1000);
return await _sessionFactory.CreateSessionAsync(config, endpoint, "LmxOpcUaClient", sessionTimeoutMs, identity,
ct);
}
private async Task HandleKeepAliveFailureAsync()
{
if (_state == ConnectionState.Reconnecting || _state == ConnectionState.Disconnected)
return;
var oldEndpoint = _session?.EndpointUrl ?? string.Empty;
TransitionState(ConnectionState.Reconnecting, oldEndpoint);
Logger.Warning("Session lost on {EndpointUrl}. Attempting failover...", oldEndpoint);
// Close old session
if (_session != null)
{
try
{
_session.Dispose();
}
catch
{
}
_session = null;
}
_dataSubscription = null;
_alarmSubscription = null;
if (_settings == null || _allEndpointUrls == null)
{
TransitionState(ConnectionState.Disconnected, oldEndpoint);
return;
}
// Try each endpoint
for (var attempt = 0; attempt < _allEndpointUrls.Length; attempt++)
{
_currentEndpointIndex = (_currentEndpointIndex + 1) % _allEndpointUrls.Length;
var url = _allEndpointUrls[_currentEndpointIndex];
try
{
Logger.Information("Failover attempt to {EndpointUrl}", url);
var session = await ConnectToEndpointAsync(_settings, url, CancellationToken.None);
_session = session;
session.RegisterKeepAliveHandler(isGood =>
{
if (!isGood) _ = HandleKeepAliveFailureAsync();
});
CurrentConnectionInfo = BuildConnectionInfo(session);
TransitionState(ConnectionState.Connected, url);
Logger.Information("Failover succeeded to {EndpointUrl}", url);
// Replay subscriptions
await ReplaySubscriptionsAsync();
return;
}
catch (Exception ex)
{
Logger.Warning(ex, "Failover to {EndpointUrl} failed", url);
}
}
Logger.Error("All failover endpoints unreachable");
TransitionState(ConnectionState.Disconnected, oldEndpoint);
}
private async Task ReplaySubscriptionsAsync()
{
// Replay data subscriptions
if (_activeDataSubscriptions.Count > 0)
{
var subscriptions = _activeDataSubscriptions.ToList();
_activeDataSubscriptions.Clear();
foreach (var (nodeIdStr, (nodeId, intervalMs, _)) in subscriptions)
try
{
if (_dataSubscription == null)
_dataSubscription = await _session!.CreateSubscriptionAsync(intervalMs, CancellationToken.None);
var handle = await _dataSubscription.AddDataChangeMonitoredItemAsync(
nodeId, intervalMs, OnDataChangeNotification, CancellationToken.None);
_activeDataSubscriptions[nodeIdStr] = (nodeId, intervalMs, handle);
}
catch (Exception ex)
{
Logger.Warning(ex, "Failed to replay data subscription for {NodeId}", nodeIdStr);
}
}
// Replay alarm subscription
if (_activeAlarmSubscription.HasValue)
{
var (sourceNodeId, intervalMs) = _activeAlarmSubscription.Value;
_activeAlarmSubscription = null;
try
{
var monitorNode = sourceNodeId ?? ObjectIds.Server;
_alarmSubscription = await _session!.CreateSubscriptionAsync(intervalMs, CancellationToken.None);
var filter = CreateAlarmEventFilter();
await _alarmSubscription.AddEventMonitoredItemAsync(
monitorNode, intervalMs, filter, OnAlarmEventNotification, CancellationToken.None);
_activeAlarmSubscription = (sourceNodeId, intervalMs);
}
catch (Exception ex)
{
Logger.Warning(ex, "Failed to replay alarm subscription");
}
}
}
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 == null || fields.Count < 6)
return;
var eventId = fields.Count > 0 ? fields[0].Value as byte[] : null;
var sourceName = fields.Count > 2 ? fields[2].Value as string ?? string.Empty : string.Empty;
var time = DateTime.MinValue;
if (fields.Count > 3 && fields[3].Value != null)
{
if (fields[3].Value is DateTime dt)
time = dt;
else if (DateTime.TryParse(fields[3].Value.ToString(), out var parsed))
time = parsed;
}
var message = fields.Count > 4 ? (fields[4].Value as LocalizedText)?.Text ?? string.Empty : string.Empty;
var severity = fields.Count > 5 ? Convert.ToUInt16(fields[5].Value) : (ushort)0;
var conditionName = fields.Count > 6 ? fields[6].Value as string ?? string.Empty : string.Empty;
var retain = fields.Count > 7 && ParseBool(fields[7].Value);
var conditionNodeId = fields.Count > 12 ? fields[12].Value?.ToString() : null;
// Try standard OPC UA ActiveState/AckedState fields first
bool? ackedField = fields.Count > 8 && fields[8].Value != null ? ParseBool(fields[8].Value) : null;
bool? activeField = fields.Count > 9 && fields[9].Value != null ? ParseBool(fields[9].Value) : null;
var ackedState = ackedField ?? false;
var activeState = activeField ?? false;
// Fallback: read InAlarm/Acked from condition node Galaxy attributes
// when the server doesn't populate standard event fields.
// Must run on a background thread to avoid deadlocking the notification thread.
if (ackedField == null && activeField == null && conditionNodeId != null && _session != null)
{
var session = _session;
var capturedConditionNodeId = conditionNodeId;
var capturedMessage = message;
_ = Task.Run(async () =>
{
try
{
var inAlarmValue = await session.ReadValueAsync(NodeId.Parse($"{capturedConditionNodeId}.InAlarm"));
if (inAlarmValue.Value is bool inAlarm) activeState = inAlarm;
var ackedValue = await session.ReadValueAsync(NodeId.Parse($"{capturedConditionNodeId}.Acked"));
if (ackedValue.Value is bool acked) ackedState = acked;
if (time == DateTime.MinValue && activeState)
{
var timeValue = await session.ReadValueAsync(NodeId.Parse($"{capturedConditionNodeId}.TimeAlarmOn"));
if (timeValue.Value is DateTime alarmTime && alarmTime != DateTime.MinValue)
time = alarmTime;
}
// Read alarm description to use as message
try
{
var descValue = await session.ReadValueAsync(NodeId.Parse($"{capturedConditionNodeId}.DescAttrName"));
if (descValue.Value is string desc && !string.IsNullOrEmpty(desc))
capturedMessage = desc;
}
catch { /* DescAttrName may not exist */ }
}
catch
{
// Supplemental read failed; use defaults
}
AlarmEvent?.Invoke(this, new AlarmEventArgs(
sourceName, conditionName, severity, capturedMessage, retain, activeState, ackedState, time,
eventId, capturedConditionNodeId));
});
return;
}
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; }
}
private static EventFilter CreateAlarmEventFilter()
{
var filter = new EventFilter();
// 0: EventId
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.EventId);
// 1: EventType
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.EventType);
// 2: SourceName
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.SourceName);
// 3: Time
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.Time);
// 4: Message
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.Message);
// 5: Severity
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.Severity);
// 6: ConditionName
filter.AddSelectClause(ObjectTypeIds.ConditionType, BrowseNames.ConditionName);
// 7: Retain
filter.AddSelectClause(ObjectTypeIds.ConditionType, BrowseNames.Retain);
// 8: AckedState/Id
filter.AddSelectClause(ObjectTypeIds.AcknowledgeableConditionType, "AckedState/Id");
// 9: ActiveState/Id
filter.AddSelectClause(ObjectTypeIds.AlarmConditionType, "ActiveState/Id");
// 10: EnabledState/Id
filter.AddSelectClause(ObjectTypeIds.AlarmConditionType, "EnabledState/Id");
// 11: SuppressedOrShelved
filter.AddSelectClause(ObjectTypeIds.AlarmConditionType, "SuppressedOrShelved");
// 12: SourceNode (ConditionId for acknowledgment)
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.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)
{
var 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.");
}
}