Compare commits
12 Commits
phase-3-pr
...
phase-3-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28328def5d | ||
| d3bf544abc | |||
|
|
24435712c4 | ||
| 3f7b4d05e6 | |||
|
|
a79c5f3008 | ||
| a5299a2fee | |||
|
|
a65215684c | ||
| 82f2dfcfa3 | |||
|
|
0433d3a35e | ||
| 141673fc80 | |||
|
|
db56a95819 | ||
| 89bd726fa8 |
@@ -27,8 +27,20 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
|||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string driverInstanceId)
|
public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string driverInstanceId)
|
||||||
: IDriver, IReadable, IWritable, IDisposable, IAsyncDisposable
|
: IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IDisposable, IAsyncDisposable
|
||||||
{
|
{
|
||||||
|
// ---- ISubscribable + IHostConnectivityProbe state ----
|
||||||
|
|
||||||
|
private readonly System.Collections.Concurrent.ConcurrentDictionary<long, RemoteSubscription> _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<DataChangeEventArgs>? OnDataChange;
|
||||||
|
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
||||||
|
|
||||||
// OPC UA StatusCode constants the driver surfaces for local-side faults. Upstream-server
|
// 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" —
|
// StatusCodes are passed through verbatim per driver-specs.md §8 "cascading quality" —
|
||||||
// downstream clients need to distinguish 'remote source down' from 'local driver failure'.
|
// downstream clients need to distinguish 'remote source down' from 'local driver failure'.
|
||||||
@@ -47,6 +59,8 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
|
|||||||
|
|
||||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
/// <summary>URL of the endpoint the driver actually connected to. Exposed via <see cref="HostName"/>.</summary>
|
||||||
|
private string? _connectedEndpointUrl;
|
||||||
|
|
||||||
public string DriverInstanceId => driverInstanceId;
|
public string DriverInstanceId => driverInstanceId;
|
||||||
public string DriverType => "OpcUaClient";
|
public string DriverType => "OpcUaClient";
|
||||||
@@ -57,61 +71,55 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var appConfig = await BuildApplicationConfigurationAsync(cancellationToken).ConfigureAwait(false);
|
var appConfig = await BuildApplicationConfigurationAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
var candidates = ResolveEndpointCandidates(_options);
|
||||||
|
|
||||||
// Endpoint selection: let the stack pick the best matching endpoint for the
|
var identity = BuildUserIdentity(_options);
|
||||||
// requested security policy/mode so the driver doesn't have to hand-validate.
|
|
||||||
// UseSecurity=false when SecurityMode=None shortcuts around cert validation
|
|
||||||
// entirely and is the typical dev-bench configuration.
|
|
||||||
var useSecurity = _options.SecurityMode != OpcUaSecurityMode.None;
|
|
||||||
// The non-obsolete SelectEndpointAsync overloads all require an ITelemetryContext
|
|
||||||
// parameter. Passing null is valid — the SDK falls through to its built-in default
|
|
||||||
// trace sink. Plumbing a telemetry context through every driver surface is out of
|
|
||||||
// scope; the driver emits its own logs via the health surface anyway.
|
|
||||||
var selected = await CoreClientUtils.SelectEndpointAsync(
|
|
||||||
appConfig, _options.EndpointUrl, useSecurity,
|
|
||||||
telemetry: null!,
|
|
||||||
ct: cancellationToken).ConfigureAwait(false);
|
|
||||||
var endpointConfig = EndpointConfiguration.Create(appConfig);
|
|
||||||
endpointConfig.OperationTimeout = (int)_options.Timeout.TotalMilliseconds;
|
|
||||||
var endpoint = new ConfiguredEndpoint(null, selected, endpointConfig);
|
|
||||||
|
|
||||||
var identity = _options.AuthType switch
|
// 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<string>(candidates.Count);
|
||||||
|
ISession? session = null;
|
||||||
|
string? connectedUrl = null;
|
||||||
|
foreach (var url in candidates)
|
||||||
{
|
{
|
||||||
OpcUaAuthType.Anonymous => new UserIdentity(new AnonymousIdentityToken()),
|
try
|
||||||
// The UserIdentity(string, string) overload was removed in favour of
|
{
|
||||||
// (string, byte[]) to make the password encoding explicit. UTF-8 is the
|
session = await OpenSessionOnEndpointAsync(
|
||||||
// overwhelmingly common choice for Basic256Sha256-secured sessions.
|
appConfig, url, _options.SecurityPolicy, _options.SecurityMode,
|
||||||
OpcUaAuthType.Username => new UserIdentity(
|
identity, cancellationToken).ConfigureAwait(false);
|
||||||
_options.Username ?? string.Empty,
|
connectedUrl = url;
|
||||||
System.Text.Encoding.UTF8.GetBytes(_options.Password ?? string.Empty)),
|
break;
|
||||||
OpcUaAuthType.Certificate => throw new NotSupportedException(
|
}
|
||||||
"Certificate authentication lands in a follow-up PR; for now use Anonymous or Username"),
|
catch (Exception ex)
|
||||||
_ => new UserIdentity(new AnonymousIdentityToken()),
|
{
|
||||||
|
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. 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);
|
||||||
};
|
};
|
||||||
|
session.KeepAlive += _keepAliveHandler;
|
||||||
// All Session.Create* static methods are marked [Obsolete] in SDK 1.5.378; the
|
|
||||||
// non-obsolete path is DefaultSessionFactory.Instance.CreateAsync (which is the
|
|
||||||
// 8-arg signature matching our driver config — ApplicationConfiguration +
|
|
||||||
// ConfiguredEndpoint, no transport-waiting-connection or reverse-connect-manager
|
|
||||||
// required for the standard opc.tcp direct-connect case).
|
|
||||||
// DefaultSessionFactory's parameterless ctor is also obsolete in 1.5.378; the
|
|
||||||
// current constructor requires an ITelemetryContext. Passing null is tolerated —
|
|
||||||
// the factory falls back to its internal default sink, same as the telemetry:null
|
|
||||||
// on SelectEndpointAsync above.
|
|
||||||
var session = await new DefaultSessionFactory(telemetry: null!).CreateAsync(
|
|
||||||
appConfig,
|
|
||||||
endpoint,
|
|
||||||
false, // updateBeforeConnect
|
|
||||||
_options.SessionName,
|
|
||||||
(uint)_options.SessionTimeout.TotalMilliseconds,
|
|
||||||
identity,
|
|
||||||
null, // preferredLocales
|
|
||||||
cancellationToken).ConfigureAwait(false);
|
|
||||||
|
|
||||||
session.KeepAliveInterval = (int)_options.KeepAliveInterval.TotalMilliseconds;
|
|
||||||
|
|
||||||
Session = session;
|
Session = session;
|
||||||
|
_connectedEndpointUrl = connectedUrl;
|
||||||
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
|
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
|
||||||
|
TransitionTo(HostState.Running);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -206,6 +214,165 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
|
|||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolve the ordered failover candidate list. <c>EndpointUrls</c> wins when
|
||||||
|
/// non-empty; otherwise fall back to <c>EndpointUrl</c> as a single-URL shortcut so
|
||||||
|
/// existing single-endpoint configs keep working without migration.
|
||||||
|
/// </summary>
|
||||||
|
internal static IReadOnlyList<string> ResolveEndpointCandidates(OpcUaClientDriverOptions opts)
|
||||||
|
{
|
||||||
|
if (opts.EndpointUrls is { Count: > 0 }) return opts.EndpointUrls;
|
||||||
|
return [opts.EndpointUrl];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build the user-identity token from the driver options. Split out of
|
||||||
|
/// <see cref="InitializeAsync"/> 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.
|
||||||
|
/// </summary>
|
||||||
|
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()),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Open a session against a single endpoint URL. Bounded by
|
||||||
|
/// <see cref="OpcUaClientDriverOptions.PerEndpointConnectTimeout"/> so the failover
|
||||||
|
/// sweep doesn't spend its full budget on one dead server. Moved out of
|
||||||
|
/// <see cref="InitializeAsync"/> so the failover loop body stays readable.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<ISession> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Select the remote endpoint matching both the requested <paramref name="policy"/>
|
||||||
|
/// and <paramref name="mode"/>. The SDK's <c>CoreClientUtils.SelectEndpointAsync</c>
|
||||||
|
/// only honours a boolean "use security" flag; we need policy-aware matching so an
|
||||||
|
/// operator asking for <c>Basic256Sha256</c> against a server that also offers
|
||||||
|
/// <c>Basic128Rsa15</c> doesn't silently end up on the weaker cipher.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<EndpointDescription> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build a <see cref="UserIdentity"/> carrying a client user-authentication
|
||||||
|
/// certificate loaded from <see cref="OpcUaClientDriverOptions.UserCertificatePath"/>.
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Convert a driver <see cref="OpcUaSecurityPolicy"/> to the OPC UA policy URI.</summary>
|
||||||
|
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)
|
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await ShutdownAsync(cancellationToken).ConfigureAwait(false);
|
await ShutdownAsync(cancellationToken).ConfigureAwait(false);
|
||||||
@@ -214,10 +381,30 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
|
|||||||
|
|
||||||
public async Task ShutdownAsync(CancellationToken cancellationToken)
|
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();
|
||||||
|
|
||||||
|
if (_keepAliveHandler is not null && Session is not null)
|
||||||
|
{
|
||||||
|
try { Session.KeepAlive -= _keepAliveHandler; } catch { }
|
||||||
|
}
|
||||||
|
_keepAliveHandler = null;
|
||||||
|
|
||||||
try { if (Session is Session s) await s.CloseAsync(cancellationToken).ConfigureAwait(false); }
|
try { if (Session is Session s) await s.CloseAsync(cancellationToken).ConfigureAwait(false); }
|
||||||
catch { /* best-effort */ }
|
catch { /* best-effort */ }
|
||||||
try { Session?.Dispose(); } catch { }
|
try { Session?.Dispose(); } catch { }
|
||||||
Session = null;
|
Session = null;
|
||||||
|
_connectedEndpointUrl = null;
|
||||||
|
|
||||||
|
TransitionTo(HostState.Unknown);
|
||||||
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -380,6 +567,397 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
|
|||||||
private ISession RequireSession() =>
|
private ISession RequireSession() =>
|
||||||
Session ?? throw new InvalidOperationException("OpcUaClientDriver not initialized");
|
Session ?? throw new InvalidOperationException("OpcUaClientDriver not initialized");
|
||||||
|
|
||||||
|
// ---- 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 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++,
|
||||||
|
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,
|
||||||
|
List<PendingVariable> 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),
|
||||||
|
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;
|
||||||
|
|
||||||
|
if (rf.NodeClass == NodeClass.Object)
|
||||||
|
{
|
||||||
|
var subFolder = folder.Folder(browseName, displayName);
|
||||||
|
increment();
|
||||||
|
await BrowseRecursiveAsync(session, childId, subFolder, visited,
|
||||||
|
depth + 1, discovered, increment, pendingVariables, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
else if (rf.NodeClass == NodeClass.Variable)
|
||||||
|
{
|
||||||
|
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(
|
||||||
|
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var session = RequireSession();
|
||||||
|
var id = Interlocked.Increment(ref _nextSubscriptionId);
|
||||||
|
var handle = new OpcUaSubscriptionHandle(id);
|
||||||
|
|
||||||
|
// Floor the publishing interval at 50ms — OPC UA servers routinely negotiate
|
||||||
|
// minimum-supported intervals up anyway, but sending sub-50ms wastes negotiation
|
||||||
|
// bandwidth on every subscription create.
|
||||||
|
var intervalMs = publishingInterval < TimeSpan.FromMilliseconds(50)
|
||||||
|
? 50
|
||||||
|
: (int)publishingInterval.TotalMilliseconds;
|
||||||
|
|
||||||
|
var subscription = new Subscription(telemetry: null!, new SubscriptionOptions
|
||||||
|
{
|
||||||
|
DisplayName = $"opcua-sub-{id}",
|
||||||
|
PublishingInterval = intervalMs,
|
||||||
|
KeepAliveCount = 10,
|
||||||
|
LifetimeCount = 1000,
|
||||||
|
MaxNotificationsPerPublish = 0,
|
||||||
|
PublishingEnabled = true,
|
||||||
|
Priority = 0,
|
||||||
|
TimestampsToReturn = TimestampsToReturn.Both,
|
||||||
|
});
|
||||||
|
|
||||||
|
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
session.AddSubscription(subscription);
|
||||||
|
await subscription.CreateAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
foreach (var fullRef in fullReferences)
|
||||||
|
{
|
||||||
|
if (!TryParseNodeId(session, fullRef, out var nodeId)) continue;
|
||||||
|
// The tag string is routed through MonitoredItem.Handle so the Notification
|
||||||
|
// handler can identify which tag changed without an extra lookup.
|
||||||
|
var item = new MonitoredItem(telemetry: null!, new MonitoredItemOptions
|
||||||
|
{
|
||||||
|
DisplayName = fullRef,
|
||||||
|
StartNodeId = nodeId,
|
||||||
|
AttributeId = Attributes.Value,
|
||||||
|
MonitoringMode = MonitoringMode.Reporting,
|
||||||
|
SamplingInterval = intervalMs,
|
||||||
|
QueueSize = 1,
|
||||||
|
DiscardOldest = true,
|
||||||
|
})
|
||||||
|
{
|
||||||
|
Handle = fullRef,
|
||||||
|
};
|
||||||
|
item.Notification += (mi, args) => OnMonitoredItemNotification(handle, mi, args);
|
||||||
|
subscription.AddItem(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
await subscription.CreateItemsAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
_subscriptions[id] = new RemoteSubscription(subscription, handle);
|
||||||
|
}
|
||||||
|
finally { _gate.Release(); }
|
||||||
|
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
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}";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- IHostConnectivityProbe ----
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Endpoint-URL-keyed host identity for the Admin /hosts dashboard. Reflects the
|
||||||
|
/// endpoint the driver actually connected to after the failover sweep — not the
|
||||||
|
/// first URL in the candidate list — so operators see which of the configured
|
||||||
|
/// endpoints is currently serving traffic. Falls back to the first configured URL
|
||||||
|
/// pre-init so the dashboard has something to render before the first connect.
|
||||||
|
/// </summary>
|
||||||
|
public string HostName => _connectedEndpointUrl
|
||||||
|
?? ResolveEndpointCandidates(_options).FirstOrDefault()
|
||||||
|
?? _options.EndpointUrl;
|
||||||
|
|
||||||
|
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses()
|
||||||
|
{
|
||||||
|
lock (_probeLock)
|
||||||
|
return [new HostConnectivityStatus(HostName, _hostState, _hostStateChangedUtc)];
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TransitionTo(HostState newState)
|
||||||
|
{
|
||||||
|
HostState old;
|
||||||
|
lock (_probeLock)
|
||||||
|
{
|
||||||
|
old = _hostState;
|
||||||
|
if (old == newState) return;
|
||||||
|
_hostState = newState;
|
||||||
|
_hostStateChangedUtc = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
OnHostStatusChanged?.Invoke(this, new HostStatusChangedEventArgs(HostName, old, newState));
|
||||||
|
}
|
||||||
|
|
||||||
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
|
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
|
|||||||
@@ -13,11 +13,42 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class OpcUaClientDriverOptions
|
public sealed class OpcUaClientDriverOptions
|
||||||
{
|
{
|
||||||
/// <summary>Remote OPC UA endpoint URL, e.g. <c>opc.tcp://plc.internal:4840</c>.</summary>
|
/// <summary>
|
||||||
|
/// Remote OPC UA endpoint URL, e.g. <c>opc.tcp://plc.internal:4840</c>. Convenience
|
||||||
|
/// shortcut for a single-endpoint deployment — equivalent to setting
|
||||||
|
/// <see cref="EndpointUrls"/> to a list with this one URL. When both are provided,
|
||||||
|
/// the list wins and <see cref="EndpointUrl"/> is ignored.
|
||||||
|
/// </summary>
|
||||||
public string EndpointUrl { get; init; } = "opc.tcp://localhost:4840";
|
public string EndpointUrl { get; init; } = "opc.tcp://localhost:4840";
|
||||||
|
|
||||||
/// <summary>Security policy. One of <c>None</c>, <c>Basic256Sha256</c>, <c>Aes128_Sha256_RsaOaep</c>.</summary>
|
/// <summary>
|
||||||
public string SecurityPolicy { get; init; } = "None";
|
/// Ordered list of candidate endpoint URLs for failover. The driver tries each in
|
||||||
|
/// order at <see cref="OpcUaClientDriver.InitializeAsync"/> and on session drop;
|
||||||
|
/// the first URL that successfully connects wins. Typical use-case: an OPC UA server
|
||||||
|
/// pair running in hot-standby (primary 4840 + backup 4841) where either can serve
|
||||||
|
/// the same address space. Leave unset (or empty) to use <see cref="EndpointUrl"/>
|
||||||
|
/// as a single-URL shortcut.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<string> EndpointUrls { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-endpoint connect-attempt timeout during the failover sweep. Short enough that
|
||||||
|
/// cycling through several dead servers doesn't blow the overall init budget, long
|
||||||
|
/// enough to tolerate a slow TLS handshake on a healthy server. Applied independently
|
||||||
|
/// of <see cref="Timeout"/> which governs steady-state operations.
|
||||||
|
/// </summary>
|
||||||
|
public TimeSpan PerEndpointConnectTimeout { get; init; } = TimeSpan.FromSeconds(3);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Security policy to require when selecting an endpoint. Either a
|
||||||
|
/// <see cref="OpcUaSecurityPolicy"/> enum constant or a free-form string (for
|
||||||
|
/// forward-compatibility with future OPC UA policies not yet in the enum).
|
||||||
|
/// Matched against <c>EndpointDescription.SecurityPolicyUri</c> suffix — the driver
|
||||||
|
/// connects to the first endpoint whose policy name matches AND whose mode matches
|
||||||
|
/// <see cref="SecurityMode"/>. When set to <see cref="OpcUaSecurityPolicy.None"/>
|
||||||
|
/// the driver picks any unsecured endpoint regardless of policy string.
|
||||||
|
/// </summary>
|
||||||
|
public OpcUaSecurityPolicy SecurityPolicy { get; init; } = OpcUaSecurityPolicy.None;
|
||||||
|
|
||||||
/// <summary>Security mode.</summary>
|
/// <summary>Security mode.</summary>
|
||||||
public OpcUaSecurityMode SecurityMode { get; init; } = OpcUaSecurityMode.None;
|
public OpcUaSecurityMode SecurityMode { get; init; } = OpcUaSecurityMode.None;
|
||||||
@@ -31,6 +62,23 @@ public sealed class OpcUaClientDriverOptions
|
|||||||
/// <summary>Password (required only for <see cref="OpcUaAuthType.Username"/>).</summary>
|
/// <summary>Password (required only for <see cref="OpcUaAuthType.Username"/>).</summary>
|
||||||
public string? Password { get; init; }
|
public string? Password { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Filesystem path to the user-identity certificate (PFX/PEM). Required when
|
||||||
|
/// <see cref="AuthType"/> is <see cref="OpcUaAuthType.Certificate"/>. The driver
|
||||||
|
/// loads the cert + private key, which the remote server validates against its
|
||||||
|
/// <c>TrustedUserCertificates</c> store to authenticate the session's user token.
|
||||||
|
/// Leave unset to use the driver's application-instance certificate as the user
|
||||||
|
/// token (not typical — most deployments have a separate user cert).
|
||||||
|
/// </summary>
|
||||||
|
public string? UserCertificatePath { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional password that unlocks <see cref="UserCertificatePath"/> when the PFX is
|
||||||
|
/// protected. PEM files generally have their password on the adjacent key file; this
|
||||||
|
/// knob only applies to password-locked PFX.
|
||||||
|
/// </summary>
|
||||||
|
public string? UserCertificatePassword { get; init; }
|
||||||
|
|
||||||
/// <summary>Server-negotiated session timeout. Default 120s per driver-specs.md §8.</summary>
|
/// <summary>Server-negotiated session timeout. Default 120s per driver-specs.md §8.</summary>
|
||||||
public TimeSpan SessionTimeout { get; init; } = TimeSpan.FromSeconds(120);
|
public TimeSpan SessionTimeout { get; init; } = TimeSpan.FromSeconds(120);
|
||||||
|
|
||||||
@@ -62,6 +110,30 @@ public sealed class OpcUaClientDriverOptions
|
|||||||
|
|
||||||
/// <summary>Connect + per-operation timeout.</summary>
|
/// <summary>Connect + per-operation timeout.</summary>
|
||||||
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(10);
|
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(10);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Root NodeId to mirror. Default <c>null</c> = <c>ObjectsFolder</c> (i=85). Set to
|
||||||
|
/// a scoped root to restrict the address space the driver exposes locally — useful
|
||||||
|
/// when the remote server has tens of thousands of nodes and only a subset is
|
||||||
|
/// needed downstream.
|
||||||
|
/// </summary>
|
||||||
|
public string? BrowseRoot { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cap on total nodes discovered during <c>DiscoverAsync</c>. Default 10_000 —
|
||||||
|
/// bounds memory on runaway remote servers without being so low that normal
|
||||||
|
/// deployments hit it. When the cap is reached discovery stops and a warning is
|
||||||
|
/// written to the driver health surface; the partially-discovered tree is still
|
||||||
|
/// projected into the local address space.
|
||||||
|
/// </summary>
|
||||||
|
public int MaxDiscoveredNodes { get; init; } = 10_000;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Max hierarchical depth of the browse. Default 10 — deep enough for realistic
|
||||||
|
/// OPC UA information models, shallow enough that cyclic graphs can't spin the
|
||||||
|
/// browse forever.
|
||||||
|
/// </summary>
|
||||||
|
public int MaxBrowseDepth { get; init; } = 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>OPC UA message security mode.</summary>
|
/// <summary>OPC UA message security mode.</summary>
|
||||||
@@ -72,6 +144,33 @@ public enum OpcUaSecurityMode
|
|||||||
SignAndEncrypt,
|
SignAndEncrypt,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// OPC UA security policies recognized by the driver. Maps to the standard
|
||||||
|
/// <c>http://opcfoundation.org/UA/SecurityPolicy#</c> URI suffixes the SDK uses for
|
||||||
|
/// endpoint matching.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <see cref="Basic128Rsa15"/> and <see cref="Basic256"/> are <b>deprecated</b> per OPC UA
|
||||||
|
/// spec v1.04 — they remain in the enum only for brownfield interop with older servers.
|
||||||
|
/// Prefer <see cref="Basic256Sha256"/>, <see cref="Aes128_Sha256_RsaOaep"/>, or
|
||||||
|
/// <see cref="Aes256_Sha256_RsaPss"/> for new deployments.
|
||||||
|
/// </remarks>
|
||||||
|
public enum OpcUaSecurityPolicy
|
||||||
|
{
|
||||||
|
/// <summary>No security. Unsigned, unencrypted wire.</summary>
|
||||||
|
None,
|
||||||
|
/// <summary>Deprecated (OPC UA 1.04). Retained for legacy server interop.</summary>
|
||||||
|
Basic128Rsa15,
|
||||||
|
/// <summary>Deprecated (OPC UA 1.04). Retained for legacy server interop.</summary>
|
||||||
|
Basic256,
|
||||||
|
/// <summary>Recommended baseline for current deployments.</summary>
|
||||||
|
Basic256Sha256,
|
||||||
|
/// <summary>Current OPC UA policy; AES-128 + SHA-256 + RSA-OAEP.</summary>
|
||||||
|
Aes128_Sha256_RsaOaep,
|
||||||
|
/// <summary>Current OPC UA policy; AES-256 + SHA-256 + RSA-PSS.</summary>
|
||||||
|
Aes256_Sha256_RsaPss,
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>User authentication type sent to the remote server.</summary>
|
/// <summary>User authentication type sent to the remote server.</summary>
|
||||||
public enum OpcUaAuthType
|
public enum OpcUaAuthType
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
using Opc.Ua;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||||
|
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class OpcUaClientAttributeMappingTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData((uint)DataTypes.Boolean, DriverDataType.Boolean)]
|
||||||
|
[InlineData((uint)DataTypes.Int16, DriverDataType.Int16)]
|
||||||
|
[InlineData((uint)DataTypes.UInt16, DriverDataType.UInt16)]
|
||||||
|
[InlineData((uint)DataTypes.Int32, DriverDataType.Int32)]
|
||||||
|
[InlineData((uint)DataTypes.UInt32, DriverDataType.UInt32)]
|
||||||
|
[InlineData((uint)DataTypes.Int64, DriverDataType.Int64)]
|
||||||
|
[InlineData((uint)DataTypes.UInt64, DriverDataType.UInt64)]
|
||||||
|
[InlineData((uint)DataTypes.Float, DriverDataType.Float32)]
|
||||||
|
[InlineData((uint)DataTypes.Double, DriverDataType.Float64)]
|
||||||
|
[InlineData((uint)DataTypes.String, DriverDataType.String)]
|
||||||
|
[InlineData((uint)DataTypes.DateTime, DriverDataType.DateTime)]
|
||||||
|
public void MapUpstreamDataType_recognizes_standard_builtin_types(uint typeId, DriverDataType expected)
|
||||||
|
{
|
||||||
|
var nodeId = new NodeId(typeId);
|
||||||
|
OpcUaClientDriver.MapUpstreamDataType(nodeId).ShouldBe(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MapUpstreamDataType_maps_SByte_and_Byte_to_Int16_since_DriverDataType_lacks_8bit()
|
||||||
|
{
|
||||||
|
// DriverDataType has no 8-bit type; conservative widen to Int16. Documented so a
|
||||||
|
// future Core.Abstractions PR that adds Int8/Byte can find this call site.
|
||||||
|
OpcUaClientDriver.MapUpstreamDataType(new NodeId((uint)DataTypes.SByte)).ShouldBe(DriverDataType.Int16);
|
||||||
|
OpcUaClientDriver.MapUpstreamDataType(new NodeId((uint)DataTypes.Byte)).ShouldBe(DriverDataType.Int16);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MapUpstreamDataType_falls_back_to_String_for_unknown_custom_types()
|
||||||
|
{
|
||||||
|
// Custom vendor extension object — NodeId in namespace 2 that isn't a standard type.
|
||||||
|
OpcUaClientDriver.MapUpstreamDataType(new NodeId("CustomStruct", 2)).ShouldBe(DriverDataType.String);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MapUpstreamDataType_handles_UtcTime_as_DateTime()
|
||||||
|
{
|
||||||
|
OpcUaClientDriver.MapUpstreamDataType(new NodeId((uint)DataTypes.UtcTime)).ShouldBe(DriverDataType.DateTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData((byte)0, SecurityClassification.ViewOnly)] // no access flags set
|
||||||
|
[InlineData((byte)1, SecurityClassification.ViewOnly)] // CurrentRead only
|
||||||
|
[InlineData((byte)2, SecurityClassification.Operate)] // CurrentWrite only
|
||||||
|
[InlineData((byte)3, SecurityClassification.Operate)] // CurrentRead + CurrentWrite
|
||||||
|
[InlineData((byte)0x0F, SecurityClassification.Operate)] // read+write+historyRead+historyWrite
|
||||||
|
[InlineData((byte)0x04, SecurityClassification.ViewOnly)] // HistoryRead only — no Write bit
|
||||||
|
public void MapAccessLevelToSecurityClass_respects_CurrentWrite_bit(byte accessLevel, SecurityClassification expected)
|
||||||
|
{
|
||||||
|
OpcUaClientDriver.MapAccessLevelToSecurityClass(accessLevel).ShouldBe(expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Security.Cryptography.X509Certificates;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||||
|
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class OpcUaClientCertAuthTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void BuildCertificateIdentity_rejects_missing_path()
|
||||||
|
{
|
||||||
|
var opts = new OpcUaClientDriverOptions { AuthType = OpcUaAuthType.Certificate };
|
||||||
|
Should.Throw<InvalidOperationException>(() => OpcUaClientDriver.BuildCertificateIdentity(opts))
|
||||||
|
.Message.ShouldContain("UserCertificatePath");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildCertificateIdentity_rejects_nonexistent_file()
|
||||||
|
{
|
||||||
|
var opts = new OpcUaClientDriverOptions
|
||||||
|
{
|
||||||
|
AuthType = OpcUaAuthType.Certificate,
|
||||||
|
UserCertificatePath = Path.Combine(Path.GetTempPath(), $"does-not-exist-{Guid.NewGuid():N}.pfx"),
|
||||||
|
};
|
||||||
|
Should.Throw<FileNotFoundException>(() => OpcUaClientDriver.BuildCertificateIdentity(opts));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildCertificateIdentity_loads_a_valid_PFX_with_private_key()
|
||||||
|
{
|
||||||
|
// Generate a self-signed cert on the fly so the test doesn't ship a static PFX.
|
||||||
|
// The driver doesn't care about the issuer — just needs a cert with a private key.
|
||||||
|
using var rsa = RSA.Create(2048);
|
||||||
|
var req = new CertificateRequest("CN=OpcUaClientCertAuthTests", rsa,
|
||||||
|
HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||||
|
var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddHours(1));
|
||||||
|
|
||||||
|
var tmpPath = Path.Combine(Path.GetTempPath(), $"opcua-cert-test-{Guid.NewGuid():N}.pfx");
|
||||||
|
File.WriteAllBytes(tmpPath, cert.Export(X509ContentType.Pfx, "testpw"));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var opts = new OpcUaClientDriverOptions
|
||||||
|
{
|
||||||
|
AuthType = OpcUaAuthType.Certificate,
|
||||||
|
UserCertificatePath = tmpPath,
|
||||||
|
UserCertificatePassword = "testpw",
|
||||||
|
};
|
||||||
|
var identity = OpcUaClientDriver.BuildCertificateIdentity(opts);
|
||||||
|
identity.ShouldNotBeNull();
|
||||||
|
identity.TokenType.ShouldBe(Opc.Ua.UserTokenType.Certificate);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
try { File.Delete(tmpPath); } catch { /* best-effort */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Scaffold tests for <see cref="OpcUaClientDriver"/>'s <see cref="ITagDiscovery"/>
|
||||||
|
/// surface that don't require a live remote server. Live-browse coverage lands in a
|
||||||
|
/// follow-up PR once the in-process OPC UA server fixture is scaffolded.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class OpcUaClientDiscoveryTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task DiscoverAsync_without_initialize_throws_InvalidOperationException()
|
||||||
|
{
|
||||||
|
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-disco");
|
||||||
|
var builder = new NullAddressSpaceBuilder();
|
||||||
|
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||||
|
await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DiscoverAsync_rejects_null_builder()
|
||||||
|
{
|
||||||
|
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-disco");
|
||||||
|
Should.ThrowAsync<ArgumentNullException>(async () =>
|
||||||
|
await drv.DiscoverAsync(null!, TestContext.Current.CancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Discovery_caps_are_sensible_defaults()
|
||||||
|
{
|
||||||
|
var opts = new OpcUaClientDriverOptions();
|
||||||
|
opts.MaxDiscoveredNodes.ShouldBe(10_000, "bounds memory on runaway servers without clipping normal models");
|
||||||
|
opts.MaxBrowseDepth.ShouldBe(10, "deep enough for realistic info models; shallow enough for cycle safety");
|
||||||
|
opts.BrowseRoot.ShouldBeNull("null = default to ObjectsFolder i=85");
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class NullAddressSpaceBuilder : IAddressSpaceBuilder
|
||||||
|
{
|
||||||
|
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
|
||||||
|
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
|
||||||
|
=> new StubHandle();
|
||||||
|
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
|
||||||
|
public void AttachAlarmCondition(IVariableHandle sourceVariable, string alarmName, DriverAttributeInfo alarmInfo) { }
|
||||||
|
|
||||||
|
private sealed class StubHandle : IVariableHandle
|
||||||
|
{
|
||||||
|
public string FullReference => "stub";
|
||||||
|
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ public sealed class OpcUaClientDriverScaffoldTests
|
|||||||
var opts = new OpcUaClientDriverOptions();
|
var opts = new OpcUaClientDriverOptions();
|
||||||
opts.EndpointUrl.ShouldBe("opc.tcp://localhost:4840", "4840 is the IANA-assigned OPC UA port");
|
opts.EndpointUrl.ShouldBe("opc.tcp://localhost:4840", "4840 is the IANA-assigned OPC UA port");
|
||||||
opts.SecurityMode.ShouldBe(OpcUaSecurityMode.None);
|
opts.SecurityMode.ShouldBe(OpcUaSecurityMode.None);
|
||||||
|
opts.SecurityPolicy.ShouldBe(OpcUaSecurityPolicy.None);
|
||||||
opts.AuthType.ShouldBe(OpcUaAuthType.Anonymous);
|
opts.AuthType.ShouldBe(OpcUaAuthType.Anonymous);
|
||||||
opts.AutoAcceptCertificates.ShouldBeFalse("production default must reject untrusted server certs");
|
opts.AutoAcceptCertificates.ShouldBeFalse("production default must reject untrusted server certs");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||||
|
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class OpcUaClientFailoverTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ResolveEndpointCandidates_prefers_EndpointUrls_when_provided()
|
||||||
|
{
|
||||||
|
var opts = new OpcUaClientDriverOptions
|
||||||
|
{
|
||||||
|
EndpointUrl = "opc.tcp://fallback:4840",
|
||||||
|
EndpointUrls = ["opc.tcp://primary:4840", "opc.tcp://backup:4841"],
|
||||||
|
};
|
||||||
|
var list = OpcUaClientDriver.ResolveEndpointCandidates(opts);
|
||||||
|
list.Count.ShouldBe(2);
|
||||||
|
list[0].ShouldBe("opc.tcp://primary:4840");
|
||||||
|
list[1].ShouldBe("opc.tcp://backup:4841");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResolveEndpointCandidates_falls_back_to_single_EndpointUrl_when_list_empty()
|
||||||
|
{
|
||||||
|
var opts = new OpcUaClientDriverOptions { EndpointUrl = "opc.tcp://only:4840" };
|
||||||
|
var list = OpcUaClientDriver.ResolveEndpointCandidates(opts);
|
||||||
|
list.Count.ShouldBe(1);
|
||||||
|
list[0].ShouldBe("opc.tcp://only:4840");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResolveEndpointCandidates_empty_list_treated_as_fallback_to_EndpointUrl()
|
||||||
|
{
|
||||||
|
// Explicit empty list should still fall back to the single-URL shortcut rather than
|
||||||
|
// producing a zero-candidate sweep that would immediately throw with no URLs tried.
|
||||||
|
var opts = new OpcUaClientDriverOptions
|
||||||
|
{
|
||||||
|
EndpointUrl = "opc.tcp://single:4840",
|
||||||
|
EndpointUrls = [],
|
||||||
|
};
|
||||||
|
OpcUaClientDriver.ResolveEndpointCandidates(opts).Count.ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HostName_uses_first_candidate_before_connect()
|
||||||
|
{
|
||||||
|
var opts = new OpcUaClientDriverOptions
|
||||||
|
{
|
||||||
|
EndpointUrls = ["opc.tcp://primary:4840", "opc.tcp://backup:4841"],
|
||||||
|
};
|
||||||
|
using var drv = new OpcUaClientDriver(opts, "opcua-host");
|
||||||
|
drv.HostName.ShouldBe("opc.tcp://primary:4840",
|
||||||
|
"pre-connect the dashboard should show the first candidate URL so operators can link back");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Initialize_against_all_unreachable_endpoints_throws_AggregateException_listing_each()
|
||||||
|
{
|
||||||
|
// Port 1 + port 2 + port 3 on loopback are all guaranteed closed (TCP RST immediate).
|
||||||
|
// Failover sweep should attempt all three and throw AggregateException naming each URL
|
||||||
|
// so operators see exactly which candidates were tried.
|
||||||
|
var opts = new OpcUaClientDriverOptions
|
||||||
|
{
|
||||||
|
EndpointUrls = ["opc.tcp://127.0.0.1:1", "opc.tcp://127.0.0.1:2", "opc.tcp://127.0.0.1:3"],
|
||||||
|
PerEndpointConnectTimeout = TimeSpan.FromMilliseconds(500),
|
||||||
|
Timeout = TimeSpan.FromMilliseconds(500),
|
||||||
|
AutoAcceptCertificates = true,
|
||||||
|
};
|
||||||
|
using var drv = new OpcUaClientDriver(opts, "opcua-failover");
|
||||||
|
|
||||||
|
var ex = await Should.ThrowAsync<AggregateException>(async () =>
|
||||||
|
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
||||||
|
|
||||||
|
ex.Message.ShouldContain("127.0.0.1:1");
|
||||||
|
ex.Message.ShouldContain("127.0.0.1:2");
|
||||||
|
ex.Message.ShouldContain("127.0.0.1:3");
|
||||||
|
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using Opc.Ua;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||||
|
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class OpcUaClientSecurityPolicyTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData(OpcUaSecurityPolicy.None)]
|
||||||
|
[InlineData(OpcUaSecurityPolicy.Basic128Rsa15)]
|
||||||
|
[InlineData(OpcUaSecurityPolicy.Basic256)]
|
||||||
|
[InlineData(OpcUaSecurityPolicy.Basic256Sha256)]
|
||||||
|
[InlineData(OpcUaSecurityPolicy.Aes128_Sha256_RsaOaep)]
|
||||||
|
[InlineData(OpcUaSecurityPolicy.Aes256_Sha256_RsaPss)]
|
||||||
|
public void MapSecurityPolicy_returns_known_non_empty_uri_for_every_enum_value(OpcUaSecurityPolicy policy)
|
||||||
|
{
|
||||||
|
var uri = OpcUaClientDriver.MapSecurityPolicy(policy);
|
||||||
|
uri.ShouldNotBeNullOrEmpty();
|
||||||
|
// Each URI should end in the enum name (for the non-None policies) so a driver
|
||||||
|
// operator reading logs can correlate the URI back to the config value.
|
||||||
|
if (policy != OpcUaSecurityPolicy.None)
|
||||||
|
uri.ShouldContain(policy.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MapSecurityPolicy_None_matches_SDK_None_URI()
|
||||||
|
{
|
||||||
|
OpcUaClientDriver.MapSecurityPolicy(OpcUaSecurityPolicy.None)
|
||||||
|
.ShouldBe(SecurityPolicies.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MapSecurityPolicy_Basic256Sha256_matches_SDK_URI()
|
||||||
|
{
|
||||||
|
OpcUaClientDriver.MapSecurityPolicy(OpcUaSecurityPolicy.Basic256Sha256)
|
||||||
|
.ShouldBe(SecurityPolicies.Basic256Sha256);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MapSecurityPolicy_Aes256_Sha256_RsaPss_matches_SDK_URI()
|
||||||
|
{
|
||||||
|
OpcUaClientDriver.MapSecurityPolicy(OpcUaSecurityPolicy.Aes256_Sha256_RsaPss)
|
||||||
|
.ShouldBe(SecurityPolicies.Aes256_Sha256_RsaPss);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Every_enum_value_has_a_mapping()
|
||||||
|
{
|
||||||
|
foreach (OpcUaSecurityPolicy p in Enum.GetValues<OpcUaSecurityPolicy>())
|
||||||
|
Should.NotThrow(() => OpcUaClientDriver.MapSecurityPolicy(p));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Scaffold tests for <c>ISubscribable</c> + <c>IHostConnectivityProbe</c> that don't
|
||||||
|
/// need a live remote server. Live-session tests (subscribe/unsubscribe round-trip,
|
||||||
|
/// keep-alive transitions) land in a follow-up PR once the in-process OPC UA server
|
||||||
|
/// fixture is scaffolded.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class OpcUaClientSubscribeAndProbeTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task SubscribeAsync_without_initialize_throws_InvalidOperationException()
|
||||||
|
{
|
||||||
|
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-sub-uninit");
|
||||||
|
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||||
|
await drv.SubscribeAsync(["ns=2;s=Demo"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UnsubscribeAsync_with_unknown_handle_is_noop()
|
||||||
|
{
|
||||||
|
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-sub-unknown");
|
||||||
|
// UnsubscribeAsync returns cleanly for handles it doesn't recognise — protects against
|
||||||
|
// the caller's race with server-side cleanup after a session drop.
|
||||||
|
await drv.UnsubscribeAsync(new FakeHandle(), TestContext.Current.CancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetHostStatuses_returns_endpoint_url_row_pre_init()
|
||||||
|
{
|
||||||
|
using var drv = new OpcUaClientDriver(
|
||||||
|
new OpcUaClientDriverOptions { EndpointUrl = "opc.tcp://plc.example:4840" },
|
||||||
|
"opcua-hosts");
|
||||||
|
var rows = drv.GetHostStatuses();
|
||||||
|
rows.Count.ShouldBe(1);
|
||||||
|
rows[0].HostName.ShouldBe("opc.tcp://plc.example:4840",
|
||||||
|
"host identity mirrors the endpoint URL so the Admin /hosts dashboard can link back to the remote server");
|
||||||
|
rows[0].State.ShouldBe(HostState.Unknown);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeHandle : ISubscriptionHandle
|
||||||
|
{
|
||||||
|
public string DiagnosticId => "fake";
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user