using System.Security.Cryptography.X509Certificates;
using System.Text.RegularExpressions;
using Opc.Ua;
using Opc.Ua.Client;
using Opc.Ua.Configuration;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
///
/// OPC UA Client (gateway) driver. Opens a against a remote OPC UA
/// server and re-exposes its address space through the local OtOpcUa server. PR 66 ships
/// the scaffold: only (connect / close / health). Browse, read,
/// write, subscribe, and probe land in PRs 67-69.
///
///
///
/// Builds its own rather than reusing
/// Client.Shared — Client.Shared is oriented at the interactive CLI; this
/// driver is an always-on service component with different session-lifetime needs
/// (keep-alive monitor, session transfer on reconnect, multi-year uptime).
///
///
/// Session lifetime: a single per driver instance.
/// Subscriptions multiplex onto that session; SDK reconnect handler takes the session
/// down and brings it back up on remote-server restart — the driver must re-send
/// subscriptions + TransferSubscriptions on reconnect to avoid dangling
/// monitored-item handles. That mechanic lands in PR 69.
///
///
public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string driverInstanceId)
: IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IAlarmSource, IHistoryProvider, IMethodInvoker, IDisposable, IAsyncDisposable
{
// ---- IAlarmSource state ----
private readonly System.Collections.Concurrent.ConcurrentDictionary _alarmSubscriptions = new();
private long _nextAlarmSubscriptionId;
public event EventHandler? OnAlarmEvent;
// ---- ISubscribable + IHostConnectivityProbe state ----
private readonly System.Collections.Concurrent.ConcurrentDictionary _subscriptions = new();
private long _nextSubscriptionId;
private readonly object _probeLock = new();
private HostState _hostState = HostState.Unknown;
private DateTime _hostStateChangedUtc = DateTime.UtcNow;
private KeepAliveEventHandler? _keepAliveHandler;
public event EventHandler? OnDataChange;
public event EventHandler? OnHostStatusChanged;
// OPC UA StatusCode constants the driver surfaces for local-side faults. Upstream-server
// StatusCodes are passed through verbatim per driver-specs.md §8 "cascading quality" —
// downstream clients need to distinguish 'remote source down' from 'local driver failure'.
private const uint StatusBadNodeIdInvalid = 0x80330000u;
private const uint StatusBadInternalError = 0x80020000u;
private const uint StatusBadCommunicationError = 0x80050000u;
private readonly OpcUaClientDriverOptions _options = options;
private readonly SemaphoreSlim _gate = new(1, 1);
///
/// Per-driver diagnostic counters (publish/notification rates, missing-publish,
/// dropped-notification, session-reset). Surfaced through
/// for the driver-diagnostics RPC.
/// Hot-path increments use ; the read path snapshots.
///
private readonly OpcUaClientDiagnostics _diagnostics = new();
/// Test seam — exposes the live counters for unit tests.
internal OpcUaClientDiagnostics DiagnosticsForTest => _diagnostics;
/// Wired to in ; cached so we can unwire in + on reconnect.
private NotificationEventHandler? _notificationHandler;
/// Wired to ; cached so we can unwire on reconnect/shutdown.
private PublishErrorEventHandler? _publishErrorHandler;
///
/// Subscription that watches the upstream Server node (i=2253) for
/// BaseModelChangeEventType / GeneralModelChangeEventType notifications.
/// Created at the end of when
/// is true; null
/// when the watch is disabled or before init runs.
///
private Subscription? _modelChangeSubscription;
///
/// Debounce timer for upstream model-change events. Created lazily on first event
/// arrival; reset (Change) on every subsequent event so a burst of N events triggers
/// exactly one ReinitializeAsync after the last event in the window.
///
private Timer? _modelChangeDebounceTimer;
///
/// Cached driver-config JSON snapshot from the most recent .
/// The debounce timer fire path passes this back into
/// so the re-import uses the same options the operator originally configured.
///
private string? _lastConfigJson;
///
/// Test seam — count of debounced re-import invocations the driver has fired. Lets
/// unit tests assert the coalescing window without spying on .
///
private long _modelChangeReimportCount;
internal long ModelChangeReimportCountForTest => Interlocked.Read(ref _modelChangeReimportCount);
///
/// Test seam — fired before the actual re-import call so unit tests can assert "the
/// driver decided to re-import N times" without standing up a full Initialize loop.
/// When non-null, the handler runs instead of calling .
///
internal Func? ModelChangeReimportHookForTest { get; set; }
///
/// Test seam — drive a synthetic model-change event into the debounce path. Mirrors
/// what the SDK's MonitoredItem.Notification wire-up does on a real
/// BaseModelChangeEventType arrival.
///
internal void InjectModelChangeForTest() => OnModelChangeNotification();
/// Active OPC UA session. Null until returns cleanly.
internal ISession? Session { get; private set; }
/// Per-connection gate. PRs 67+ serialize read/write/browse on this.
internal SemaphoreSlim Gate => _gate;
private DriverHealth _health = new(DriverState.Unknown, null, null);
private bool _disposed;
/// URL of the endpoint the driver actually connected to. Exposed via .
private string? _connectedEndpointUrl;
///
/// Reverse-connect listener acquired during when
/// is set. Null when reverse-connect is
/// disabled. Released back to the singleton pool on shutdown so multiple driver
/// instances on the same listener URL can come and go independently.
///
private ReverseConnectListener? _reverseListener;
///
/// Test seam — pluggable reverse-connect "wait" hook. When non-null,
/// uses this delegate instead of
/// calling into a real . Lets unit tests
/// inject a synthetic ITransportWaitingConnection without binding a port
/// or running the SDK's listener threads.
///
internal Func>? ReverseConnectWaitHookForTest { get; set; }
///
/// Test seam — pluggable session factory invoked in the reverse-connect path.
/// Tests can use this to verify that the session-create call receives the
/// expected ITransportWaitingConnection without instantiating the SDK
/// (which hits real cert + transport code).
///
internal Func>? ReverseConnectSessionFactoryForTest { get; set; }
/// Test seam — last reverse-connect listener acquired (null when reverse-connect is disabled or shut down).
internal ReverseConnectListener? ReverseListenerForTest => _reverseListener;
///
/// 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.
///
private SessionReconnectHandler? _reconnectHandler;
///
/// Cached server-advertised OperationLimits, fetched lazily on first batch op and
/// refreshed on reconnect. Null until the first successful fetch; null components
/// mean "fetch hasn't completed yet, fall through to single-call". Per spec, a 0
/// limit means "no limit" — we surface that as uint?=null too so the
/// chunking helper has a single sentinel for "don't chunk".
///
private OperationLimitsCache? _operationLimits;
private readonly SemaphoreSlim _operationLimitsLock = new(1, 1);
///
/// Snapshot of the four OperationLimits the driver chunks against. Stored as
/// uint? so callers can distinguish "not yet fetched" / "no limit"
/// (null) from "limit = N" (Some(N)). Spec sentinel 0 is normalized to null at
/// fetch time so the chunking helper has a single "don't chunk" sentinel.
///
internal sealed record OperationLimitsCache(
uint? MaxNodesPerRead,
uint? MaxNodesPerWrite,
uint? MaxNodesPerBrowse,
uint? MaxNodesPerHistoryReadData);
/// Test seam — exposes the cached limits so unit tests can assert fetch behaviour.
internal OperationLimitsCache? OperationLimitsForTest => _operationLimits;
public string DriverInstanceId => driverInstanceId;
public string DriverType => "OpcUaClient";
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
_health = new DriverHealth(DriverState.Initializing, null, null);
// Snapshot the config JSON so the model-change debounce path can hand it back to
// ReinitializeAsync without callers needing to re-pass it. Capture before the failover
// sweep so a partial-init failure still has the JSON available for the next attempt.
_lastConfigJson = driverConfigJson;
try
{
var appConfig = await BuildApplicationConfigurationAsync(cancellationToken).ConfigureAwait(false);
// When DiscoveryUrl is set, run FindServers + GetEndpoints first and merge the
// discovered URLs into the candidate list before the failover sweep. Discovery
// failures are non-fatal: log + fall through to the statically configured
// candidates so a transient LDS outage doesn't block init.
IReadOnlyList discovered = [];
if (!string.IsNullOrWhiteSpace(_options.DiscoveryUrl))
{
try
{
discovered = await DiscoverEndpointsAsync(
appConfig, _options.DiscoveryUrl!, _options.SecurityPolicy, _options.SecurityMode,
cancellationToken).ConfigureAwait(false);
}
catch (Exception)
{
// Swallow + continue with static candidates; the failover sweep error
// (if all static candidates also fail) will surface the situation.
discovered = [];
}
}
var candidates = ResolveEndpointCandidates(_options, discovered);
var identity = BuildUserIdentity(_options);
ISession? session = null;
string? connectedUrl = null;
if (_options.ReverseConnect.Enabled)
{
// Reverse-connect path: instead of dialling each candidate URL, we register
// our listener URL with the process-wide ReverseConnectManager and wait for
// the upstream server to dial in. The first candidate URL still drives
// EndpointDescription selection so SecurityPolicy/Mode + user-identity flow
// through the same code path as the conventional dial — only the transport
// direction flips. ExpectedServerUri filters incoming connections so the
// listener can be shared across drivers targeting different upstreams.
if (string.IsNullOrWhiteSpace(_options.ReverseConnect.ListenerUrl))
throw new InvalidOperationException(
"ReverseConnect.Enabled=true but ReverseConnect.ListenerUrl is not set. " +
"Configure a listener URL like 'opc.tcp://0.0.0.0:4844' so the upstream server can dial in.");
var endpointForReverse = candidates.FirstOrDefault()
?? throw new InvalidOperationException(
"ReverseConnect requires at least one EndpointUrl in the candidate list to derive the EndpointDescription from.");
session = await OpenReverseConnectSessionAsync(
appConfig, endpointForReverse, identity, cancellationToken).ConfigureAwait(false);
connectedUrl = endpointForReverse;
}
else
{
// Failover sweep: try each endpoint in order, return the session from the first
// one that successfully connects. Per-endpoint failures are captured so the final
// aggregate exception names every URL that was tried and why — critical diag for
// operators debugging 'why did the failover pick #3?'.
var attemptErrors = new List(candidates.Count);
foreach (var url in candidates)
{
try
{
session = await OpenSessionOnEndpointAsync(
appConfig, url, _options.SecurityPolicy, _options.SecurityMode,
identity, cancellationToken).ConfigureAwait(false);
connectedUrl = url;
break;
}
catch (Exception ex)
{
attemptErrors.Add($"{url} -> {ex.GetType().Name}: {ex.Message}");
}
}
if (session is null)
throw new AggregateException(
"OPC UA Client failed to connect to any of the configured endpoints. " +
"Tried:\n " + string.Join("\n ", attemptErrors),
attemptErrors.Select(e => new InvalidOperationException(e)));
}
// 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;
WireSessionDiagnostics(session);
Session = session;
_connectedEndpointUrl = connectedUrl;
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
TransitionTo(HostState.Running);
// Watch the upstream Server node for ModelChangeEvent notifications. Best-effort
// — if the upstream doesn't expose the event types or rejects the EventFilter the
// driver still functions for the existing capability surface. Init shouldn't fail
// because the operator's upstream doesn't advertise topology change events.
if (_options.WatchModelChanges)
{
try
{
await SubscribeModelChangesAsync(session, cancellationToken).ConfigureAwait(false);
}
catch
{
// best-effort — silently degrade to no-watch; operators see this through
// the absence of re-import on topology change rather than a hard init fail.
}
}
}
catch (Exception ex)
{
try { if (Session is Session s) await s.CloseAsync().ConfigureAwait(false); } catch { }
Session = null;
// Release the reverse-connect listener if we acquired it but session-create failed
// — leaks a port-bind otherwise, blocking the next init attempt.
if (_reverseListener is not null)
{
try { _reverseListener.Release(); } catch { /* best-effort */ }
_reverseListener = null;
}
_health = new DriverHealth(DriverState.Faulted, null, ex.Message);
throw;
}
}
///
/// Build a minimal in-memory . Certificates live
/// under the OS user profile — on Windows that's %LocalAppData%\OtOpcUa\pki
/// — so multiple driver instances in the same OtOpcUa server process share one
/// certificate store without extra config.
///
private async Task BuildApplicationConfigurationAsync(CancellationToken ct)
{
// The default ctor is obsolete in favour of the ITelemetryContext overload; suppress
// locally rather than plumbing a telemetry context all the way through the driver
// surface — the driver emits no per-request telemetry of its own and the SDK's
// internal fallback is fine for a gateway use case.
#pragma warning disable CS0618
var app = new ApplicationInstance
{
ApplicationName = _options.SessionName,
ApplicationType = ApplicationType.Client,
};
#pragma warning restore CS0618
var pkiRoot = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"OtOpcUa", "pki");
var config = new ApplicationConfiguration
{
ApplicationName = _options.SessionName,
ApplicationType = ApplicationType.Client,
ApplicationUri = _options.ApplicationUri,
SecurityConfiguration = new SecurityConfiguration
{
ApplicationCertificate = new CertificateIdentifier
{
StoreType = CertificateStoreType.Directory,
StorePath = Path.Combine(pkiRoot, "own"),
SubjectName = $"CN={_options.SessionName}",
},
TrustedPeerCertificates = new CertificateTrustList
{
StoreType = CertificateStoreType.Directory,
StorePath = Path.Combine(pkiRoot, "trusted"),
},
TrustedIssuerCertificates = new CertificateTrustList
{
StoreType = CertificateStoreType.Directory,
StorePath = Path.Combine(pkiRoot, "issuers"),
},
RejectedCertificateStore = new CertificateTrustList
{
StoreType = CertificateStoreType.Directory,
StorePath = Path.Combine(pkiRoot, "rejected"),
},
AutoAcceptUntrustedCertificates = _options.AutoAcceptCertificates,
},
TransportQuotas = new TransportQuotas { OperationTimeout = (int)_options.Timeout.TotalMilliseconds },
ClientConfiguration = new ClientConfiguration
{
DefaultSessionTimeout = (int)_options.SessionTimeout.TotalMilliseconds,
},
DisableHiResClock = true,
};
await config.ValidateAsync(ApplicationType.Client, ct).ConfigureAwait(false);
// Attach a cert-validator handler. The SDK's AutoAcceptUntrustedCertificates flag
// alone isn't always enough in newer SDK versions — the validator raises an event
// the app has to handle. We also use this hook to enforce the
// CertificateValidation policy (revoked, SHA-1, key size) regardless of AutoAccept.
config.CertificateValidator.CertificateValidation += OnCertificateValidation;
// Ensure an application certificate exists. The SDK auto-generates one if missing.
app.ApplicationConfiguration = config;
await app.CheckApplicationInstanceCertificatesAsync(silent: true, lifeTimeInMonths: null, ct)
.ConfigureAwait(false);
return config;
}
///
/// Cert-validator callback. Funnels into
/// for testability — the static helper takes the cert + status code + options and
/// returns the decision, which this method then applies to the SDK's event args.
///
private void OnCertificateValidation(object sender, Opc.Ua.CertificateValidationEventArgs e)
{
var decision = EvaluateCertificateValidation(
e.Certificate,
e.Error.StatusCode,
_options.AutoAcceptCertificates,
_options.CertificateValidation);
if (decision.LogMessage is { Length: > 0 })
{
// Use the SDK's trace surface — no driver-side ILogger is plumbed today, and the
// SDK trace is already wired up by the host. Warning level for rejections so
// operators surface them without code changes. The non-telemetry overload is
// marked obsolete in the latest SDK; suppress locally to keep the gateway-driver
// surface free of an ITelemetryContext plumb-through (parity with the same
// pattern in BuildApplicationConfigurationAsync).
#pragma warning disable CS0618
Opc.Ua.Utils.LogWarning(
"OpcUaClient[{0}] cert-validation: {1} (subject={2}, status=0x{3:X8})",
driverInstanceId, decision.LogMessage,
e.Certificate?.Subject ?? "",
(uint)e.Error.StatusCode.Code);
#pragma warning restore CS0618
}
e.Accept = decision.Accept;
}
///
/// Cert-validation decision pipeline. Pulled out as a static helper so unit tests can
/// drive each branch without standing up an OPC UA SDK CertificateValidator.
/// Order matters: revoked > SHA-1 > key-size > revocation-unknown > auto-accept-untrusted.
///
/// Server certificate the SDK is asking us to validate. May be null in pathological cases.
/// The SDK's validation result. Good = no failure to inspect.
/// Mirror of .
/// The cert-validation knobs.
internal static CertificateValidationDecision EvaluateCertificateValidation(
System.Security.Cryptography.X509Certificates.X509Certificate2? cert,
Opc.Ua.StatusCode status,
bool autoAcceptUntrusted,
OpcUaCertificateValidationOptions opts)
{
// Revoked certs are always a hard fail — never auto-accept regardless of flags.
if (status.Code == Opc.Ua.StatusCodes.BadCertificateRevoked)
return new CertificateValidationDecision(false, "REVOKED server certificate — rejecting");
if (status.Code == Opc.Ua.StatusCodes.BadCertificateIssuerRevoked)
return new CertificateValidationDecision(false, "REVOKED issuer certificate — rejecting");
// SHA-1 signature detection runs even when the SDK didn't surface a status —
// we want to reject SHA-1 certs on policy, not just when the SDK happens to flag them.
if (opts.RejectSHA1SignedCertificates && IsSha1Signed(cert))
return new CertificateValidationDecision(false, "SHA-1 signed certificate rejected by policy");
// Key-size check: only meaningful for RSA keys; ECC bypasses.
if (cert is not null && TryGetRsaKeySize(cert, out var keyBits) && keyBits < opts.MinimumCertificateKeySize)
return new CertificateValidationDecision(false,
$"RSA key size {keyBits} bits below minimum {opts.MinimumCertificateKeySize}");
// Unknown revocation status — reject only if policy says so.
if (status.Code == Opc.Ua.StatusCodes.BadCertificateRevocationUnknown
|| status.Code == Opc.Ua.StatusCodes.BadCertificateIssuerRevocationUnknown)
{
if (opts.RejectUnknownRevocationStatus)
return new CertificateValidationDecision(false, "revocation status unknown (no/stale CRL) — rejecting per policy");
return new CertificateValidationDecision(true, "revocation status unknown (no/stale CRL) — accepting per policy");
}
// Untrusted: SDK couldn't chain the cert to a trusted issuer. Honour AutoAccept.
if (status.Code == Opc.Ua.StatusCodes.BadCertificateUntrusted)
{
if (autoAcceptUntrusted) return new CertificateValidationDecision(true, null);
return new CertificateValidationDecision(false, "untrusted certificate — rejecting (AutoAcceptCertificates=false)");
}
// Anything else is an SDK-level failure — let the SDK's default disposition stand
// (don't accept by default; surface the status code in the log).
if (status.Code != Opc.Ua.StatusCodes.Good)
return new CertificateValidationDecision(false, $"validation failed (status=0x{(uint)status.Code:X8})");
return new CertificateValidationDecision(true, null);
}
///
/// True when the cert's signature algorithm OID matches a SHA-1 RSA signature
/// (1.2.840.113549.1.1.5) or a SHA-1 ECDSA signature (1.2.840.10045.4.1).
/// Friendly-name prefix match is unreliable across .NET runtimes, so we use OIDs.
///
internal static bool IsSha1Signed(System.Security.Cryptography.X509Certificates.X509Certificate2? cert)
{
if (cert is null) return false;
var oid = cert.SignatureAlgorithm?.Value;
return oid is "1.2.840.113549.1.1.5" // sha1RSA
or "1.2.840.10045.4.1"; // sha1ECDSA
}
///
/// Read the RSA public key size in bits if the cert has an RSA key. Returns false for
/// non-RSA (ECC, DSA) certs so the key-size check is skipped on them.
///
internal static bool TryGetRsaKeySize(
System.Security.Cryptography.X509Certificates.X509Certificate2 cert,
out int keyBits)
{
using var rsa = cert.GetRSAPublicKey();
if (rsa is null) { keyBits = 0; return false; }
keyBits = rsa.KeySize;
return true;
}
///
/// Outcome of .
/// is null when the decision is silently "accept (Good)" — no need to log healthy
/// validations.
///
internal readonly record struct CertificateValidationDecision(bool Accept, string? LogMessage);
///
/// Resolve the ordered failover candidate list. EndpointUrls wins when
/// non-empty; otherwise fall back to EndpointUrl as a single-URL shortcut so
/// existing single-endpoint configs keep working without migration.
///
internal static IReadOnlyList ResolveEndpointCandidates(OpcUaClientDriverOptions opts) =>
ResolveEndpointCandidates(opts, []);
///
/// Resolve the ordered failover candidate list with optional discovery results.
/// Discovered URLs are prepended to the static candidate list so a discovery
/// sweep gets first-attempt priority over hand-rolled fallbacks. When the static
/// list is empty (no AND only
/// the default ), the discovered
/// URLs replace the static candidate entirely so a pure-discovery deployment doesn't
/// need a hard-coded fallback URL. Duplicates are removed (case-insensitive on the
/// URL string) so a discovered URL that also appears in EndpointUrls isn't
/// attempted twice in a row.
///
internal static IReadOnlyList ResolveEndpointCandidates(
OpcUaClientDriverOptions opts,
IReadOnlyList discovered)
{
var staticList = opts.EndpointUrls is { Count: > 0 }
? (IReadOnlyList)opts.EndpointUrls
: [opts.EndpointUrl];
if (discovered.Count == 0) return staticList;
// Discovered first; merge static after with case-insensitive de-dup so a single
// server that appears in both lists doesn't cause two consecutive identical attempts.
var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
var merged = new List(discovered.Count + staticList.Count);
foreach (var u in discovered)
if (!string.IsNullOrWhiteSpace(u) && seen.Add(u))
merged.Add(u);
foreach (var u in staticList)
if (!string.IsNullOrWhiteSpace(u) && seen.Add(u))
merged.Add(u);
return merged;
}
///
/// Run OPC UA discovery against : FindServers
/// enumerates every server registered with the LDS (or just the one server when
/// points at a server directly), then
/// GetEndpoints on each server's discovery URL pulls its full endpoint list.
/// Endpoints are filtered to those matching the requested policy + mode before being
/// returned.
///
///
/// SecurityMode=None on the discovery channel is mandated by the OPC UA spec —
/// discovery is unauthenticated even when the steady-state session uses Sign or
/// SignAndEncrypt. DiscoveryClient.CreateAsync opens an unsecured channel by
/// default; we don't override that here.
///
internal static async Task> DiscoverEndpointsAsync(
ApplicationConfiguration appConfig,
string discoveryUrl,
OpcUaSecurityPolicy policy,
OpcUaSecurityMode mode,
CancellationToken ct)
{
var wantedPolicyUri = MapSecurityPolicy(policy);
var wantedMode = mode switch
{
OpcUaSecurityMode.None => MessageSecurityMode.None,
OpcUaSecurityMode.Sign => MessageSecurityMode.Sign,
OpcUaSecurityMode.SignAndEncrypt => MessageSecurityMode.SignAndEncrypt,
_ => throw new ArgumentOutOfRangeException(nameof(mode)),
};
var results = new List();
var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
// FindServers against the LDS / server discovery endpoint. Returned ApplicationDescriptions
// each carry one or more DiscoveryUrls (typically one per network interface).
ApplicationDescriptionCollection servers;
using (var lds = await DiscoveryClient.CreateAsync(
appConfig, new Uri(discoveryUrl), DiagnosticsMasks.None, ct).ConfigureAwait(false))
{
servers = await lds.FindServersAsync(null, ct).ConfigureAwait(false);
}
foreach (var server in servers)
{
if (server.DiscoveryUrls is null) continue;
foreach (var serverDiscoveryUrl in server.DiscoveryUrls)
{
if (string.IsNullOrWhiteSpace(serverDiscoveryUrl)) continue;
EndpointDescriptionCollection endpoints;
try
{
using var ep = await DiscoveryClient.CreateAsync(
appConfig, new Uri(serverDiscoveryUrl), DiagnosticsMasks.None, ct).ConfigureAwait(false);
endpoints = await ep.GetEndpointsAsync(null, ct).ConfigureAwait(false);
}
catch
{
// One unreachable server in the LDS list shouldn't blow up the whole
// sweep — skip it and keep going.
continue;
}
foreach (var e in endpoints)
{
if (e.SecurityPolicyUri != wantedPolicyUri) continue;
if (e.SecurityMode != wantedMode) continue;
if (string.IsNullOrWhiteSpace(e.EndpointUrl)) continue;
if (seen.Add(e.EndpointUrl)) results.Add(e.EndpointUrl);
}
}
}
return results;
}
///
/// Build the user-identity token from the driver options. Split out of
/// so the failover sweep reuses one identity across
/// every endpoint attempt — generating it N times would re-unlock the user cert's
/// private key N times, wasteful + keeps the password in memory longer.
///
internal static UserIdentity BuildUserIdentity(OpcUaClientDriverOptions options) =>
options.AuthType switch
{
OpcUaAuthType.Anonymous => new UserIdentity(new AnonymousIdentityToken()),
OpcUaAuthType.Username => new UserIdentity(
options.Username ?? string.Empty,
System.Text.Encoding.UTF8.GetBytes(options.Password ?? string.Empty)),
OpcUaAuthType.Certificate => BuildCertificateIdentity(options),
_ => new UserIdentity(new AnonymousIdentityToken()),
};
///
/// Open a session against a single endpoint URL. Bounded by
/// so the failover
/// sweep doesn't spend its full budget on one dead server. Moved out of
/// so the failover loop body stays readable.
///
private async Task OpenSessionOnEndpointAsync(
ApplicationConfiguration appConfig,
string endpointUrl,
OpcUaSecurityPolicy policy,
OpcUaSecurityMode mode,
UserIdentity identity,
CancellationToken ct)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(_options.PerEndpointConnectTimeout);
var selected = await SelectMatchingEndpointAsync(
appConfig, endpointUrl, policy, mode, cts.Token).ConfigureAwait(false);
var endpointConfig = EndpointConfiguration.Create(appConfig);
endpointConfig.OperationTimeout = (int)_options.Timeout.TotalMilliseconds;
var endpoint = new ConfiguredEndpoint(null, selected, endpointConfig);
var session = await new DefaultSessionFactory(telemetry: null!).CreateAsync(
appConfig,
endpoint,
false, // updateBeforeConnect
_options.SessionName,
(uint)_options.SessionTimeout.TotalMilliseconds,
identity,
null, // preferredLocales
cts.Token).ConfigureAwait(false);
session.KeepAliveInterval = (int)_options.KeepAliveInterval.TotalMilliseconds;
return session;
}
///
/// Open a session over a server-initiated (reverse) connect. Acquires a process-wide
/// for the configured listener URL, waits for the
/// upstream server to dial in (filtered by ),
/// then hands the resulting into the
/// session-create path. The endpoint description still comes from the candidate URL so
/// SecurityPolicy / Mode / cert handling are identical to the dial path — only the
/// transport direction flips.
///
private async Task OpenReverseConnectSessionAsync(
ApplicationConfiguration appConfig,
string endpointUrl,
UserIdentity identity,
CancellationToken ct)
{
var listenerUrl = _options.ReverseConnect.ListenerUrl!;
var expectedServerUri = _options.ReverseConnect.ExpectedServerUri;
// Acquire a ref to the singleton listener for this URL. Multiple driver instances
// sharing a URL share one underlying SDK manager — see ReverseConnectListener for
// the ref-count model.
if (ReverseConnectWaitHookForTest is null)
{
_reverseListener = ReverseConnectListener.Acquire(listenerUrl, appConfig);
}
// Build the ConfiguredEndpoint from the configured endpointUrl. We DON'T call
// GetEndpointsAsync over reverse connect here — the SDK's reverse-connect overload
// accepts a synthetic EndpointDescription and the upstream resends its real one
// during ReverseHello, so a static description is fine for the create call.
var endpointDescription = new EndpointDescription(endpointUrl)
{
SecurityPolicyUri = MapSecurityPolicy(_options.SecurityPolicy),
SecurityMode = _options.SecurityMode switch
{
OpcUaSecurityMode.None => MessageSecurityMode.None,
OpcUaSecurityMode.Sign => MessageSecurityMode.Sign,
OpcUaSecurityMode.SignAndEncrypt => MessageSecurityMode.SignAndEncrypt,
_ => MessageSecurityMode.None,
},
};
var endpointConfig = EndpointConfiguration.Create(appConfig);
endpointConfig.OperationTimeout = (int)_options.Timeout.TotalMilliseconds;
var endpoint = new ConfiguredEndpoint(null, endpointDescription, endpointConfig);
// Wait for the upstream to dial in. Bounded by Timeout so a stuck listener doesn't
// hang init forever — operators see a clear timeout error rather than a silent stall.
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(_options.Timeout);
Opc.Ua.ITransportWaitingConnection connection;
if (ReverseConnectWaitHookForTest is not null)
{
connection = await ReverseConnectWaitHookForTest(
new Uri(listenerUrl), expectedServerUri, cts.Token).ConfigureAwait(false);
}
else
{
connection = await _reverseListener!.WaitForServerAsync(
new Uri(listenerUrl), expectedServerUri, cts.Token).ConfigureAwait(false);
}
// Hand the inbound connection into the session-create path. The factory hook lets
// unit tests assert that the right connection + endpoint flow through without
// standing up a real DefaultSessionFactory (which expects a fully-wired transport).
ISession session;
if (ReverseConnectSessionFactoryForTest is not null)
{
session = await ReverseConnectSessionFactoryForTest(
appConfig, connection, endpoint, identity, cts.Token).ConfigureAwait(false);
}
else
{
session = await new DefaultSessionFactory(telemetry: null!).CreateAsync(
appConfig,
connection,
endpoint,
updateBeforeConnect: false,
checkDomain: false,
_options.SessionName,
(uint)_options.SessionTimeout.TotalMilliseconds,
identity,
preferredLocales: null,
cts.Token).ConfigureAwait(false);
}
session.KeepAliveInterval = (int)_options.KeepAliveInterval.TotalMilliseconds;
return session;
}
///
/// Select the remote endpoint matching both the requested
/// and . The SDK's CoreClientUtils.SelectEndpointAsync
/// only honours a boolean "use security" flag; we need policy-aware matching so an
/// operator asking for Basic256Sha256 against a server that also offers
/// Basic128Rsa15 doesn't silently end up on the weaker cipher.
///
private static async Task SelectMatchingEndpointAsync(
ApplicationConfiguration appConfig,
string endpointUrl,
OpcUaSecurityPolicy policy,
OpcUaSecurityMode mode,
CancellationToken ct)
{
// GetEndpoints returns everything the server advertises; policy + mode filter is
// applied client-side so the selection is explicit and fails loudly if the operator
// asks for a combination the server doesn't publish. DiscoveryClient.CreateAsync
// is the non-obsolete path in SDK 1.5.378; the synchronous Create(..) variants are
// all deprecated.
using var client = await DiscoveryClient.CreateAsync(
appConfig, new Uri(endpointUrl), Opc.Ua.DiagnosticsMasks.None, ct).ConfigureAwait(false);
var all = await client.GetEndpointsAsync(null, ct).ConfigureAwait(false);
var wantedPolicyUri = MapSecurityPolicy(policy);
var wantedMode = mode switch
{
OpcUaSecurityMode.None => MessageSecurityMode.None,
OpcUaSecurityMode.Sign => MessageSecurityMode.Sign,
OpcUaSecurityMode.SignAndEncrypt => MessageSecurityMode.SignAndEncrypt,
_ => throw new ArgumentOutOfRangeException(nameof(mode)),
};
var match = all.FirstOrDefault(e =>
e.SecurityPolicyUri == wantedPolicyUri && e.SecurityMode == wantedMode);
if (match is null)
{
var advertised = string.Join(", ", all
.Select(e => $"{ShortPolicyName(e.SecurityPolicyUri)}/{e.SecurityMode}"));
throw new InvalidOperationException(
$"No endpoint at '{endpointUrl}' matches SecurityPolicy={policy} + SecurityMode={mode}. " +
$"Server advertises: {advertised}");
}
return match;
}
///
/// Build a carrying a client user-authentication
/// certificate loaded from .
/// Used when the remote server's endpoint advertises Certificate-type user tokens.
/// Fails fast if the path is missing, the file doesn't exist, or the certificate
/// lacks a private key (the private key is required to sign the user-token
/// challenge during session activation).
///
internal static UserIdentity BuildCertificateIdentity(OpcUaClientDriverOptions options)
{
if (string.IsNullOrWhiteSpace(options.UserCertificatePath))
throw new InvalidOperationException(
"OpcUaAuthType.Certificate requires OpcUaClientDriverOptions.UserCertificatePath to be set.");
if (!System.IO.File.Exists(options.UserCertificatePath))
throw new System.IO.FileNotFoundException(
$"User certificate not found at '{options.UserCertificatePath}'.",
options.UserCertificatePath);
// X509CertificateLoader (new in .NET 9) is the only non-obsolete way to load a PFX
// since the legacy X509Certificate2 ctors are marked obsolete on net10. Passes the
// password through verbatim; PEM files with external keys fall back to
// LoadCertificateFromFile which picks up the adjacent .key if present.
var cert = System.Security.Cryptography.X509Certificates.X509CertificateLoader
.LoadPkcs12FromFile(options.UserCertificatePath, options.UserCertificatePassword);
if (!cert.HasPrivateKey)
throw new InvalidOperationException(
$"User certificate at '{options.UserCertificatePath}' has no private key — " +
"the private key is required to sign the OPC UA user-token challenge at session activation.");
return new UserIdentity(cert);
}
/// Convert a driver to the OPC UA policy URI.
internal static string MapSecurityPolicy(OpcUaSecurityPolicy policy) => policy switch
{
OpcUaSecurityPolicy.None => SecurityPolicies.None,
OpcUaSecurityPolicy.Basic128Rsa15 => SecurityPolicies.Basic128Rsa15,
OpcUaSecurityPolicy.Basic256 => SecurityPolicies.Basic256,
OpcUaSecurityPolicy.Basic256Sha256 => SecurityPolicies.Basic256Sha256,
OpcUaSecurityPolicy.Aes128_Sha256_RsaOaep => SecurityPolicies.Aes128_Sha256_RsaOaep,
OpcUaSecurityPolicy.Aes256_Sha256_RsaPss => SecurityPolicies.Aes256_Sha256_RsaPss,
_ => throw new ArgumentOutOfRangeException(nameof(policy), policy, null),
};
private static string ShortPolicyName(string policyUri) =>
policyUri?.Substring(policyUri.LastIndexOf('#') + 1) ?? "(null)";
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
await ShutdownAsync(cancellationToken).ConfigureAwait(false);
await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
}
public async Task ShutdownAsync(CancellationToken cancellationToken)
{
// Tear down remote subscriptions first — otherwise Session.Close will try and may fail
// with BadSubscriptionIdInvalid noise in the upstream log. _subscriptions is cleared
// whether or not the wire-side delete succeeds since the local handles are useless
// after close anyway.
foreach (var rs in _subscriptions.Values)
{
try { await rs.Subscription.DeleteAsync(silent: true, cancellationToken).ConfigureAwait(false); }
catch { /* best-effort */ }
}
_subscriptions.Clear();
foreach (var ras in _alarmSubscriptions.Values)
{
try { await ras.Subscription.DeleteAsync(silent: true, cancellationToken).ConfigureAwait(false); }
catch { /* best-effort */ }
}
_alarmSubscriptions.Clear();
// Tear down the model-change subscription + dispose the debounce timer. A pending
// debounce fire that races with shutdown is harmless — the timer callback null-checks
// the session before doing any work, and ReinitializeAsync re-acquires _gate which
// serializes with the caller of ShutdownAsync.
if (_modelChangeSubscription is not null)
{
try { await _modelChangeSubscription.DeleteAsync(silent: true, cancellationToken).ConfigureAwait(false); }
catch { /* best-effort */ }
_modelChangeSubscription = null;
}
try { _modelChangeDebounceTimer?.Dispose(); } catch { }
_modelChangeDebounceTimer = null;
// 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 { }
}
_keepAliveHandler = null;
UnwireSessionDiagnostics(Session);
try { if (Session is Session s) await s.CloseAsync(cancellationToken).ConfigureAwait(false); }
catch { /* best-effort */ }
try { Session?.Dispose(); } catch { }
Session = null;
_connectedEndpointUrl = null;
_operationLimits = null;
// Release our hold on the reverse-connect listener. Last release tears the manager
// down; siblings that share the URL keep it alive. Idempotent — releasing a null
// listener (e.g. shutdown after a failed init) is a no-op.
if (_reverseListener is not null)
{
try { _reverseListener.Release(); } catch { /* best-effort */ }
_reverseListener = null;
}
TransitionTo(HostState.Unknown);
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
}
public DriverHealth GetHealth()
{
// Snapshot the counters into the optional Diagnostics dictionary on every poll —
// the RPC reads through GetHealth so we can't lazy-cache without a tick source.
// The snapshot is O(7) so the per-poll cost is negligible compared to the RPC plumbing.
var h = _health;
return new DriverHealth(h.State, h.LastSuccessfulRead, h.LastError, _diagnostics.Snapshot());
}
public long GetMemoryFootprint() => 0;
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
// ---- IReadable ----
public async Task> ReadAsync(
IReadOnlyList fullReferences, CancellationToken cancellationToken)
{
var session = RequireSession();
await EnsureOperationLimitsFetchedAsync(cancellationToken).ConfigureAwait(false);
var results = new DataValueSnapshot[fullReferences.Count];
var now = DateTime.UtcNow;
// Parse NodeIds up-front. Tags whose reference doesn't parse get BadNodeIdInvalid
// and are omitted from the wire request — saves a round-trip against the upstream
// server for a fault the driver can detect locally.
var toSend = new ReadValueIdCollection();
var indexMap = new List(fullReferences.Count); // maps wire-index -> results-index
for (var i = 0; i < fullReferences.Count; i++)
{
if (!TryParseNodeId(session, fullReferences[i], out var nodeId))
{
results[i] = new DataValueSnapshot(null, StatusBadNodeIdInvalid, null, now);
continue;
}
toSend.Add(new ReadValueId { NodeId = nodeId, AttributeId = Attributes.Value });
indexMap.Add(i);
}
if (toSend.Count == 0) return results;
// Honor server's MaxNodesPerRead — chunk large batches so a single ReadAsync stays
// under the cap. cap=null means "no limit" (sentinel for both 0-from-server and
// not-yet-fetched), in which case ChunkBy yields the input as a single slice and
// the wire path collapses to one SDK call.
var readCap = _operationLimits?.MaxNodesPerRead;
var indexMapList = indexMap; // close over for catch
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
try
{
var wireOffset = 0;
foreach (var chunk in ChunkBy(toSend, readCap))
{
var chunkColl = new ReadValueIdCollection(chunk.Count);
for (var i = 0; i < chunk.Count; i++) chunkColl.Add(chunk.Array![chunk.Offset + i]);
var resp = await session.ReadAsync(
requestHeader: null,
maxAge: 0,
timestampsToReturn: TimestampsToReturn.Both,
nodesToRead: chunkColl,
ct: cancellationToken).ConfigureAwait(false);
var values = resp.Results;
for (var w = 0; w < values.Count; w++)
{
var r = indexMapList[wireOffset + w];
var dv = values[w];
// Preserve the upstream StatusCode verbatim — including Bad codes per
// §8's cascading-quality rule. Also preserve SourceTimestamp so downstream
// clients can detect stale upstream data.
results[r] = new DataValueSnapshot(
Value: dv.Value,
StatusCode: dv.StatusCode.Code,
SourceTimestampUtc: dv.SourceTimestamp == DateTime.MinValue ? null : dv.SourceTimestamp,
ServerTimestampUtc: dv.ServerTimestamp == DateTime.MinValue ? now : dv.ServerTimestamp);
}
wireOffset += chunk.Count;
}
_health = new DriverHealth(DriverState.Healthy, now, null);
}
catch (Exception ex)
{
// Transport / timeout / session-dropped — fan out the same fault across every
// tag in this batch. Per-tag StatusCode stays BadCommunicationError (not
// BadInternalError) so operators distinguish "upstream unreachable" from
// "driver bug".
for (var w = 0; w < indexMapList.Count; w++)
{
var r = indexMapList[w];
results[r] = new DataValueSnapshot(null, StatusBadCommunicationError, null, now);
}
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
}
}
finally { _gate.Release(); }
return results;
}
// ---- IWritable ----
public async Task> WriteAsync(
IReadOnlyList writes, CancellationToken cancellationToken)
{
var session = RequireSession();
await EnsureOperationLimitsFetchedAsync(cancellationToken).ConfigureAwait(false);
var results = new WriteResult[writes.Count];
var toSend = new WriteValueCollection();
var indexMap = new List(writes.Count);
for (var i = 0; i < writes.Count; i++)
{
if (!TryParseNodeId(session, writes[i].FullReference, out var nodeId))
{
results[i] = new WriteResult(StatusBadNodeIdInvalid);
continue;
}
toSend.Add(new WriteValue
{
NodeId = nodeId,
AttributeId = Attributes.Value,
Value = new DataValue(new Variant(writes[i].Value)),
});
indexMap.Add(i);
}
if (toSend.Count == 0) return results;
// Honor server's MaxNodesPerWrite — same chunking pattern as ReadAsync. cap=null
// collapses to a single wire call.
var writeCap = _operationLimits?.MaxNodesPerWrite;
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
try
{
var wireOffset = 0;
foreach (var chunk in ChunkBy(toSend, writeCap))
{
var chunkColl = new WriteValueCollection(chunk.Count);
for (var i = 0; i < chunk.Count; i++) chunkColl.Add(chunk.Array![chunk.Offset + i]);
var resp = await session.WriteAsync(
requestHeader: null,
nodesToWrite: chunkColl,
ct: cancellationToken).ConfigureAwait(false);
var codes = resp.Results;
for (var w = 0; w < codes.Count; w++)
{
var r = indexMap[wireOffset + w];
// Pass upstream WriteResult StatusCode through verbatim. Success codes
// include Good (0) and any warning-level Good* variants; anything with
// the severity bits set is a Bad.
results[r] = new WriteResult(codes[w].Code);
}
wireOffset += chunk.Count;
}
}
catch (Exception)
{
for (var w = 0; w < indexMap.Count; w++)
results[indexMap[w]] = new WriteResult(StatusBadCommunicationError);
}
}
finally { _gate.Release(); }
return results;
}
///
/// Parse a tag's full-reference string as a NodeId. Accepts the standard OPC UA
/// serialized forms (ns=2;s=…, i=2253, ns=4;g=…, ns=3;b=…).
/// Empty + malformed strings return false; the driver surfaces that as
/// without a wire round-trip.
///
internal static bool TryParseNodeId(ISession session, string fullReference, out NodeId nodeId)
{
nodeId = NodeId.Null;
if (string.IsNullOrWhiteSpace(fullReference)) return false;
try
{
nodeId = NodeId.Parse(session.MessageContext, fullReference);
return !NodeId.IsNull(nodeId);
}
catch
{
return false;
}
}
private ISession RequireSession() =>
Session ?? throw new InvalidOperationException("OpcUaClientDriver not initialized");
///
/// Lazily fetch Server.ServerCapabilities.OperationLimits from the upstream
/// server and cache them on the driver. Idempotent — called from every batch op,
/// no-ops once a successful fetch has populated the cache. The cache is cleared on
/// reconnect (see ) so a server with redrawn
/// capabilities doesn't run forever with stale caps.
///
///
/// Uses when the
/// active session is a concrete (always true in production —
/// the SDK's session factory returns Session). Falls back gracefully on any fetch
/// failure: callers see remain null and fall through
/// to single-call behaviour. Per OPC UA Part 5, a server reporting 0 for any
/// OperationLimits attribute means "no limit"; we normalize that to null so
/// the chunking helper has a single sentinel.
///
private async Task EnsureOperationLimitsFetchedAsync(CancellationToken ct)
{
if (_operationLimits is not null) return;
await _operationLimitsLock.WaitAsync(ct).ConfigureAwait(false);
try
{
if (_operationLimits is not null) return;
if (Session is not Session concrete) return;
try
{
await concrete.FetchOperationLimitsAsync(ct).ConfigureAwait(false);
var ol = concrete.OperationLimits;
if (ol is null) return;
_operationLimits = new OperationLimitsCache(
MaxNodesPerRead: NormalizeLimit(ol.MaxNodesPerRead),
MaxNodesPerWrite: NormalizeLimit(ol.MaxNodesPerWrite),
MaxNodesPerBrowse: NormalizeLimit(ol.MaxNodesPerBrowse),
MaxNodesPerHistoryReadData: NormalizeLimit(ol.MaxNodesPerHistoryReadData));
}
catch
{
// Fetch failed — leave cache null so we re-attempt on the next batch op.
// Single-call behaviour applies in the meantime; never block traffic on a
// capability discovery glitch.
}
}
finally { _operationLimitsLock.Release(); }
}
/// Spec sentinel: 0 = "no limit". Normalize to null for the chunking helper.
private static uint? NormalizeLimit(uint raw) => raw == 0 ? null : raw;
///
/// Split into contiguous slices of at most
/// items. Returns the input as a single slice when the cap is null (no limit),
/// 0, or larger than the input — the spec sentinel + the no-cap path collapse onto
/// the same single-call branch so the wire path stays a single SDK invocation when
/// the server doesn't impose a limit.
///
internal static IEnumerable> ChunkBy(IReadOnlyList source, uint? cap)
{
if (source.Count == 0) yield break;
var array = source as T[] ?? source.ToArray();
if (cap is null or 0 || (uint)array.Length <= cap.Value)
{
yield return new ArraySegment(array, 0, array.Length);
yield break;
}
var size = checked((int)cap.Value);
for (var offset = 0; offset < array.Length; offset += size)
{
var len = Math.Min(size, array.Length - offset);
yield return new ArraySegment(array, offset, len);
}
}
// ---- ITagDiscovery ----
public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(builder);
var session = RequireSession();
var root = !string.IsNullOrEmpty(_options.BrowseRoot)
? NodeId.Parse(session.MessageContext, _options.BrowseRoot)
: ObjectIds.ObjectsFolder;
var rootName = string.IsNullOrWhiteSpace(_options.Curation.RootAlias)
? "Remote"
: _options.Curation.RootAlias!;
var rootFolder = builder.Folder(rootName, rootName);
var visited = new HashSet();
var discovered = 0;
var pendingVariables = new List();
// Compile curation globs once per Discover so the recursion's hot path is a regex
// match rather than a per-segment string-walk. Empty include = include all.
var includeRegex = CompileGlobs(_options.Curation.IncludePaths);
var excludeRegex = CompileGlobs(_options.Curation.ExcludePaths);
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,
pathPrefix: string.Empty,
includeRegex: includeRegex,
excludeRegex: excludeRegex,
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);
// Pass 3 (opt-in): mirror upstream type definitions under the four standard type
// sub-folders (ObjectTypes / VariableTypes / DataTypes / ReferenceTypes). Off by
// default so existing deployments don't suddenly see a flood of type nodes; enable
// via OpcUaClientDriverOptions.MirrorTypeDefinitions when downstream clients need
// the upstream type system to render structured values or decode custom events.
if (_options.MirrorTypeDefinitions)
{
await MirrorTypeDefinitionsAsync(session, builder, includeRegex, excludeRegex,
cancellationToken).ConfigureAwait(false);
}
}
finally { _gate.Release(); }
}
///
/// Pass 3 of discovery: walk the upstream TypesFolder (i=86) and project
/// the four standard type sub-folders into the local address space via
/// . Honours the same curation rules
/// as pass-1 — paths are slash-joined under each type-folder root (e.g.
/// "ObjectTypes/BaseObjectType/MyType").
///
///
///
/// Uses Session.FetchTypeTreeAsync on each of the four root type nodes so the
/// SDK's TypeTree cache is populated in one batched call per root rather than
/// per-node round trips during the recursion. This PR ships the structural mirror
/// only — binary-encoding priming via LoadDataTypeSystem is tracked as a
/// follow-up because the public SDK surface for that helper was removed in
/// OPCFoundation.NetStandard 1.5.378+.
///
///
/// RegisterTypeNode has a default no-op implementation on the interface so
/// most builders (Galaxy, Modbus, FOCAS, S7, TwinCAT, AB-CIP) ignore the projection
/// entirely — only the OPC UA server-side DriverNodeManager needs to override
/// it for the client driver's mirror pass to surface in the OPC UA server's address
/// space.
///
///
private async Task MirrorTypeDefinitionsAsync(
ISession session, IAddressSpaceBuilder builder,
Regex? includeRegex, Regex? excludeRegex, CancellationToken ct)
{
// FetchTypeTreeAsync populates the SDK-side TypeTree cache rooted at the four standard
// type folders. This isn't free (it's a hierarchical browse) but it's the canonical way
// to prime the cache so subsequent NodeCache.FetchNode calls hit memory rather than the
// wire on every type. Failures are caught + logged-via-health-surface — the structural
// mirror still proceeds with an empty cache.
try
{
var typeRoots = new ExpandedNodeIdCollection
{
new ExpandedNodeId(ObjectIds.ObjectTypesFolder),
new ExpandedNodeId(ObjectIds.VariableTypesFolder),
new ExpandedNodeId(ObjectIds.DataTypesFolder),
new ExpandedNodeId(ObjectIds.ReferenceTypesFolder),
};
await session.FetchTypeTreeAsync(typeRoots, ct).ConfigureAwait(false);
}
catch
{
// Non-fatal — the structural mirror still works without a primed TypeTree cache;
// we just don't get the in-memory super-type chain shortcuts.
}
// Note: this PR ships the structural mirror only. A previous SDK version exposed
// ISession.LoadDataTypeSystem(NodeId, CancellationToken) for priming structured-type
// encodings; that method was removed from the public surface in OPCFoundation.NetStandard
// 1.5.378+. Loading the binary type system now requires per-node walks of the encoding
// dictionaries via NodeCache helpers, which is significant additional scope. Tracked as
// a follow-up; existing deployments that need structured-type decoding can mirror the
// raw type tree today and consume Variant on the client side.
await MirrorTypeBranchAsync(session, builder, ObjectIds.ObjectTypesFolder,
MirroredTypeKind.ObjectType, "ObjectTypes", includeRegex, excludeRegex, ct)
.ConfigureAwait(false);
await MirrorTypeBranchAsync(session, builder, ObjectIds.VariableTypesFolder,
MirroredTypeKind.VariableType, "VariableTypes", includeRegex, excludeRegex, ct)
.ConfigureAwait(false);
await MirrorTypeBranchAsync(session, builder, ObjectIds.DataTypesFolder,
MirroredTypeKind.DataType, "DataTypes", includeRegex, excludeRegex, ct)
.ConfigureAwait(false);
await MirrorTypeBranchAsync(session, builder, ObjectIds.ReferenceTypesFolder,
MirroredTypeKind.ReferenceType, "ReferenceTypes", includeRegex, excludeRegex, ct)
.ConfigureAwait(false);
}
///
/// Recursive walk of a single type-folder branch (ObjectType / VariableType / DataType /
/// ReferenceType). Uses HasSubtype reference walking (the canonical OPC UA way to
/// enumerate type hierarchies) — IncludeSubtypes=false so the recursion controls depth
/// itself rather than the server bulk-returning the full subtree at the root.
///
private async Task MirrorTypeBranchAsync(
ISession session, IAddressSpaceBuilder builder,
NodeId rootNode, MirroredTypeKind kind, string rootSegmentName,
Regex? includeRegex, Regex? excludeRegex, CancellationToken ct)
{
var visited = new HashSet();
var discovered = 0;
await WalkTypeNodeAsync(session, builder, rootNode, kind, rootSegmentName,
superTypeNodeId: null, depth: 0, visited, includeRegex, excludeRegex,
() => discovered, () => discovered++, ct).ConfigureAwait(false);
}
private async Task WalkTypeNodeAsync(
ISession session, IAddressSpaceBuilder builder,
NodeId node, MirroredTypeKind kind, string pathPrefix,
string? superTypeNodeId, int depth,
HashSet visited, Regex? includeRegex, Regex? excludeRegex,
Func discovered, Action increment, CancellationToken ct)
{
if (depth >= _options.MaxBrowseDepth) return;
if (discovered() >= _options.MaxDiscoveredNodes) return;
if (!visited.Add(node)) return;
// Browse subtypes only (HasSubtype): for an Object/Variable/ReferenceType the children
// we care about are subtypes. We don't need to enumerate property nodes / instance
// children of types at this layer — RegisterTypeNode is purely for the type identity.
var browseDescriptions = new BrowseDescriptionCollection
{
new()
{
NodeId = node,
BrowseDirection = BrowseDirection.Forward,
ReferenceTypeId = ReferenceTypeIds.HasSubtype,
IncludeSubtypes = false,
NodeClassMask = (uint)(NodeClass.ObjectType | NodeClass.VariableType
| NodeClass.DataType | NodeClass.ReferenceType),
ResultMask = (uint)(BrowseResultMask.BrowseName | BrowseResultMask.DisplayName
| BrowseResultMask.NodeClass),
}
};
BrowseResponse resp;
try
{
resp = await session.BrowseAsync(
requestHeader: null,
view: null,
requestedMaxReferencesPerNode: 0,
nodesToBrowse: browseDescriptions,
ct: ct).ConfigureAwait(false);
}
catch
{
// Transient browse failure — skip this branch, keep the rest of the mirror going.
return;
}
if (resp.Results.Count == 0) return;
var refs = resp.Results[0].References;
foreach (var rf in refs)
{
if (discovered() >= _options.MaxDiscoveredNodes) break;
var childId = ExpandedNodeId.ToNodeId(rf.NodeId, session.NamespaceUris);
if (NodeId.IsNull(childId)) continue;
var browseName = rf.BrowseName?.Name ?? childId.ToString();
var displayName = rf.DisplayName?.Text ?? browseName;
var childPath = pathPrefix + "/" + browseName;
// Curation rules apply to the type walk too — operators with very tight servers
// can scope the type mirror via "ObjectTypes/MyVendor/*" globs.
if (!ShouldInclude(childPath, includeRegex, excludeRegex))
continue;
// Read IsAbstract for the type. Treated as best-effort — if the upstream returns
// Bad we default to false so the mirror still ships rather than dropping the node.
var isAbstract = await TryReadIsAbstractAsync(session, childId, ct)
.ConfigureAwait(false);
var upstreamId = BuildRemappedFullName(childId, session.NamespaceUris,
_options.Curation.NamespaceRemap);
var parentId = BuildRemappedFullName(node, session.NamespaceUris,
_options.Curation.NamespaceRemap);
builder.RegisterTypeNode(new MirroredTypeNodeInfo(
Kind: kind,
UpstreamNodeId: upstreamId,
BrowseName: browseName,
DisplayName: displayName,
SuperTypeNodeId: superTypeNodeId is null ? null : parentId,
IsAbstract: isAbstract));
increment();
// Recurse — depth+1 because each level of HasSubtype is real depth in the type tree.
// Pass childId-as-supertype string so descendants can record their super-type chain.
await WalkTypeNodeAsync(session, builder, childId, kind, childPath,
superTypeNodeId: upstreamId, depth + 1, visited,
includeRegex, excludeRegex, discovered, increment, ct)
.ConfigureAwait(false);
}
}
///
/// Best-effort read of the IsAbstract attribute for a type node. Falls back to
/// false on any read failure so a single bad upstream attribute doesn't drop the
/// entire type from the mirror.
///
private static async Task TryReadIsAbstractAsync(
ISession session, NodeId node, CancellationToken ct)
{
try
{
var nodesToRead = new ReadValueIdCollection
{
new ReadValueId { NodeId = node, AttributeId = Attributes.IsAbstract },
};
var resp = await session.ReadAsync(
requestHeader: null,
maxAge: 0,
timestampsToReturn: TimestampsToReturn.Neither,
nodesToRead: nodesToRead,
ct: ct).ConfigureAwait(false);
if (resp.Results.Count > 0
&& StatusCode.IsGood(resp.Results[0].StatusCode)
&& resp.Results[0].Value is bool b)
{
return b;
}
return false;
}
catch
{
return false;
}
}
///
/// Translate the curation glob list into a single regex that matches if any pattern
/// matches. Returns null for null/empty input so the call site can short-circuit
/// without allocating.
///
///
/// Glob semantics — see remarks. Only
/// * (any sequence) and ? (single char) are honoured; every other
/// character is regex-escaped.
///
internal static Regex? CompileGlobs(IReadOnlyList? patterns)
{
if (patterns is null || patterns.Count == 0) return null;
var alternatives = new List(patterns.Count);
foreach (var p in patterns)
{
if (string.IsNullOrEmpty(p)) continue;
alternatives.Add(GlobToRegex(p));
}
if (alternatives.Count == 0) return null;
var combined = "^(?:" + string.Join("|", alternatives) + ")$";
return new Regex(combined, RegexOptions.Compiled | RegexOptions.CultureInvariant);
}
private static string GlobToRegex(string glob)
{
var sb = new System.Text.StringBuilder(glob.Length * 2);
foreach (var ch in glob)
{
switch (ch)
{
case '*': sb.Append(".*"); break;
case '?': sb.Append('.'); break;
default: sb.Append(Regex.Escape(ch.ToString())); break;
}
}
return sb.ToString();
}
///
/// Apply the configured curation rules to a candidate BrowsePath. Returns
/// true when the node should be included. Empty include = include all;
/// exclude wins over include.
///
internal static bool ShouldInclude(string path, Regex? include, Regex? exclude)
{
if (exclude is not null && exclude.IsMatch(path)) return false;
if (include is null) return true;
return include.IsMatch(path);
}
///
/// A variable collected during the browse pass, waiting for attribute enrichment
/// before being registered on the address-space builder.
///
private readonly record struct PendingVariable(
IAddressSpaceBuilder ParentFolder,
string BrowseName,
string DisplayName,
NodeId NodeId,
string FullName);
private async Task BrowseRecursiveAsync(
ISession session, NodeId node, IAddressSpaceBuilder folder, HashSet visited,
int depth, string pathPrefix, Regex? includeRegex, Regex? excludeRegex,
Func discovered, Action increment,
List pendingVariables, CancellationToken ct)
{
if (depth >= _options.MaxBrowseDepth) return;
if (discovered() >= _options.MaxDiscoveredNodes) return;
if (!visited.Add(node)) return;
var browseDescriptions = new BrowseDescriptionCollection
{
new()
{
NodeId = node,
BrowseDirection = BrowseDirection.Forward,
ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
IncludeSubtypes = true,
NodeClassMask = (uint)(NodeClass.Object | NodeClass.Variable | NodeClass.Method),
ResultMask = (uint)(BrowseResultMask.BrowseName | BrowseResultMask.DisplayName
| BrowseResultMask.NodeClass | BrowseResultMask.TypeDefinition),
}
};
BrowseResponse resp;
try
{
resp = await session.BrowseAsync(
requestHeader: null,
view: null,
requestedMaxReferencesPerNode: 0,
nodesToBrowse: browseDescriptions,
ct: ct).ConfigureAwait(false);
}
catch
{
// Transient browse failure on a sub-tree — don't kill the whole discovery, just
// skip this branch. The driver's health surface will reflect the cascade via the
// probe loop (PR 69).
return;
}
if (resp.Results.Count == 0) return;
var refs = resp.Results[0].References;
foreach (var rf in refs)
{
if (discovered() >= _options.MaxDiscoveredNodes) break;
var childId = ExpandedNodeId.ToNodeId(rf.NodeId, session.NamespaceUris);
if (NodeId.IsNull(childId)) continue;
var browseName = rf.BrowseName?.Name ?? childId.ToString();
var displayName = rf.DisplayName?.Text ?? browseName;
var childPath = string.IsNullOrEmpty(pathPrefix) ? browseName : pathPrefix + "/" + browseName;
// Apply curation: exclude wins over include; empty include = include all.
// Folders pruned here aren't browsed, so descendants don't reach the wire — keeps
// the cost down on large servers.
if (!ShouldInclude(childPath, includeRegex, excludeRegex))
continue;
if (rf.NodeClass == NodeClass.Object)
{
var subFolder = folder.Folder(browseName, displayName);
increment();
await BrowseRecursiveAsync(session, childId, subFolder, visited,
depth + 1, childPath, includeRegex, excludeRegex,
discovered, increment, pendingVariables, ct).ConfigureAwait(false);
}
else if (rf.NodeClass == NodeClass.Variable)
{
var fullName = BuildRemappedFullName(childId, session.NamespaceUris,
_options.Curation.NamespaceRemap);
pendingVariables.Add(new PendingVariable(folder, browseName, displayName, childId, fullName));
increment();
}
else if (rf.NodeClass == NodeClass.Method)
{
// Methods hang off Objects (the parent of this browse step). Walk HasProperty
// to harvest InputArguments / OutputArguments — both are standard properties
// on Method nodes — then project to the address-space builder. Best-effort:
// arguments that fail to read fall through to null so the method still
// registers (the dispatcher returns BadArgumentsMissing if a client tries
// to invoke it without the argument schema).
var (inputArgs, outputArgs) = await ReadMethodArgumentsAsync(session, childId, ct)
.ConfigureAwait(false);
var methodId = BuildRemappedFullName(childId, session.NamespaceUris,
_options.Curation.NamespaceRemap);
var ownerId = BuildRemappedFullName(node, session.NamespaceUris,
_options.Curation.NamespaceRemap);
folder.RegisterMethodNode(new MirroredMethodNodeInfo(
BrowseName: browseName,
DisplayName: displayName,
ObjectNodeId: ownerId,
MethodNodeId: methodId,
InputArguments: inputArgs,
OutputArguments: outputArgs));
increment();
}
}
}
///
/// Read a method node's InputArguments and OutputArguments properties.
/// Both are standard HasProperty children of any NodeClass.Method node
/// in OPC UA — they carry the array-of-Argument structure the dispatcher needs to
/// surface a callable signature on the local method node.
///
///
/// A tuple of (InputArguments, OutputArguments). Either side may be null when
/// the method has no arguments of that kind (the property simply isn't present on the
/// upstream method) or when the read failed — both paths are non-fatal.
///
private static async Task<(IReadOnlyList?, IReadOnlyList?)>
ReadMethodArgumentsAsync(ISession session, NodeId methodNodeId, CancellationToken ct)
{
// Browse the method's HasProperty children to find the InputArguments /
// OutputArguments property NodeIds. Standard browse-name-based lookup would also
// work but the property NodeIds aren't stable across servers, so we walk the
// references — the SDK gives us BrowseName + NodeId in the same response.
var browseDescriptions = new BrowseDescriptionCollection
{
new()
{
NodeId = methodNodeId,
BrowseDirection = BrowseDirection.Forward,
ReferenceTypeId = ReferenceTypeIds.HasProperty,
IncludeSubtypes = true,
NodeClassMask = (uint)NodeClass.Variable,
ResultMask = (uint)(BrowseResultMask.BrowseName | BrowseResultMask.NodeClass),
}
};
NodeId? inputPropId = null;
NodeId? outputPropId = null;
try
{
var resp = await session.BrowseAsync(
requestHeader: null,
view: null,
requestedMaxReferencesPerNode: 0,
nodesToBrowse: browseDescriptions,
ct: ct).ConfigureAwait(false);
if (resp.Results.Count == 0) return (null, null);
foreach (var rf in resp.Results[0].References)
{
var name = rf.BrowseName?.Name;
if (string.IsNullOrEmpty(name)) continue;
var propId = ExpandedNodeId.ToNodeId(rf.NodeId, session.NamespaceUris);
if (NodeId.IsNull(propId)) continue;
if (string.Equals(name, BrowseNames.InputArguments, StringComparison.Ordinal))
inputPropId = propId;
else if (string.Equals(name, BrowseNames.OutputArguments, StringComparison.Ordinal))
outputPropId = propId;
}
}
catch
{
return (null, null);
}
if (inputPropId is null && outputPropId is null) return (null, null);
var nodesToRead = new ReadValueIdCollection();
if (inputPropId is not null)
nodesToRead.Add(new ReadValueId { NodeId = inputPropId, AttributeId = Attributes.Value });
if (outputPropId is not null)
nodesToRead.Add(new ReadValueId { NodeId = outputPropId, AttributeId = Attributes.Value });
DataValueCollection values;
try
{
var readResp = await session.ReadAsync(
requestHeader: null,
maxAge: 0,
timestampsToReturn: TimestampsToReturn.Neither,
nodesToRead: nodesToRead,
ct: ct).ConfigureAwait(false);
values = readResp.Results;
}
catch
{
return (null, null);
}
var idx = 0;
IReadOnlyList? inputArgs = null;
IReadOnlyList? outputArgs = null;
if (inputPropId is not null)
{
inputArgs = ConvertArguments(values[idx++]);
}
if (outputPropId is not null)
{
outputArgs = ConvertArguments(values[idx]);
}
return (inputArgs, outputArgs);
}
///
/// Convert an OPC UA InputArguments/OutputArguments property value
/// (an array of Argument wrapped in ExtensionObject) into the local
/// DTO. Returns null when the value can't be
/// decoded — non-fatal, the method still registers without arg metadata.
///
private static IReadOnlyList? ConvertArguments(DataValue dv)
{
if (StatusCode.IsBad(dv.StatusCode)) return null;
if (dv.Value is not ExtensionObject[] extensionArray) return null;
var result = new List(extensionArray.Length);
foreach (var ext in extensionArray)
{
if (ext?.Body is not Argument arg) continue;
result.Add(new MethodArgumentInfo(
Name: arg.Name ?? string.Empty,
DriverDataType: MapUpstreamDataType(arg.DataType),
ValueRank: arg.ValueRank,
Description: arg.Description?.Text));
}
return result;
}
///
/// Render a NodeId as the canonical nsu=<uri>;… string, applying the
/// configured upstream→local namespace-URI remap. Index-namespace nodes (ns=0,
/// standard OPC UA nodes) bypass remap and use the legacy index-form so the
/// base-namespace round-trips unchanged. When remap is null/empty the result is the
/// SDK's default NodeId.ToString().
///
internal static string BuildRemappedFullName(NodeId nodeId, NamespaceTable? table,
IReadOnlyDictionary? remap)
{
if (nodeId is null) return string.Empty;
var defaultName = nodeId.ToString() ?? string.Empty;
if (remap is null || remap.Count == 0) return defaultName;
if (nodeId.NamespaceIndex == 0 || table is null) return defaultName;
var upstreamUri = table.GetString(nodeId.NamespaceIndex);
if (string.IsNullOrEmpty(upstreamUri)) return defaultName;
if (!remap.TryGetValue(upstreamUri, out var localUri) || string.IsNullOrEmpty(localUri))
return defaultName;
// ExpandedNodeId.Format with an explicit URI emits "nsu=;=" form.
var expanded = new ExpandedNodeId(nodeId.Identifier, 0, localUri, 0);
return expanded.ToString() ?? defaultName;
}
///
/// 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 .
///
///
///
/// Attributes read: DataType (NodeId of the value type),
/// ValueRank (-1 = scalar, 1 = array), UserAccessLevel (the
/// effective access mask for our session — more accurate than AccessLevel which
/// is the server-side configured mask before user filtering), and
/// Historizing (server flags whether historian data is available).
///
///
/// 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.
///
///
private async Task EnrichAndRegisterVariablesAsync(
ISession session, IReadOnlyList 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.FullName,
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.FullName,
DriverDataType: DriverDataType.Int32,
IsArray: false,
ArrayDim: null,
SecurityClass: SecurityClassification.ViewOnly,
IsHistorized: false,
IsAlarm: false));
}
}
///
/// Map an upstream OPC UA built-in DataType NodeId (via DataTypeIds.*) to a
/// . Unknown / custom types fall through to
/// 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.
///
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;
}
///
/// Map an OPC UA AccessLevel/UserAccessLevel attribute value (AccessLevels
/// bitmask) to a the local node-manager's ACL
/// layer can gate writes off. CurrentWrite-capable variables surface as
/// ; read-only as .
///
internal static SecurityClassification MapAccessLevelToSecurityClass(byte accessLevel)
{
const byte CurrentWrite = 2; // AccessLevels.CurrentWrite = 0x02
return (accessLevel & CurrentWrite) != 0
? SecurityClassification.Operate
: SecurityClassification.ViewOnly;
}
// ---- ISubscribable ----
public Task SubscribeAsync(
IReadOnlyList fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
{
// Route the simple-string overload through the per-tag overload with all knobs at
// their defaults. Single code path for subscription create — keeps the wire-side
// identical for callers that don't need per-tag tuning.
var specs = new MonitoredTagSpec[fullReferences.Count];
for (var i = 0; i < fullReferences.Count; i++)
specs[i] = new MonitoredTagSpec(fullReferences[i]);
return SubscribeAsync(specs, publishingInterval, cancellationToken);
}
public async Task SubscribeAsync(
IReadOnlyList tags, TimeSpan publishingInterval, CancellationToken cancellationToken)
{
var session = RequireSession();
var id = Interlocked.Increment(ref _nextSubscriptionId);
var handle = new OpcUaSubscriptionHandle(id);
// Floor the publishing interval — OPC UA servers routinely negotiate
// minimum-supported intervals up anyway, but sending sub-floor values wastes
// negotiation bandwidth on every subscription create. Floor is configurable via
// OpcUaSubscriptionDefaults.MinPublishingIntervalMs (default 50ms).
var subDefaults = _options.Subscriptions;
var intervalMs = publishingInterval < TimeSpan.FromMilliseconds(subDefaults.MinPublishingIntervalMs)
? subDefaults.MinPublishingIntervalMs
: (int)publishingInterval.TotalMilliseconds;
var subscription = new Subscription(telemetry: null!, new SubscriptionOptions
{
DisplayName = $"opcua-sub-{id}",
PublishingInterval = intervalMs,
KeepAliveCount = (uint)subDefaults.KeepAliveCount,
LifetimeCount = subDefaults.LifetimeCount,
MaxNotificationsPerPublish = subDefaults.MaxNotificationsPerPublish,
PublishingEnabled = true,
Priority = subDefaults.Priority,
TimestampsToReturn = TimestampsToReturn.Both,
});
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
session.AddSubscription(subscription);
await subscription.CreateAsync(cancellationToken).ConfigureAwait(false);
foreach (var spec in tags)
{
if (!TryParseNodeId(session, spec.TagName, out var nodeId)) continue;
var monItem = BuildMonitoredItem(spec, nodeId, intervalMs);
monItem.Notification += (mi, args) => OnMonitoredItemNotification(handle, mi, args);
subscription.AddItem(monItem);
}
try
{
await subscription.CreateItemsAsync(cancellationToken).ConfigureAwait(false);
}
catch (Opc.Ua.ServiceResultException sre)
{
// PercentDeadband requires the server to expose EURange on the variable; if
// it isn't set the server returns BadFilterNotAllowed during item creation.
// We swallow the exception here so other items in the batch still get created
// — per-item failure surfaces through MonitoredItem.Status.Error rather than
// tearing down the whole subscription.
if (sre.StatusCode != StatusCodes.BadFilterNotAllowed) throw;
}
_subscriptions[id] = new RemoteSubscription(subscription, handle);
}
finally { _gate.Release(); }
return handle;
}
///
/// Map a to a SDK with the
/// per-tag knobs applied. Defaults match the original hard-coded values
/// (Reporting / SamplingInterval=publishInterval / QueueSize=1 / DiscardOldest=true)
/// so a spec with all knobs null behaves identically to the legacy path.
///
internal static MonitoredItem BuildMonitoredItem(MonitoredTagSpec spec, NodeId nodeId, int defaultIntervalMs)
{
var sampling = spec.SamplingIntervalMs.HasValue ? (int)spec.SamplingIntervalMs.Value : defaultIntervalMs;
var queueSize = spec.QueueSize ?? 1u;
var discardOldest = spec.DiscardOldest ?? true;
var monitoringMode = spec.MonitoringMode is { } mm ? MapMonitoringMode(mm) : MonitoringMode.Reporting;
var filter = BuildDataChangeFilter(spec.DataChangeFilter);
var options = new MonitoredItemOptions
{
DisplayName = spec.TagName,
StartNodeId = nodeId,
AttributeId = Attributes.Value,
MonitoringMode = monitoringMode,
SamplingInterval = sampling,
QueueSize = queueSize,
DiscardOldest = discardOldest,
Filter = filter,
};
return new MonitoredItem(telemetry: null!, options)
{
// The tag string is routed through MonitoredItem.Handle so the Notification
// handler can identify which tag changed without an extra lookup.
Handle = spec.TagName,
};
}
///
/// Build the OPC UA from a ,
/// or return null if the caller didn't supply a filter. PercentDeadband requires
/// server-side EURange — if the server rejects with BadFilterNotAllowed, the caller's
/// SubscribeAsync swallows it so other items in the batch still get created.
///
internal static DataChangeFilter? BuildDataChangeFilter(DataChangeFilterSpec? spec)
{
if (spec is null) return null;
return new DataChangeFilter
{
Trigger = MapTrigger(spec.Trigger),
DeadbandType = (uint)MapDeadbandType(spec.DeadbandType),
DeadbandValue = spec.DeadbandValue,
};
}
/// Map our SDK-free to the OPC UA SDK's enum.
internal static MonitoringMode MapMonitoringMode(SubscriptionMonitoringMode mode) => mode switch
{
SubscriptionMonitoringMode.Disabled => MonitoringMode.Disabled,
SubscriptionMonitoringMode.Sampling => MonitoringMode.Sampling,
SubscriptionMonitoringMode.Reporting => MonitoringMode.Reporting,
_ => MonitoringMode.Reporting,
};
/// Map our to the SDK enum.
internal static Opc.Ua.DataChangeTrigger MapTrigger(Core.Abstractions.DataChangeTrigger trigger) => trigger switch
{
Core.Abstractions.DataChangeTrigger.Status => Opc.Ua.DataChangeTrigger.Status,
Core.Abstractions.DataChangeTrigger.StatusValue => Opc.Ua.DataChangeTrigger.StatusValue,
Core.Abstractions.DataChangeTrigger.StatusValueTimestamp => Opc.Ua.DataChangeTrigger.StatusValueTimestamp,
_ => Opc.Ua.DataChangeTrigger.StatusValue,
};
/// Map our to the SDK enum.
internal static Opc.Ua.DeadbandType MapDeadbandType(Core.Abstractions.DeadbandType type) => type switch
{
Core.Abstractions.DeadbandType.None => Opc.Ua.DeadbandType.None,
Core.Abstractions.DeadbandType.Absolute => Opc.Ua.DeadbandType.Absolute,
Core.Abstractions.DeadbandType.Percent => Opc.Ua.DeadbandType.Percent,
_ => Opc.Ua.DeadbandType.None,
};
public async Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
{
if (handle is not OpcUaSubscriptionHandle h) return;
if (!_subscriptions.TryRemove(h.Id, out var rs)) return;
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
try { await rs.Subscription.DeleteAsync(silent: true, cancellationToken).ConfigureAwait(false); }
catch { /* best-effort — the subscription may already be gone on reconnect */ }
}
finally { _gate.Release(); }
}
private void OnMonitoredItemNotification(OpcUaSubscriptionHandle handle, MonitoredItem item, MonitoredItemNotificationEventArgs args)
{
// args.NotificationValue arrives as a MonitoredItemNotification for value-change
// subscriptions; extract its DataValue. The Handle property carries our tag string.
if (args.NotificationValue is not MonitoredItemNotification mn) return;
var dv = mn.Value;
if (dv is null) return;
var fullRef = (item.Handle as string) ?? item.DisplayName ?? string.Empty;
var snapshot = new DataValueSnapshot(
Value: dv.Value,
StatusCode: dv.StatusCode.Code,
SourceTimestampUtc: dv.SourceTimestamp == DateTime.MinValue ? null : dv.SourceTimestamp,
ServerTimestampUtc: dv.ServerTimestamp == DateTime.MinValue ? DateTime.UtcNow : dv.ServerTimestamp);
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, fullRef, snapshot));
}
private sealed record RemoteSubscription(Subscription Subscription, OpcUaSubscriptionHandle Handle);
private sealed record OpcUaSubscriptionHandle(long Id) : ISubscriptionHandle
{
public string DiagnosticId => $"opcua-sub-{Id}";
}
// ---- IAlarmSource ----
///
/// Field positions in the EventFilter SelectClauses below. Used to index into the
/// EventFieldList.EventFields Variant collection when an event arrives.
///
private const int AlarmFieldEventId = 0;
private const int AlarmFieldEventType = 1;
private const int AlarmFieldSourceNode = 2;
private const int AlarmFieldMessage = 3;
private const int AlarmFieldSeverity = 4;
private const int AlarmFieldTime = 5;
private const int AlarmFieldConditionId = 6;
public async Task SubscribeAlarmsAsync(
IReadOnlyList sourceNodeIds, CancellationToken cancellationToken)
{
var session = RequireSession();
var id = Interlocked.Increment(ref _nextAlarmSubscriptionId);
var handle = new OpcUaAlarmSubscriptionHandle(id);
// Pre-resolve the source-node filter set so the per-event notification handler can
// match in O(1) without re-parsing on every event.
var sourceFilter = new HashSet(sourceNodeIds, StringComparer.Ordinal);
var alarmDefaults = _options.Subscriptions;
var subscription = new Subscription(telemetry: null!, new SubscriptionOptions
{
DisplayName = $"opcua-alarm-sub-{id}",
PublishingInterval = 500, // 500ms — alarms don't need fast polling; the server pushes
KeepAliveCount = (uint)alarmDefaults.KeepAliveCount,
LifetimeCount = alarmDefaults.LifetimeCount,
MaxNotificationsPerPublish = alarmDefaults.MaxNotificationsPerPublish,
PublishingEnabled = true,
Priority = alarmDefaults.AlarmsPriority,
TimestampsToReturn = TimestampsToReturn.Both,
});
// EventFilter SelectClauses — pick the standard BaseEventType fields we need to
// materialize an AlarmEventArgs. Field positions are indexed by the AlarmField*
// constants so the notification handler indexes in O(1) without re-examining the
// QualifiedName BrowsePaths.
var filter = new EventFilter();
void AddField(string browseName) => filter.SelectClauses.Add(new SimpleAttributeOperand
{
TypeDefinitionId = ObjectTypeIds.BaseEventType,
BrowsePath = [new QualifiedName(browseName)],
AttributeId = Attributes.Value,
});
AddField("EventId");
AddField("EventType");
AddField("SourceNode");
AddField("Message");
AddField("Severity");
AddField("Time");
// ConditionId on ConditionType nodes is the branch identifier for
// acknowledgeable conditions. Not a BaseEventType field — reach it via the typed path.
filter.SelectClauses.Add(new SimpleAttributeOperand
{
TypeDefinitionId = ObjectTypeIds.ConditionType,
BrowsePath = [], // empty path = the condition node itself
AttributeId = Attributes.NodeId,
});
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
session.AddSubscription(subscription);
await subscription.CreateAsync(cancellationToken).ConfigureAwait(false);
var eventItem = new MonitoredItem(telemetry: null!, new MonitoredItemOptions
{
DisplayName = "Server/Events",
StartNodeId = ObjectIds.Server,
AttributeId = Attributes.EventNotifier,
MonitoringMode = MonitoringMode.Reporting,
QueueSize = 1000, // deep queue — a server can fire many alarms in bursts
DiscardOldest = false,
Filter = filter,
})
{
Handle = handle,
};
eventItem.Notification += (mi, args) => OnEventNotification(handle, sourceFilter, mi, args);
subscription.AddItem(eventItem);
await subscription.CreateItemsAsync(cancellationToken).ConfigureAwait(false);
_alarmSubscriptions[id] = new RemoteAlarmSubscription(subscription, handle);
}
finally { _gate.Release(); }
return handle;
}
public async Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken)
{
if (handle is not OpcUaAlarmSubscriptionHandle h) return;
if (!_alarmSubscriptions.TryRemove(h.Id, out var rs)) return;
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
try { await rs.Subscription.DeleteAsync(silent: true, cancellationToken).ConfigureAwait(false); }
catch { /* best-effort — session may already be gone across a reconnect */ }
}
finally { _gate.Release(); }
}
public async Task AcknowledgeAsync(
IReadOnlyList acknowledgements, CancellationToken cancellationToken)
{
// Short-circuit empty batch BEFORE touching the session so callers can pass an empty
// list without guarding the size themselves — e.g. a bulk-ack UI that built an empty
// list because the filter matched nothing.
if (acknowledgements.Count == 0) return;
var session = RequireSession();
// OPC UA A&C: call the AcknowledgeableConditionType.Acknowledge method on each
// condition node with EventId + Comment arguments. CallAsync accepts a batch —
// one CallMethodRequest per ack.
var callRequests = new CallMethodRequestCollection();
foreach (var ack in acknowledgements)
{
if (!TryParseNodeId(session, ack.ConditionId, out var conditionId)) continue;
callRequests.Add(new CallMethodRequest
{
ObjectId = conditionId,
MethodId = MethodIds.AcknowledgeableConditionType_Acknowledge,
InputArguments = [
new Variant(Array.Empty()), // EventId — server-side best-effort; empty resolves to 'most recent'
new Variant(new LocalizedText(ack.Comment ?? string.Empty)),
],
});
}
if (callRequests.Count == 0) return;
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
try
{
_ = await session.CallAsync(
requestHeader: null,
methodsToCall: callRequests,
ct: cancellationToken).ConfigureAwait(false);
}
catch { /* best-effort — caller's re-ack mechanism catches pathological paths */ }
}
finally { _gate.Release(); }
}
// ---- IMethodInvoker ----
///
/// Forward an OPC UA Call service invocation to the upstream server. The
/// method NodeId + object NodeId come from the address-space mirror set up during
/// discovery (); input values are CLR primitives
/// wrapped into s here so the cross-driver capability surface
/// stays SDK-free.
///
///
///
/// Status-code passthrough: per driver-specs.md §8 (cascading quality), an
/// upstream Bad code is returned verbatim through
/// rather than thrown — downstream OPC UA clients see the canonical service result
/// (BadMethodInvalid, BadUserAccessDenied, BadArgumentsMissing,
/// …). Local-side faults (NodeId parse, lost session) surface the corresponding
/// StatusBad* constants the rest of the driver uses.
///
///
/// Per-argument validation results are unpacked into a uint[] so the
/// Core.Abstractions DTO stays SDK-free. null when the upstream
/// didn't surface per-argument codes (typical for Good calls).
///
///
public async Task CallMethodAsync(
string objectNodeId, string methodNodeId, object[] inputs, CancellationToken cancellationToken)
{
var session = RequireSession();
if (!TryParseNodeId(session, objectNodeId, out var objId))
return new MethodCallResult(StatusBadNodeIdInvalid, null, null);
if (!TryParseNodeId(session, methodNodeId, out var methodId))
return new MethodCallResult(StatusBadNodeIdInvalid, null, null);
var inputVariants = new VariantCollection(
(inputs ?? Array.Empty