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>
699 lines
26 KiB
C#
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.");
|
|
}
|
|
}
|