|
|
|
|
@@ -61,6 +61,12 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
|
|
|
|
|
private bool _disposed;
|
|
|
|
|
/// <summary>URL of the endpoint the driver actually connected to. Exposed via <see cref="HostName"/>.</summary>
|
|
|
|
|
private string? _connectedEndpointUrl;
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// SDK-provided reconnect handler that owns the retry loop + session-transfer machinery
|
|
|
|
|
/// when the session's keep-alive channel reports a bad status. Null outside the
|
|
|
|
|
/// reconnecting window; constructed lazily inside the keep-alive handler.
|
|
|
|
|
/// </summary>
|
|
|
|
|
private SessionReconnectHandler? _reconnectHandler;
|
|
|
|
|
|
|
|
|
|
public string DriverInstanceId => driverInstanceId;
|
|
|
|
|
public string DriverType => "OpcUaClient";
|
|
|
|
|
@@ -104,16 +110,13 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
|
|
|
|
|
"Tried:\n " + string.Join("\n ", attemptErrors),
|
|
|
|
|
attemptErrors.Select(e => new InvalidOperationException(e)));
|
|
|
|
|
|
|
|
|
|
// Wire the session's keep-alive channel into HostState. OPC UA keep-alives are
|
|
|
|
|
// authoritative for session liveness: the SDK pings on KeepAliveInterval and sets
|
|
|
|
|
// KeepAliveStopped when N intervals elapse without a response. That's strictly
|
|
|
|
|
// better than a driver-side polling probe — no extra round-trip, no duplicate
|
|
|
|
|
// semantic.
|
|
|
|
|
_keepAliveHandler = (_, e) =>
|
|
|
|
|
{
|
|
|
|
|
var healthy = !ServiceResult.IsBad(e.Status);
|
|
|
|
|
TransitionTo(healthy ? HostState.Running : HostState.Stopped);
|
|
|
|
|
};
|
|
|
|
|
// Wire the session's keep-alive channel into HostState + the reconnect trigger.
|
|
|
|
|
// OPC UA keep-alives are authoritative for session liveness: the SDK pings on
|
|
|
|
|
// KeepAliveInterval and sets KeepAliveStopped when N intervals elapse without a
|
|
|
|
|
// response. On a bad keep-alive the driver spins up a SessionReconnectHandler
|
|
|
|
|
// which transparently retries + swaps the underlying session. Subscriptions move
|
|
|
|
|
// via TransferSubscriptions so local MonitoredItem handles stay valid.
|
|
|
|
|
_keepAliveHandler = OnKeepAlive;
|
|
|
|
|
session.KeepAlive += _keepAliveHandler;
|
|
|
|
|
|
|
|
|
|
Session = session;
|
|
|
|
|
@@ -392,6 +395,13 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
|
|
|
|
|
}
|
|
|
|
|
_subscriptions.Clear();
|
|
|
|
|
|
|
|
|
|
// Abort any in-flight reconnect attempts before touching the session — BeginReconnect's
|
|
|
|
|
// retry loop holds a reference to the current session and would fight Session.CloseAsync
|
|
|
|
|
// if left spinning.
|
|
|
|
|
try { _reconnectHandler?.CancelReconnect(); } catch { }
|
|
|
|
|
_reconnectHandler?.Dispose();
|
|
|
|
|
_reconnectHandler = null;
|
|
|
|
|
|
|
|
|
|
if (_keepAliveHandler is not null && Session is not null)
|
|
|
|
|
{
|
|
|
|
|
try { Session.KeepAlive -= _keepAliveHandler; } catch { }
|
|
|
|
|
@@ -581,20 +591,46 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
|
|
|
|
|
var rootFolder = builder.Folder("Remote", "Remote");
|
|
|
|
|
var visited = new HashSet<NodeId>();
|
|
|
|
|
var discovered = 0;
|
|
|
|
|
var pendingVariables = new List<PendingVariable>();
|
|
|
|
|
|
|
|
|
|
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
// Pass 1: browse hierarchy + create folders inline, collect variables into a
|
|
|
|
|
// pending list. Defers variable registration until attributes are resolved — the
|
|
|
|
|
// address-space builder's Variable call is the one-way commit, so doing it only
|
|
|
|
|
// once per variable (with correct DataType/SecurityClass/IsArray) avoids the
|
|
|
|
|
// alternative (register with placeholders + mutate later) which the
|
|
|
|
|
// IAddressSpaceBuilder contract doesn't expose.
|
|
|
|
|
await BrowseRecursiveAsync(session, root, rootFolder, visited,
|
|
|
|
|
depth: 0, discovered: () => discovered, increment: () => discovered++,
|
|
|
|
|
depth: 0,
|
|
|
|
|
discovered: () => discovered, increment: () => discovered++,
|
|
|
|
|
pendingVariables: pendingVariables,
|
|
|
|
|
ct: cancellationToken).ConfigureAwait(false);
|
|
|
|
|
|
|
|
|
|
// Pass 2: batch-read DataType + AccessLevel + ValueRank + Historizing per
|
|
|
|
|
// variable. One wire request for up to ~N variables; for 10k-node servers this is
|
|
|
|
|
// still a couple of hundred ms total since the SDK chunks ReadAsync automatically.
|
|
|
|
|
await EnrichAndRegisterVariablesAsync(session, pendingVariables, cancellationToken)
|
|
|
|
|
.ConfigureAwait(false);
|
|
|
|
|
}
|
|
|
|
|
finally { _gate.Release(); }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// A variable collected during the browse pass, waiting for attribute enrichment
|
|
|
|
|
/// before being registered on the address-space builder.
|
|
|
|
|
/// </summary>
|
|
|
|
|
private readonly record struct PendingVariable(
|
|
|
|
|
IAddressSpaceBuilder ParentFolder,
|
|
|
|
|
string BrowseName,
|
|
|
|
|
string DisplayName,
|
|
|
|
|
NodeId NodeId);
|
|
|
|
|
|
|
|
|
|
private async Task BrowseRecursiveAsync(
|
|
|
|
|
ISession session, NodeId node, IAddressSpaceBuilder folder, HashSet<NodeId> visited,
|
|
|
|
|
int depth, Func<int> discovered, Action increment, CancellationToken ct)
|
|
|
|
|
int depth, Func<int> discovered, Action increment,
|
|
|
|
|
List<PendingVariable> pendingVariables, CancellationToken ct)
|
|
|
|
|
{
|
|
|
|
|
if (depth >= _options.MaxBrowseDepth) return;
|
|
|
|
|
if (discovered() >= _options.MaxDiscoveredNodes) return;
|
|
|
|
|
@@ -650,27 +686,155 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
|
|
|
|
|
var subFolder = folder.Folder(browseName, displayName);
|
|
|
|
|
increment();
|
|
|
|
|
await BrowseRecursiveAsync(session, childId, subFolder, visited,
|
|
|
|
|
depth + 1, discovered, increment, ct).ConfigureAwait(false);
|
|
|
|
|
depth + 1, discovered, increment, pendingVariables, ct).ConfigureAwait(false);
|
|
|
|
|
}
|
|
|
|
|
else if (rf.NodeClass == NodeClass.Variable)
|
|
|
|
|
{
|
|
|
|
|
// Serialize the NodeId so the IReadable/IWritable surface receives a
|
|
|
|
|
// round-trippable string. Deferring the DataType + AccessLevel fetch to a
|
|
|
|
|
// follow-up PR — initial browse uses a conservative ViewOnly + Int32 default.
|
|
|
|
|
var nodeIdString = childId.ToString() ?? string.Empty;
|
|
|
|
|
folder.Variable(browseName, displayName, new DriverAttributeInfo(
|
|
|
|
|
FullName: nodeIdString,
|
|
|
|
|
DriverDataType: DriverDataType.Int32,
|
|
|
|
|
IsArray: false,
|
|
|
|
|
ArrayDim: null,
|
|
|
|
|
SecurityClass: SecurityClassification.ViewOnly,
|
|
|
|
|
IsHistorized: false,
|
|
|
|
|
IsAlarm: false));
|
|
|
|
|
pendingVariables.Add(new PendingVariable(folder, browseName, displayName, childId));
|
|
|
|
|
increment();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Pass 2 of discovery: batch-read DataType + ValueRank + AccessLevel + Historizing
|
|
|
|
|
/// for every collected variable in one Session.ReadAsync (the SDK chunks internally
|
|
|
|
|
/// to respect the server's per-request limits). Then register each variable on its
|
|
|
|
|
/// parent folder with the real <see cref="DriverAttributeInfo"/>.
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// <para>
|
|
|
|
|
/// Attributes read: <c>DataType</c> (NodeId of the value type),
|
|
|
|
|
/// <c>ValueRank</c> (-1 = scalar, 1 = array), <c>UserAccessLevel</c> (the
|
|
|
|
|
/// effective access mask for our session — more accurate than AccessLevel which
|
|
|
|
|
/// is the server-side configured mask before user filtering), and
|
|
|
|
|
/// <c>Historizing</c> (server flags whether historian data is available).
|
|
|
|
|
/// </para>
|
|
|
|
|
/// <para>
|
|
|
|
|
/// When the upstream server returns Bad on any attribute, the variable falls back
|
|
|
|
|
/// to safe defaults (Int32 / ViewOnly / not-array / not-historized) and is still
|
|
|
|
|
/// registered — a partial enrichment failure shouldn't drop entire variables from
|
|
|
|
|
/// the address space. Operators reading the Admin dashboard see the variable
|
|
|
|
|
/// with conservative metadata which is obviously wrong and easy to triage.
|
|
|
|
|
/// </para>
|
|
|
|
|
/// </remarks>
|
|
|
|
|
private async Task EnrichAndRegisterVariablesAsync(
|
|
|
|
|
ISession session, IReadOnlyList<PendingVariable> pending, CancellationToken ct)
|
|
|
|
|
{
|
|
|
|
|
if (pending.Count == 0) return;
|
|
|
|
|
|
|
|
|
|
// 4 attributes per variable: DataType, ValueRank, UserAccessLevel, Historizing.
|
|
|
|
|
var nodesToRead = new ReadValueIdCollection(pending.Count * 4);
|
|
|
|
|
foreach (var pv in pending)
|
|
|
|
|
{
|
|
|
|
|
nodesToRead.Add(new ReadValueId { NodeId = pv.NodeId, AttributeId = Attributes.DataType });
|
|
|
|
|
nodesToRead.Add(new ReadValueId { NodeId = pv.NodeId, AttributeId = Attributes.ValueRank });
|
|
|
|
|
nodesToRead.Add(new ReadValueId { NodeId = pv.NodeId, AttributeId = Attributes.UserAccessLevel });
|
|
|
|
|
nodesToRead.Add(new ReadValueId { NodeId = pv.NodeId, AttributeId = Attributes.Historizing });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
DataValueCollection values;
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
var resp = await session.ReadAsync(
|
|
|
|
|
requestHeader: null,
|
|
|
|
|
maxAge: 0,
|
|
|
|
|
timestampsToReturn: TimestampsToReturn.Neither,
|
|
|
|
|
nodesToRead: nodesToRead,
|
|
|
|
|
ct: ct).ConfigureAwait(false);
|
|
|
|
|
values = resp.Results;
|
|
|
|
|
}
|
|
|
|
|
catch
|
|
|
|
|
{
|
|
|
|
|
// Enrichment-read failed wholesale (server unreachable mid-browse). Register the
|
|
|
|
|
// pending variables with conservative defaults rather than dropping them — the
|
|
|
|
|
// downstream catalog is still useful for reading via IReadable.
|
|
|
|
|
foreach (var pv in pending)
|
|
|
|
|
RegisterFallback(pv);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (var i = 0; i < pending.Count; i++)
|
|
|
|
|
{
|
|
|
|
|
var pv = pending[i];
|
|
|
|
|
var baseIdx = i * 4;
|
|
|
|
|
var dataTypeDv = values[baseIdx];
|
|
|
|
|
var valueRankDv = values[baseIdx + 1];
|
|
|
|
|
var accessDv = values[baseIdx + 2];
|
|
|
|
|
var histDv = values[baseIdx + 3];
|
|
|
|
|
|
|
|
|
|
var dataType = StatusCode.IsGood(dataTypeDv.StatusCode) && dataTypeDv.Value is NodeId dtId
|
|
|
|
|
? MapUpstreamDataType(dtId)
|
|
|
|
|
: DriverDataType.Int32;
|
|
|
|
|
var valueRank = StatusCode.IsGood(valueRankDv.StatusCode) && valueRankDv.Value is int vr ? vr : -1;
|
|
|
|
|
var isArray = valueRank >= 0; // -1 = scalar; 1+ = array dimensions; 0 = one-dimensional array
|
|
|
|
|
var access = StatusCode.IsGood(accessDv.StatusCode) && accessDv.Value is byte ab ? ab : (byte)0;
|
|
|
|
|
var securityClass = MapAccessLevelToSecurityClass(access);
|
|
|
|
|
var historizing = StatusCode.IsGood(histDv.StatusCode) && histDv.Value is bool b && b;
|
|
|
|
|
|
|
|
|
|
pv.ParentFolder.Variable(pv.BrowseName, pv.DisplayName, new DriverAttributeInfo(
|
|
|
|
|
FullName: pv.NodeId.ToString() ?? string.Empty,
|
|
|
|
|
DriverDataType: dataType,
|
|
|
|
|
IsArray: isArray,
|
|
|
|
|
ArrayDim: null,
|
|
|
|
|
SecurityClass: securityClass,
|
|
|
|
|
IsHistorized: historizing,
|
|
|
|
|
IsAlarm: false));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void RegisterFallback(PendingVariable pv)
|
|
|
|
|
{
|
|
|
|
|
pv.ParentFolder.Variable(pv.BrowseName, pv.DisplayName, new DriverAttributeInfo(
|
|
|
|
|
FullName: pv.NodeId.ToString() ?? string.Empty,
|
|
|
|
|
DriverDataType: DriverDataType.Int32,
|
|
|
|
|
IsArray: false,
|
|
|
|
|
ArrayDim: null,
|
|
|
|
|
SecurityClass: SecurityClassification.ViewOnly,
|
|
|
|
|
IsHistorized: false,
|
|
|
|
|
IsAlarm: false));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Map an upstream OPC UA built-in DataType NodeId (via <c>DataTypeIds.*</c>) to a
|
|
|
|
|
/// <see cref="DriverDataType"/>. Unknown / custom types fall through to
|
|
|
|
|
/// <see cref="DriverDataType.String"/> which is the safest passthrough for
|
|
|
|
|
/// Variant-wrapped structs + enums + extension objects; downstream clients see a
|
|
|
|
|
/// string rendering but the cascading-quality path still preserves upstream
|
|
|
|
|
/// StatusCode + timestamps.
|
|
|
|
|
/// </summary>
|
|
|
|
|
internal static DriverDataType MapUpstreamDataType(NodeId dataType)
|
|
|
|
|
{
|
|
|
|
|
if (dataType == DataTypeIds.Boolean) return DriverDataType.Boolean;
|
|
|
|
|
if (dataType == DataTypeIds.SByte || dataType == DataTypeIds.Byte ||
|
|
|
|
|
dataType == DataTypeIds.Int16) return DriverDataType.Int16;
|
|
|
|
|
if (dataType == DataTypeIds.UInt16) return DriverDataType.UInt16;
|
|
|
|
|
if (dataType == DataTypeIds.Int32) return DriverDataType.Int32;
|
|
|
|
|
if (dataType == DataTypeIds.UInt32) return DriverDataType.UInt32;
|
|
|
|
|
if (dataType == DataTypeIds.Int64) return DriverDataType.Int64;
|
|
|
|
|
if (dataType == DataTypeIds.UInt64) return DriverDataType.UInt64;
|
|
|
|
|
if (dataType == DataTypeIds.Float) return DriverDataType.Float32;
|
|
|
|
|
if (dataType == DataTypeIds.Double) return DriverDataType.Float64;
|
|
|
|
|
if (dataType == DataTypeIds.String) return DriverDataType.String;
|
|
|
|
|
if (dataType == DataTypeIds.DateTime || dataType == DataTypeIds.UtcTime)
|
|
|
|
|
return DriverDataType.DateTime;
|
|
|
|
|
return DriverDataType.String;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Map an OPC UA AccessLevel/UserAccessLevel attribute value (<c>AccessLevels</c>
|
|
|
|
|
/// bitmask) to a <see cref="SecurityClassification"/> the local node-manager's ACL
|
|
|
|
|
/// layer can gate writes off. CurrentWrite-capable variables surface as
|
|
|
|
|
/// <see cref="SecurityClassification.Operate"/>; read-only as <see cref="SecurityClassification.ViewOnly"/>.
|
|
|
|
|
/// </summary>
|
|
|
|
|
internal static SecurityClassification MapAccessLevelToSecurityClass(byte accessLevel)
|
|
|
|
|
{
|
|
|
|
|
const byte CurrentWrite = 2; // AccessLevels.CurrentWrite = 0x02
|
|
|
|
|
return (accessLevel & CurrentWrite) != 0
|
|
|
|
|
? SecurityClassification.Operate
|
|
|
|
|
: SecurityClassification.ViewOnly;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- ISubscribable ----
|
|
|
|
|
|
|
|
|
|
public async Task<ISubscriptionHandle> SubscribeAsync(
|
|
|
|
|
@@ -791,6 +955,76 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
|
|
|
|
|
return [new HostConnectivityStatus(HostName, _hostState, _hostStateChangedUtc)];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Session keep-alive handler. On a healthy ping, bumps HostState back to Running
|
|
|
|
|
/// (typical bounce after a transient network blip). On a bad ping, starts the SDK's
|
|
|
|
|
/// <see cref="SessionReconnectHandler"/> which retries on the configured period +
|
|
|
|
|
/// fires <see cref="OnReconnectComplete"/> when it lands a new session.
|
|
|
|
|
/// </summary>
|
|
|
|
|
private void OnKeepAlive(ISession sender, KeepAliveEventArgs e)
|
|
|
|
|
{
|
|
|
|
|
if (!ServiceResult.IsBad(e.Status))
|
|
|
|
|
{
|
|
|
|
|
TransitionTo(HostState.Running);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TransitionTo(HostState.Stopped);
|
|
|
|
|
|
|
|
|
|
// Kick off the SDK's reconnect loop exactly once per drop. The handler handles its
|
|
|
|
|
// own retry cadence via ReconnectPeriod; we tear it down in OnReconnectComplete.
|
|
|
|
|
if (_reconnectHandler is not null) return;
|
|
|
|
|
|
|
|
|
|
_reconnectHandler = new SessionReconnectHandler(telemetry: null!,
|
|
|
|
|
reconnectAbort: false,
|
|
|
|
|
maxReconnectPeriod: (int)TimeSpan.FromMinutes(2).TotalMilliseconds);
|
|
|
|
|
|
|
|
|
|
var state = _reconnectHandler.BeginReconnect(
|
|
|
|
|
sender,
|
|
|
|
|
(int)_options.ReconnectPeriod.TotalMilliseconds,
|
|
|
|
|
OnReconnectComplete);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Called by <see cref="SessionReconnectHandler"/> when its retry loop has either
|
|
|
|
|
/// successfully swapped to a new session or given up. Reads the new session off
|
|
|
|
|
/// <c>handler.Session</c>, unwires the old keep-alive hook, rewires for the new
|
|
|
|
|
/// one, and tears down the handler. Subscription migration is already handled
|
|
|
|
|
/// inside the SDK via <c>TransferSubscriptions</c> (the SDK calls it automatically
|
|
|
|
|
/// when <see cref="Session.TransferSubscriptionsOnReconnect"/> is <c>true</c>,
|
|
|
|
|
/// which is the default).
|
|
|
|
|
/// </summary>
|
|
|
|
|
private void OnReconnectComplete(object? sender, EventArgs e)
|
|
|
|
|
{
|
|
|
|
|
if (sender is not SessionReconnectHandler handler) return;
|
|
|
|
|
var newSession = handler.Session;
|
|
|
|
|
var oldSession = Session;
|
|
|
|
|
|
|
|
|
|
// Rewire keep-alive onto the new session — without this the next drop wouldn't
|
|
|
|
|
// trigger another reconnect attempt.
|
|
|
|
|
if (oldSession is not null && _keepAliveHandler is not null)
|
|
|
|
|
{
|
|
|
|
|
try { oldSession.KeepAlive -= _keepAliveHandler; } catch { }
|
|
|
|
|
}
|
|
|
|
|
if (newSession is not null && _keepAliveHandler is not null)
|
|
|
|
|
{
|
|
|
|
|
newSession.KeepAlive += _keepAliveHandler;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Session = newSession;
|
|
|
|
|
_reconnectHandler?.Dispose();
|
|
|
|
|
_reconnectHandler = null;
|
|
|
|
|
|
|
|
|
|
// Whether the reconnect actually succeeded depends on whether the session is
|
|
|
|
|
// non-null + connected. When it succeeded, flip back to Running so downstream
|
|
|
|
|
// consumers see recovery.
|
|
|
|
|
if (newSession is not null)
|
|
|
|
|
{
|
|
|
|
|
TransitionTo(HostState.Running);
|
|
|
|
|
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void TransitionTo(HostState newState)
|
|
|
|
|
{
|
|
|
|
|
HostState old;
|
|
|
|
|
|