feat(dcl): move the OPC UA client to OPC Foundation SDK 2.0.1 (#125)
OPCFoundation.NetStandard.Opc.Ua.Client and the test-only .Server move 1.5.378.182 -> 2.0.1, the version OtOpcUa master runs (lmxopcua#782; #799 and #801 closed). Transitive Core/Core.Types/Types/Configuration/ Security.Certificates resolve to 2.0.1. API breaks fixed in the DCL adapter: - Built-in types are immutable structs (Variant, DataValue, NodeId, LocalizedText, StatusCode, ArrayOf<T>). New OpcUaValueBoundary restores the SDK 1.5 CLR shapes for every read, subscription and alarm-event value (byte[], UTC DateTime with no-timestamp = MinValue, Guid, detached arrays) and builds write variants with SDK 1.5's new Variant(object) semantics (byte[] = ByteString scalar). - StatusCodes members are no longer constants: switches/patterns over them (verify-outcome mapping, connection-level classification, BadResourceUnavailable) match by value. - CertificateIdentifier no longer caches a certificate and the CertificateValidator/CertificateValidation event is gone: the configuration's CertificateManager owns trust lists and the loaded application certificate and is disposed with the session; auto-accept and the verify probe's capture-and-reject use AcceptError. - new UserIdentity(X509Certificate2) is gone: X509 user identities use X509IdentityTokenHandler with an on-demand PFX provider (still fails fast on a missing file/key). - DefaultSessionFactory takes the telemetry context; collections are ArrayOf<T>; EventFilter select clauses and ContentFilter operands are built immutably. UserName identity (lmxopcua#880): SDK 2's default handler sends a password over 64 bytes - every 65-character OtOpcUa API key - as an RsaEncryptedSecret that SDK 1.5 servers reject with BadIdentityTokenInvalid and SDK 2 servers accept only within a 5-minute clock window (#873). The DCL now always uses LegacyRsaUserNameIdentityTokenHandler (ported from OtOpcUa be28aa44), i.e. exactly what it sent on SDK 1.5, so it logs in to both server generations during a mixed rollout. Tests: the in-process secured test server is ported to SDK 2 and can admit an API key; new tests cover the handler (mutation-checked: 5 of 7 fail with the SDK default handler), the value boundary, and a 65-char key end to end over None/Sign/SignAndEncrypt with a wrong key reaching the key check (BadIdentityTokenRejected). Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
This commit is contained in:
@@ -1953,7 +1953,7 @@ public class DataConnectionActor : UntypedActor, IWithTimers
|
||||
or System.Net.Sockets.SocketException
|
||||
or TimeoutException
|
||||
or System.IO.IOException
|
||||
|| (baseEx is Opc.Ua.ServiceResultException sre && IsConnectionLevelStatus(sre.StatusCode));
|
||||
|| (baseEx is Opc.Ua.ServiceResultException sre && IsConnectionLevelStatus(sre.StatusCode.Code));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1971,16 +1971,22 @@ public class DataConnectionActor : UntypedActor, IWithTimers
|
||||
/// as connection-level would put the connection into a reconnect loop over one bad tag path.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private static bool IsConnectionLevelStatus(uint statusCode) => statusCode is
|
||||
Opc.Ua.StatusCodes.BadSessionIdInvalid
|
||||
or Opc.Ua.StatusCodes.BadSessionClosed
|
||||
or Opc.Ua.StatusCodes.BadSessionNotActivated
|
||||
or Opc.Ua.StatusCodes.BadSecureChannelClosed
|
||||
or Opc.Ua.StatusCodes.BadSecureChannelIdInvalid
|
||||
or Opc.Ua.StatusCodes.BadServerNotConnected
|
||||
or Opc.Ua.StatusCodes.BadConnectionClosed
|
||||
or Opc.Ua.StatusCodes.BadCommunicationError
|
||||
or Opc.Ua.StatusCodes.BadNotConnected;
|
||||
private static bool IsConnectionLevelStatus(uint statusCode) => ConnectionLevelStatusCodes.Contains(statusCode);
|
||||
|
||||
// SDK 2 (ScadaBridge#125): StatusCodes members are StatusCode structs, not constants, so the
|
||||
// set is built from their numeric codes rather than matched as constant patterns.
|
||||
private static readonly HashSet<uint> ConnectionLevelStatusCodes =
|
||||
[
|
||||
Opc.Ua.StatusCodes.BadSessionIdInvalid.Code,
|
||||
Opc.Ua.StatusCodes.BadSessionClosed.Code,
|
||||
Opc.Ua.StatusCodes.BadSessionNotActivated.Code,
|
||||
Opc.Ua.StatusCodes.BadSecureChannelClosed.Code,
|
||||
Opc.Ua.StatusCodes.BadSecureChannelIdInvalid.Code,
|
||||
Opc.Ua.StatusCodes.BadServerNotConnected.Code,
|
||||
Opc.Ua.StatusCodes.BadConnectionClosed.Code,
|
||||
Opc.Ua.StatusCodes.BadCommunicationError.Code,
|
||||
Opc.Ua.StatusCodes.BadNotConnected.Code,
|
||||
];
|
||||
|
||||
private void HandleUnsubscribe(UnsubscribeTagsRequest request)
|
||||
{
|
||||
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Security.Certificates;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
|
||||
|
||||
/// <summary>
|
||||
/// UserName token handler that always encrypts the password in the legacy RSA format under an RSA security
|
||||
/// policy, whatever the password's length (lmxopcua#880, ScadaBridge#125).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Ported verbatim from OtOpcUa (<c>Driver.OpcUaClient.Session</c>, lmxopcua <c>be28aa44</c>) when ScadaBridge
|
||||
/// moved to SDK 2.0.1. Under SDK 1.5 the DCL always sent the legacy format, so this keeps its wire behaviour
|
||||
/// unchanged in both directions of a mixed rollout: an SDK 1.5 OtOpcUa server still decrypts a 65-character API
|
||||
/// key, and an SDK 2 server's <c>RsaEncryptedSecret</c> signing-time window (lmxopcua#873, 5 minutes) never
|
||||
/// applies to a DCL login.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// SDK 2.0.1's <see cref="UserNameIdentityTokenHandler"/> encrypts a password longer than 64 bytes (a private
|
||||
/// <c>RsaEncryptedSecretPasswordThreshold</c> constant with no setting) as an <c>RsaEncryptedSecret</c>, with
|
||||
/// <see cref="UserNameIdentityToken.EncryptionAlgorithm"/> left null. A 64-byte or shorter password is sent in
|
||||
/// the legacy format: <c>RSA-OAEP(len ‖ password ‖ serverNonce)</c> under the server certificate, with the
|
||||
/// algorithm URI set. An SDK 1.5.378 server decrypts only the legacy format under an RSA policy. A null
|
||||
/// algorithm reaches its <c>SecurityPolicies.Decrypt</c> as "Unexpected encryption algorithm", so
|
||||
/// <c>BadIdentityTokenInvalid</c> is returned before any credential check. Every production OtOpcUa API key
|
||||
/// is 65 characters long (<c>otua_</c> + 16 hex + <c>_</c> + 43), so an SDK 2 client could not use one
|
||||
/// against an SDK 1.5 server: QA, any rollback target, or an older third-party stack.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The legacy format needs no size limit, because the SDK frames the plaintext with a length and spreads it
|
||||
/// over as many RSA blocks as it needs. An SDK 2 server accepts both formats. It uses the legacy decryptor
|
||||
/// whenever the algorithm URI is set. This handler therefore sends what SDK 1.5 clients (ScadaBridge
|
||||
/// included) always send, and what SDK 2 itself sends for every password up to 64 bytes. The crypto is the
|
||||
/// same RSA-OAEP under the server certificate, bound to the server nonce. The only thing lost is
|
||||
/// <c>RsaEncryptedSecret</c>'s block padding of the secret's length. Policy <c>None</c> and the ECC policies
|
||||
/// are handed to the SDK handler unchanged, so this type never sends anything in a weaker form than the SDK
|
||||
/// would.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class LegacyRsaUserNameIdentityTokenHandler : IUserIdentityTokenHandler
|
||||
{
|
||||
private readonly UserNameIdentityTokenHandler _inner;
|
||||
private readonly ISecurityPolicyRegistry _policies;
|
||||
|
||||
/// <summary>Create the handler for a user name and its plaintext password.</summary>
|
||||
/// <param name="username">The user name.</param>
|
||||
/// <param name="password">The password bytes (UTF-8 for an API key).</param>
|
||||
/// <param name="securityPolicies">The policy registry, or null for <see cref="SecurityPolicies.Default"/>.</param>
|
||||
public LegacyRsaUserNameIdentityTokenHandler(string username, ReadOnlySpan<byte> password,
|
||||
ISecurityPolicyRegistry? securityPolicies = null)
|
||||
: this(new UserNameIdentityTokenHandler(username, password, securityPolicies),
|
||||
securityPolicies ?? SecurityPolicies.Default)
|
||||
{
|
||||
}
|
||||
|
||||
private LegacyRsaUserNameIdentityTokenHandler(UserNameIdentityTokenHandler inner, ISecurityPolicyRegistry policies)
|
||||
{
|
||||
_inner = inner;
|
||||
_policies = policies;
|
||||
}
|
||||
|
||||
/// <summary>The user name in the token.</summary>
|
||||
public string UserName => _inner.UserName;
|
||||
|
||||
/// <inheritdoc />
|
||||
public UserIdentityToken Token => _inner.Token;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DisplayName => _inner.DisplayName;
|
||||
|
||||
/// <inheritdoc />
|
||||
public UserTokenType TokenType => _inner.TokenType;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void UpdatePolicy(UserTokenPolicy userTokenPolicy) => _inner.UpdatePolicy(userTokenPolicy);
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask EncryptAsync(Certificate receiverCertificate, byte[] receiverNonce, string securityPolicyUri,
|
||||
IServiceMessageContext context, Nonce? receiverEphemeralKey = null, Certificate? senderCertificate = null,
|
||||
CertificateCollection? senderIssuerCertificates = null, bool doNotEncodeSenderCertificate = false,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var password = _inner.DecryptedPassword;
|
||||
var policy = password is null || string.IsNullOrEmpty(securityPolicyUri)
|
||||
? null
|
||||
: _policies.GetInfo(securityPolicyUri);
|
||||
|
||||
// Only an RSA policy (no ephemeral key) with a receiver certificate is ours. None, ECC, an unknown policy
|
||||
// and a missing certificate get the SDK's own handling, and its own errors.
|
||||
if (policy is null || policy.Uri == SecurityPolicies.None
|
||||
|| policy.EphemeralKeyAlgorithm != CertificateKeyAlgorithm.None || receiverCertificate is null)
|
||||
{
|
||||
return _inner.EncryptAsync(receiverCertificate!, receiverNonce, securityPolicyUri, context,
|
||||
receiverEphemeralKey, senderCertificate, senderIssuerCertificates, doNotEncodeSenderCertificate, ct);
|
||||
}
|
||||
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var token = (UserNameIdentityToken)_inner.Token;
|
||||
var plaintext = Utils.Append(password, receiverNonce);
|
||||
try
|
||||
{
|
||||
var encrypted = _policies.Encrypt(receiverCertificate, policy.Uri, plaintext);
|
||||
token.Password = encrypted.Data.ToByteString();
|
||||
token.EncryptionAlgorithm = encrypted.Algorithm;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Array.Clear(plaintext);
|
||||
}
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask DecryptAsync(Certificate certificate, Nonce receiverNonce, string securityPolicyUri,
|
||||
IServiceMessageContext context, Nonce? ephemeralKey = null, Certificate? senderCertificate = null,
|
||||
CertificateCollection? senderIssuerCertificates = null, ICertificateValidatorEx? validator = null,
|
||||
CancellationToken ct = default) =>
|
||||
_inner.DecryptAsync(certificate, receiverNonce, securityPolicyUri, context, ephemeralKey, senderCertificate,
|
||||
senderIssuerCertificates, validator, ct);
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<SignatureData> SignAsync(byte[] dataToSign, string securityPolicyUri, CancellationToken ct = default) =>
|
||||
_inner.SignAsync(dataToSign, securityPolicyUri, ct);
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<bool> VerifyAsync(byte[] dataToVerify, SignatureData signatureData, string securityPolicyUri,
|
||||
CancellationToken ct = default) =>
|
||||
_inner.VerifyAsync(dataToVerify, signatureData, securityPolicyUri, ct);
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>The SDK session encrypts a <c>Copy()</c> of the handler, so the clone must keep this type.</remarks>
|
||||
public object Clone() => new LegacyRsaUserNameIdentityTokenHandler((UserNameIdentityTokenHandler)_inner.Clone(), _policies);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool Equals(IUserIdentityTokenHandler? other) =>
|
||||
other is LegacyRsaUserNameIdentityTokenHandler legacy ? _inner.Equals(legacy._inner) : _inner.Equals(other);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool Equals(object? obj) => obj is IUserIdentityTokenHandler other && Equals(other);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(UserName ?? string.Empty);
|
||||
}
|
||||
+35
-19
@@ -43,7 +43,7 @@ public static class OpcUaApplicationIdentity
|
||||
|
||||
// SDK telemetry for the non-obsolete ApplicationInstance ctor. SDK-internal logging is
|
||||
// discarded; this class logs the outcome that matters (created vs reused) itself.
|
||||
private static readonly Lazy<ITelemetryContext> SdkTelemetry = new(() => DefaultTelemetry.Create(_ => { }));
|
||||
internal static readonly Lazy<ITelemetryContext> SdkTelemetry = new(() => DefaultTelemetry.Create(_ => { }));
|
||||
|
||||
// One gate per own-store directory: two connections starting together against an empty
|
||||
// store would otherwise BOTH generate a certificate, leaving two identities for one subject.
|
||||
@@ -112,7 +112,10 @@ public static class OpcUaApplicationIdentity
|
||||
OpcUaGlobalOptions options, bool secured, bool autoAcceptUntrusted,
|
||||
int sessionTimeoutMs, int operationTimeoutMs, ILogger logger, CancellationToken ct)
|
||||
{
|
||||
var appConfig = new ApplicationConfiguration
|
||||
// SDK 2 (ScadaBridge#125): the configuration carries the telemetry context, and ValidateAsync
|
||||
// creates the CertificateManager that owns the trust lists, the validator and (for a secured
|
||||
// configuration) the loaded application certificate.
|
||||
var appConfig = new ApplicationConfiguration(SdkTelemetry.Value)
|
||||
{
|
||||
ApplicationName = ResolveApplicationName(options),
|
||||
ApplicationUri = ResolveApplicationUri(options),
|
||||
@@ -158,9 +161,9 @@ public static class OpcUaApplicationIdentity
|
||||
operationTimeoutMs: 15_000, logger, ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var cert = appConfig.SecurityConfiguration.ApplicationCertificate.Certificate
|
||||
using var entry = AcquireApplicationCertificate(appConfig)
|
||||
?? throw new InvalidOperationException("OPC UA application certificate was not loaded.");
|
||||
return X509CertificateLoader.LoadCertificate(cert.RawData);
|
||||
return X509CertificateLoader.LoadCertificate(entry.Certificate.RawData);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -169,25 +172,34 @@ public static class OpcUaApplicationIdentity
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes the private-key-bearing application certificate a configuration loaded. Call
|
||||
/// only once every session built from <paramref name="appConfig"/> is closed and disposed.
|
||||
/// Disposes the certificate manager a configuration created, which releases the
|
||||
/// private-key-bearing application certificate it loaded. Call only once every session built
|
||||
/// from <paramref name="appConfig"/> is closed and disposed.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// SDK 2 (ScadaBridge#125): <see cref="CertificateIdentifier"/> no longer caches a certificate.
|
||||
/// The loaded identity and the trust-list stores belong to the configuration's
|
||||
/// <see cref="ApplicationConfiguration.CertificateManager"/>, which <c>ValidateAsync</c> creates
|
||||
/// and <see cref="ApplicationInstance"/> reuses, so disposing that manager releases everything.
|
||||
/// </remarks>
|
||||
/// <param name="appConfig">A configuration from <see cref="BuildClientConfigurationAsync"/>; null is a no-op.</param>
|
||||
internal static void ReleaseApplicationCertificate(ApplicationConfiguration? appConfig)
|
||||
{
|
||||
var certificates = appConfig?.SecurityConfiguration?.ApplicationCertificates;
|
||||
if (certificates is null)
|
||||
if (appConfig?.CertificateManager is not { } manager)
|
||||
return;
|
||||
foreach (var id in certificates)
|
||||
{
|
||||
var cert = id?.Certificate;
|
||||
if (cert is null)
|
||||
continue;
|
||||
id!.Certificate = null;
|
||||
cert.Dispose();
|
||||
}
|
||||
appConfig.CertificateManager = null!;
|
||||
(manager as IDisposable)?.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The configuration's RSA application certificate entry, or null when none is loaded. The
|
||||
/// caller disposes the entry.
|
||||
/// </summary>
|
||||
private static Opc.Ua.Security.Certificates.CertificateEntry? AcquireApplicationCertificate(
|
||||
ApplicationConfiguration appConfig) =>
|
||||
appConfig.CertificateManager?.AcquireApplicationCertificateByType(
|
||||
ObjectTypeIds.RsaSha256ApplicationCertificateType);
|
||||
|
||||
private static CertificateIdentifier NewOwnIdentifier(OpcUaGlobalOptions options) => new()
|
||||
{
|
||||
StoreType = CertificateStoreType.Directory,
|
||||
@@ -199,8 +211,11 @@ public static class OpcUaApplicationIdentity
|
||||
private static async Task EnsureLoadedAsync(
|
||||
ApplicationConfiguration appConfig, OpcUaGlobalOptions options, ILogger logger, CancellationToken ct)
|
||||
{
|
||||
var id = appConfig.SecurityConfiguration.ApplicationCertificate;
|
||||
var gate = ProvisionGates.GetOrAdd(Path.GetFullPath(id.StorePath), _ => new SemaphoreSlim(1, 1));
|
||||
var id = appConfig.SecurityConfiguration.ApplicationCertificate
|
||||
?? throw new InvalidOperationException("OPC UA application certificate identifier is not configured.");
|
||||
var storePath = id.StorePath
|
||||
?? throw new InvalidOperationException("OPC UA application certificate store path is not configured.");
|
||||
var gate = ProvisionGates.GetOrAdd(Path.GetFullPath(storePath), _ => new SemaphoreSlim(1, 1));
|
||||
await gate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
@@ -226,7 +241,8 @@ public static class OpcUaApplicationIdentity
|
||||
$"ApplicationUri '{appConfig.ApplicationUri}': {ex.Message}", ex);
|
||||
}
|
||||
|
||||
var cert = id.Certificate;
|
||||
using var entry = ok ? AcquireApplicationCertificate(appConfig) : null;
|
||||
var cert = entry?.Certificate;
|
||||
if (!ok || cert is null || !cert.HasPrivateKey)
|
||||
throw new InvalidOperationException(
|
||||
$"OPC UA application certificate '{id.SubjectName}' could not be loaded with its private key from '{id.StorePath}'.");
|
||||
|
||||
@@ -482,7 +482,7 @@ public class OpcUaDataConnection
|
||||
/// connection fault, so it must not reach <c>RaiseDisconnected</c> or the connection-level
|
||||
/// status set the DataConnectionActor reconnects on.
|
||||
/// </summary>
|
||||
internal const uint BadResourceUnavailableCode = Opc.Ua.StatusCodes.BadResourceUnavailable;
|
||||
internal static readonly uint BadResourceUnavailableCode = Opc.Ua.StatusCodes.BadResourceUnavailable.Code;
|
||||
|
||||
/// <summary>True when <paramref name="statusCode"/>'s code part is BadResourceUnavailable
|
||||
/// (the low 16 bits are info bits and do not change the meaning).</summary>
|
||||
@@ -494,7 +494,7 @@ public class OpcUaDataConnection
|
||||
/// transient source-side condition from a configuration or security error.</summary>
|
||||
internal static string DescribeBadRead(uint statusCode)
|
||||
{
|
||||
var name = Opc.Ua.StatusCodes.LookupSymbolicId(statusCode & 0xFFFF0000u);
|
||||
var name = new Opc.Ua.StatusCode(statusCode & 0xFFFF0000u).SymbolicId;
|
||||
var text = string.IsNullOrEmpty(name)
|
||||
? $"OPC UA read returned bad status: 0x{statusCode:X8}"
|
||||
: $"OPC UA read returned bad status: 0x{statusCode:X8} {name}";
|
||||
|
||||
@@ -72,7 +72,7 @@ public static class OpcUaNodeReference
|
||||
|
||||
// Maps nsu= -> the live index; leaves an ns= reference's index untouched.
|
||||
var nodeId = ExpandedNodeId.ToNodeId(expanded, namespaceUris);
|
||||
if (NodeId.IsNull(nodeId))
|
||||
if (nodeId.IsNull)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"'{reference}' names namespace URI '{expanded.NamespaceUri}', which this server " +
|
||||
@@ -95,14 +95,13 @@ public static class OpcUaNodeReference
|
||||
/// </returns>
|
||||
public static string ToDurable(ExpandedNodeId nodeId, NamespaceTable namespaceUris)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(nodeId);
|
||||
ArgumentNullException.ThrowIfNull(namespaceUris);
|
||||
|
||||
// A browse can hand back a reference carrying a NamespaceUri or a server index
|
||||
// rather than a plain index. Resolving to session-local first collapses those
|
||||
// shapes; without it the raw ToString() can emit a string Resolve cannot re-read.
|
||||
var local = ExpandedNodeId.ToNodeId(nodeId, namespaceUris);
|
||||
if (NodeId.IsNull(local))
|
||||
if (local.IsNull)
|
||||
return nodeId.ToString() ?? string.Empty;
|
||||
|
||||
// ns=0 is spec-fixed, so its short form is already durable.
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Security.Certificates;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the OPC UA user identity a DCL session (and the verify probe) presents (ScadaBridge#125).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>UserName.</b> Always the <see cref="LegacyRsaUserNameIdentityTokenHandler"/>: SDK 2's default
|
||||
/// handler sends a password over 64 bytes, such as a 65-character OtOpcUa API key, as an
|
||||
/// <c>RsaEncryptedSecret</c> that an SDK 1.5 server rejects with <c>BadIdentityTokenInvalid</c>
|
||||
/// (lmxopcua#880). The legacy handler sends what the DCL sent under SDK 1.5, which both server
|
||||
/// generations decrypt.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>X509Certificate.</b> SDK 2 removed <c>new UserIdentity(X509Certificate2)</c>; the identity is
|
||||
/// now a <see cref="X509IdentityTokenHandler"/> that resolves its private key on demand through an
|
||||
/// <see cref="ICertificateProvider"/>. The PFX is read once here, so a missing file, a bad password or
|
||||
/// a certificate without a private key still fails the connect before any session is created, as it
|
||||
/// did under SDK 1.5. The provider re-reads the same file whenever the SDK asks for the key.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class OpcUaUserIdentities
|
||||
{
|
||||
/// <summary>
|
||||
/// The identity for <paramref name="options"/>, or null for anonymous (no options, or a token
|
||||
/// type this client does not implement).
|
||||
/// </summary>
|
||||
/// <param name="options">The configured user identity.</param>
|
||||
/// <returns>The identity, or null.</returns>
|
||||
internal static UserIdentity? Build(OpcUaUserIdentityOptions? options)
|
||||
{
|
||||
if (options is null) return null;
|
||||
return options.TokenType.ToUpperInvariant() switch
|
||||
{
|
||||
"USERNAMEPASSWORD" => UserName(options.Username ?? string.Empty, options.Password ?? string.Empty),
|
||||
"X509CERTIFICATE" => FromCertificateFile(options.CertificatePath, options.CertificatePassword),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>A UserName identity that always uses the legacy RSA password format.</summary>
|
||||
/// <param name="username">The user name.</param>
|
||||
/// <param name="password">The plaintext password, sent UTF-8 encoded.</param>
|
||||
/// <returns>The identity.</returns>
|
||||
internal static UserIdentity UserName(string username, string password)
|
||||
{
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes(password);
|
||||
try
|
||||
{
|
||||
return new UserIdentity(new LegacyRsaUserNameIdentityTokenHandler(username, bytes));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Array.Clear(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>An X509 identity backed by a PFX file.</summary>
|
||||
/// <param name="path">The PFX path.</param>
|
||||
/// <param name="password">The PFX password, or null.</param>
|
||||
/// <returns>The identity.</returns>
|
||||
internal static UserIdentity FromCertificateFile(string? path, string? password)
|
||||
{
|
||||
using var certificate = Load(path, password);
|
||||
var identifier = new CertificateIdentifier { Thumbprint = certificate.Thumbprint };
|
||||
return new UserIdentity(new X509IdentityTokenHandler(
|
||||
identifier, new CertificatePasswordProvider(),
|
||||
new PfxFileCertificateProvider(path!, password, certificate.Thumbprint)));
|
||||
}
|
||||
|
||||
private static X509Certificate2 Load(string? path, string? password)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
throw new InvalidOperationException(
|
||||
"An X509Certificate OPC UA user identity requires a certificate path.");
|
||||
var certificate = X509CertificateLoader.LoadPkcs12FromFile(path, password);
|
||||
if (!certificate.HasPrivateKey)
|
||||
{
|
||||
certificate.Dispose();
|
||||
throw new InvalidOperationException(
|
||||
$"The OPC UA user certificate at '{path}' has no private key; the key is required to sign the " +
|
||||
"user-token challenge when the session activates.");
|
||||
}
|
||||
return certificate;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the user certificate's private key from its PFX file. The thumbprint read when the
|
||||
/// identity was built is pinned: a file replaced underneath a live identity fails the activation
|
||||
/// rather than presenting a different certificate.
|
||||
/// </summary>
|
||||
private sealed class PfxFileCertificateProvider(string path, string? password, string thumbprint)
|
||||
: ICertificateProvider
|
||||
{
|
||||
public Certificate? TryGetPrivateKeyCertificate(string requestedThumbprint) => null;
|
||||
|
||||
public ValueTask<Certificate?> GetPrivateKeyCertificateAsync(CertificateIdentifier identifier,
|
||||
ICertificatePasswordProvider? passwordProvider = null, string? applicationUri = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var certificate = Load(path, password);
|
||||
if (!string.Equals(certificate.Thumbprint, thumbprint, StringComparison.OrdinalIgnoreCase)
|
||||
|| !string.Equals(identifier.Thumbprint, thumbprint, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
certificate.Dispose();
|
||||
throw new ServiceResultException(StatusCodes.BadIdentityTokenInvalid,
|
||||
"The OPC UA user certificate file changed; reconnect to present the new certificate.");
|
||||
}
|
||||
return ValueTask.FromResult<Certificate?>(Certificate.From(certificate));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using Opc.Ua;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
|
||||
|
||||
/// <summary>
|
||||
/// Converts between OPC UA SDK 2 values and the CLR payloads the rest of ScadaBridge consumes
|
||||
/// (ScadaBridge#125).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// SDK 2 made <see cref="Variant"/>, <see cref="DataValue"/>, <see cref="NodeId"/> and the
|
||||
/// other built-in types immutable structs, and its legacy boxing still hands back a few SDK
|
||||
/// wrapper types (<see cref="ByteString"/>, <see cref="DateTimeUtc"/>, <see cref="Uuid"/>)
|
||||
/// where SDK 1.5 handed back CLR ones. Everything downstream of the DCL (the codec, scripts,
|
||||
/// the alarm mapper) was written against the 1.5 shapes, so this boundary restores them:
|
||||
/// <c>byte[]</c>, <see cref="DateTime"/> (UTC) and <see cref="Guid"/>, and copies arrays so a
|
||||
/// retained value never aliases an SDK buffer.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The same mapping OtOpcUa uses at its SDK 2 driver boundary (<c>UpstreamValueMapping</c>),
|
||||
/// except that a matrix is passed through as the SDK boxes it rather than refused: SDK 1.5
|
||||
/// passed one through too, and a value-change callback has no way to report a refusal.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class OpcUaValueBoundary
|
||||
{
|
||||
/// <summary>The CLR payload of <paramref name="value"/>, in its SDK 1.5 shape.</summary>
|
||||
/// <param name="value">The SDK value.</param>
|
||||
/// <returns>The detached CLR payload; null for a null variant.</returns>
|
||||
internal static object? ToClr(Variant value)
|
||||
{
|
||||
var boxed = value.AsBoxedObject(Variant.BoxingBehavior.Legacy);
|
||||
return value.TypeInfo.IsMatrix ? boxed : Detach(boxed);
|
||||
}
|
||||
|
||||
private static object? Detach(object? value) => value switch
|
||||
{
|
||||
ByteString bytes => bytes.IsNull ? null : bytes.ToArray(),
|
||||
DateTimeUtc time => ToClrTimestamp(time),
|
||||
Uuid uuid => (Guid)uuid,
|
||||
ByteString[] bytes => bytes.Select(b => b.IsNull ? null : b.ToArray()).ToArray(),
|
||||
DateTimeUtc[] times => times.Select(ToClrTimestamp).ToArray(),
|
||||
Uuid[] uuids => uuids.Select(u => (Guid)u).ToArray(),
|
||||
Variant[] variants => variants.Select(ToClr).ToArray(),
|
||||
object?[] objects when objects.GetType() == typeof(object[]) => objects.Select(Detach).ToArray(),
|
||||
Array array => array.Clone(),
|
||||
_ => value,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// A UTC <see cref="DateTime"/> for an SDK timestamp. The OPC UA "no timestamp" value maps to
|
||||
/// <see cref="DateTime.MinValue"/>, which is what SDK 1.5 decoded it to.
|
||||
/// </summary>
|
||||
/// <param name="value">The SDK timestamp.</param>
|
||||
/// <returns>The CLR timestamp.</returns>
|
||||
internal static DateTime ToClrTimestamp(DateTimeUtc value) =>
|
||||
value == DateTimeUtc.MinValue ? DateTime.MinValue : value.ToDateTime();
|
||||
|
||||
/// <summary>
|
||||
/// The SDK value to write for a CLR payload, with SDK 1.5's <c>new Variant(object)</c>
|
||||
/// semantics: a <c>byte[]</c> is a ByteString scalar, other supported scalars and
|
||||
/// one-dimensional arrays map to their built-in type, and caller buffers are copied.
|
||||
/// </summary>
|
||||
/// <param name="value">The CLR payload (null writes a null variant).</param>
|
||||
/// <returns>The variant.</returns>
|
||||
/// <exception cref="ArgumentException">The payload has no OPC UA built-in representation.</exception>
|
||||
internal static Variant ToVariant(object? value)
|
||||
{
|
||||
switch (value)
|
||||
{
|
||||
case null:
|
||||
return Variant.Null;
|
||||
case byte[] bytes:
|
||||
return Variant.From(ByteString.From(bytes.AsSpan()));
|
||||
case Array { Rank: > 1 }:
|
||||
throw new ArgumentException("Multi-dimensional write values are not supported.", nameof(value));
|
||||
case Array array:
|
||||
value = array.Clone();
|
||||
break;
|
||||
}
|
||||
|
||||
if (!VariantHelper.TryCastFrom(value, out Variant variant))
|
||||
throw new ArgumentException(
|
||||
$"A {value.GetType().Name} value has no OPC UA built-in type to write it as.", nameof(value));
|
||||
return variant;
|
||||
}
|
||||
}
|
||||
@@ -116,13 +116,14 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
/// the SDK's default 120 s operation timeout, and could not be cancelled by a verify probe's
|
||||
/// budget.
|
||||
/// </summary>
|
||||
private static async Task<EndpointDescriptionCollection> DiscoverEndpointsAsync(
|
||||
private static async Task<IReadOnlyList<EndpointDescription>> DiscoverEndpointsAsync(
|
||||
ApplicationConfiguration appConfig, string endpointUrl, CancellationToken ct)
|
||||
{
|
||||
var discoveryConfig = EndpointConfiguration.Create(appConfig);
|
||||
using var discoveryClient = await DiscoveryClient.CreateAsync(
|
||||
appConfig, new Uri(endpointUrl), discoveryConfig, DiagnosticsMasks.None, ct).ConfigureAwait(false);
|
||||
return await discoveryClient.GetEndpointsAsync(null, ct).ConfigureAwait(false);
|
||||
var endpoints = await discoveryClient.GetEndpointsAsync(default, ct).ConfigureAwait(false);
|
||||
return endpoints.IsNull ? [] : endpoints.ToList();
|
||||
}
|
||||
|
||||
/// <summary>Raised when the OPC UA connection is lost.</summary>
|
||||
@@ -138,9 +139,9 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
/// </param>
|
||||
/// <returns>The endpoint matching the requested security mode.</returns>
|
||||
internal static async Task<EndpointDescription> DiscoverEndpointAsync(string url, MessageSecurityMode requested,
|
||||
Func<CancellationToken, Task<EndpointDescriptionCollection>> discover, CancellationToken ct = default)
|
||||
Func<CancellationToken, Task<IReadOnlyList<EndpointDescription>>> discover, CancellationToken ct = default)
|
||||
{
|
||||
EndpointDescriptionCollection endpoints;
|
||||
IReadOnlyList<EndpointDescription> endpoints;
|
||||
try { endpoints = await discover(ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
|
||||
catch (Exception ex)
|
||||
@@ -184,7 +185,10 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
"OPC UA connection to {Endpoint} has AutoAcceptUntrustedCerts enabled — every " +
|
||||
"server certificate is accepted unconditionally. This defeats Sign / " +
|
||||
"SignAndEncrypt protection against a man-in-the-middle.", endpointUrl);
|
||||
appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
|
||||
// SDK 2 replaced the CertificateValidation event with a per-error AcceptError hook on the
|
||||
// configuration's certificate manager (ScadaBridge#125). Errors the SDK classes as
|
||||
// non-suppressible (an incomplete chain, an empty distinguished name) never reach it.
|
||||
appConfig.CertificateManager.AcceptError = (_, _) => true;
|
||||
}
|
||||
|
||||
// Discover endpoints from the server, pick the preferred security mode
|
||||
@@ -199,16 +203,14 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
var endpointConfig = EndpointConfiguration.Create(appConfig);
|
||||
var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfig);
|
||||
|
||||
#pragma warning disable CS0618 // Allow obsolete DefaultSessionFactory constructor for compatibility
|
||||
var sessionFactory = new DefaultSessionFactory();
|
||||
#pragma warning restore CS0618
|
||||
var sessionFactory = new DefaultSessionFactory(OpcUaApplicationIdentity.SdkTelemetry.Value);
|
||||
var userIdentity = BuildUserIdentity(opts.UserIdentity);
|
||||
_writeDataTypes.Clear();
|
||||
try
|
||||
{
|
||||
_session = await sessionFactory.CreateAsync(
|
||||
appConfig, configuredEndpoint, false,
|
||||
"ScadaBridge-DCL-Session", (uint)opts.SessionTimeoutMs, userIdentity, null, cancellationToken);
|
||||
"ScadaBridge-DCL-Session", (uint)opts.SessionTimeoutMs, userIdentity, default, cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -379,19 +381,22 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
// Capture the untrusted server cert, then REJECT it (e.Accept = false). The
|
||||
// validator runs on the SDK's connect thread; copying the cert is the only state we
|
||||
// keep. Never accept — this probe must not trust anything.
|
||||
CertificateValidationEventHandler captureAndReject = (_, e) =>
|
||||
// SDK 2 (ScadaBridge#125): the per-error AcceptError hook replaces the CertificateValidation
|
||||
// event. Returning false rejects the error exactly as e.Accept = false did.
|
||||
Func<Opc.Ua.Security.Certificates.Certificate, ServiceResult, bool> captureAndReject = (certificate, _) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
// Copy into a stable instance so disposing the SDK's chain doesn't invalidate it.
|
||||
capturedCert = X509CertificateLoader.LoadCertificate(e.Certificate.RawData);
|
||||
// Copy into a stable instance: the SDK's Certificate is reference-counted and is
|
||||
// released once validation returns.
|
||||
capturedCert ??= X509CertificateLoader.LoadCertificate(certificate.RawData);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort capture: fall back to the original reference if the copy fails.
|
||||
capturedCert = e.Certificate;
|
||||
// Best-effort capture: the SDK's own copy is the fallback.
|
||||
capturedCert ??= certificate.AsX509Certificate2();
|
||||
}
|
||||
e.Accept = false;
|
||||
return false;
|
||||
};
|
||||
|
||||
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
@@ -407,7 +412,7 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
globalOptions, secured: preferredSecurityMode != MessageSecurityMode.None,
|
||||
autoAcceptUntrusted: false, config.SessionTimeoutMs, config.OperationTimeoutMs,
|
||||
logger, linkedCts.Token);
|
||||
appConfig.CertificateValidator.CertificateValidation += captureAndReject;
|
||||
appConfig.CertificateManager.AcceptError = captureAndReject;
|
||||
|
||||
// Discover endpoints, pick the preferred security mode (same logic as ConnectAsync).
|
||||
// Bounded by the probe's own budget (linkedCts), not just the SDK operation timeout.
|
||||
@@ -421,9 +426,7 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
var endpointConfig = EndpointConfiguration.Create(appConfig);
|
||||
var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfig);
|
||||
|
||||
#pragma warning disable CS0618 // Allow obsolete DefaultSessionFactory constructor for compatibility
|
||||
var sessionFactory = new DefaultSessionFactory();
|
||||
#pragma warning restore CS0618
|
||||
var sessionFactory = new DefaultSessionFactory(OpcUaApplicationIdentity.SdkTelemetry.Value);
|
||||
|
||||
var userIdentity = BuildUserIdentity(config.UserIdentity is { } ui
|
||||
? new OpcUaUserIdentityOptions(
|
||||
@@ -434,7 +437,7 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
session = await sessionFactory.CreateAsync(
|
||||
appConfig, configuredEndpoint, false,
|
||||
"ScadaBridge-DCL-Verify", (uint)config.SessionTimeoutMs,
|
||||
userIdentity, null, linkedCts.Token);
|
||||
userIdentity, default, linkedCts.Token);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -452,8 +455,8 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
catch (Exception ex) { logger.LogDebug(ex, "OPC UA verify session close failed (ignored)."); }
|
||||
session.Dispose();
|
||||
}
|
||||
if (appConfig != null)
|
||||
appConfig.CertificateValidator.CertificateValidation -= captureAndReject;
|
||||
if (appConfig?.CertificateManager is { } manager)
|
||||
manager.AcceptError = null;
|
||||
OpcUaApplicationIdentity.ReleaseApplicationCertificate(appConfig);
|
||||
}
|
||||
|
||||
@@ -502,24 +505,18 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
if (HasSocketCause(sre))
|
||||
return new VerifyEndpointResult(false, VerifyFailureKind.Unreachable, sre.Message, null);
|
||||
|
||||
switch (sre.StatusCode)
|
||||
{
|
||||
case StatusCodes.BadRequestTimeout:
|
||||
case StatusCodes.BadTimeout:
|
||||
return new VerifyEndpointResult(false, VerifyFailureKind.Timeout, sre.Message, null);
|
||||
case StatusCodes.BadUserAccessDenied:
|
||||
case StatusCodes.BadIdentityTokenRejected:
|
||||
case StatusCodes.BadIdentityTokenInvalid:
|
||||
return new VerifyEndpointResult(false, VerifyFailureKind.AuthFailed, sre.Message, null);
|
||||
case StatusCodes.BadConnectionRejected:
|
||||
case StatusCodes.BadNotConnected:
|
||||
case StatusCodes.BadConnectionClosed:
|
||||
case StatusCodes.BadNoCommunication:
|
||||
case StatusCodes.BadServerNotConnected:
|
||||
return new VerifyEndpointResult(false, VerifyFailureKind.Unreachable, sre.Message, null);
|
||||
default:
|
||||
return new VerifyEndpointResult(false, VerifyFailureKind.ServerError, sre.Message, null);
|
||||
}
|
||||
// SDK 2: StatusCodes members are StatusCode structs, not constants, so match by value.
|
||||
var status = sre.StatusCode;
|
||||
if (status == StatusCodes.BadRequestTimeout || status == StatusCodes.BadTimeout)
|
||||
return new VerifyEndpointResult(false, VerifyFailureKind.Timeout, sre.Message, null);
|
||||
if (status == StatusCodes.BadUserAccessDenied || status == StatusCodes.BadIdentityTokenRejected
|
||||
|| status == StatusCodes.BadIdentityTokenInvalid)
|
||||
return new VerifyEndpointResult(false, VerifyFailureKind.AuthFailed, sre.Message, null);
|
||||
if (status == StatusCodes.BadConnectionRejected || status == StatusCodes.BadNotConnected
|
||||
|| status == StatusCodes.BadConnectionClosed || status == StatusCodes.BadNoCommunication
|
||||
|| status == StatusCodes.BadServerNotConnected)
|
||||
return new VerifyEndpointResult(false, VerifyFailureKind.Unreachable, sre.Message, null);
|
||||
return new VerifyEndpointResult(false, VerifyFailureKind.ServerError, sre.Message, null);
|
||||
}
|
||||
|
||||
// A bare socket failure (DNS / connection refused) before the SDK wrapped it.
|
||||
@@ -754,9 +751,11 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
{
|
||||
if (e.NotificationValue is MonitoredItemNotification notification)
|
||||
{
|
||||
var value = notification.Value?.Value;
|
||||
var timestamp = notification.Value?.SourceTimestamp ?? DateTime.UtcNow;
|
||||
var statusCode = notification.Value?.StatusCode.Code ?? 0;
|
||||
// SDK 2: DataValue is a struct; restore the SDK 1.5 CLR payload shapes.
|
||||
var dataValue = notification.Value;
|
||||
var value = OpcUaValueBoundary.ToClr(dataValue.WrappedValue);
|
||||
var timestamp = OpcUaValueBoundary.ToClrTimestamp(dataValue.SourceTimestamp);
|
||||
var statusCode = dataValue.StatusCode.Code;
|
||||
|
||||
if (_callbacks.TryGetValue(handle, out var cb))
|
||||
{
|
||||
@@ -1039,11 +1038,11 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
/// <returns>The friendly type name when known; otherwise the NodeId string (or "" when null).</returns>
|
||||
internal static string ResolveAlarmTypeName(NodeId? eventType)
|
||||
{
|
||||
if (eventType is null)
|
||||
if (eventType is not { } type || type.IsNull)
|
||||
return "";
|
||||
return ConditionTypeNamesById.TryGetValue(eventType, out var friendly)
|
||||
return ConditionTypeNamesById.TryGetValue(type, out var friendly)
|
||||
? friendly
|
||||
: eventType.ToString();
|
||||
: type.ToString() ?? "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1067,20 +1066,22 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
internal static EventFilter BuildAlarmEventFilter(AlarmConditionFilter conditionFilter)
|
||||
{
|
||||
var filter = new EventFilter();
|
||||
// SDK 2: SelectClauses is an immutable ArrayOf; collect the clauses, then assign once.
|
||||
var selectClauses = new List<SimpleAttributeOperand>();
|
||||
foreach (var name in AlarmStateFields)
|
||||
filter.SelectClauses.Add(SelectField(ObjectTypeIds.BaseEventType, name));
|
||||
selectClauses.Add(SelectField(ObjectTypeIds.BaseEventType, name));
|
||||
|
||||
// Two-state sub-condition /Id booleans + shelving current-state + identity.
|
||||
filter.SelectClauses.Add(SelectField(ObjectTypeIds.AlarmConditionType, "ActiveState", "Id")); // 6
|
||||
filter.SelectClauses.Add(SelectField(ObjectTypeIds.AcknowledgeableConditionType, "AckedState", "Id")); // 7
|
||||
filter.SelectClauses.Add(SelectField(ObjectTypeIds.AcknowledgeableConditionType, "ConfirmedState", "Id"));// 8
|
||||
filter.SelectClauses.Add(SelectField(ObjectTypeIds.AlarmConditionType, "SuppressedState", "Id")); // 9
|
||||
filter.SelectClauses.Add(SelectField(ObjectTypeIds.AlarmConditionType, "ShelvingState", "CurrentState"));// 10
|
||||
filter.SelectClauses.Add(SelectField(ObjectTypeIds.ConditionType, "ConditionName")); // 11
|
||||
filter.SelectClauses.Add(SelectField(ObjectTypeIds.ConditionType, "Comment")); // 12
|
||||
selectClauses.Add(SelectField(ObjectTypeIds.AlarmConditionType, "ActiveState", "Id")); // 6
|
||||
selectClauses.Add(SelectField(ObjectTypeIds.AcknowledgeableConditionType, "AckedState", "Id")); // 7
|
||||
selectClauses.Add(SelectField(ObjectTypeIds.AcknowledgeableConditionType, "ConfirmedState", "Id"));// 8
|
||||
selectClauses.Add(SelectField(ObjectTypeIds.AlarmConditionType, "SuppressedState", "Id")); // 9
|
||||
selectClauses.Add(SelectField(ObjectTypeIds.AlarmConditionType, "ShelvingState", "CurrentState"));// 10
|
||||
selectClauses.Add(SelectField(ObjectTypeIds.ConditionType, "ConditionName")); // 11
|
||||
selectClauses.Add(SelectField(ObjectTypeIds.ConditionType, "Comment")); // 12
|
||||
|
||||
// APPENDED fields (indices 13+): optional — only present on specific derived types.
|
||||
// Guard all reads with fields.Count > N so base-ConditionType events still process.
|
||||
// Guard all reads with fields.Length > N so base-ConditionType events still process.
|
||||
|
||||
// 13: AlarmConditionType/ActiveState/TransitionTime — the UTC instant the active-state
|
||||
// last flipped to TRUE. Mapped to OriginalRaiseTime; absent on non-AlarmCondition
|
||||
@@ -1089,16 +1090,16 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
// rather than the historical raise instant (OPC UA Part 9 §5.5.2 makes it advisory),
|
||||
// so a snapshot-derived OriginalRaiseTime can look like the refresh time — it is
|
||||
// display-only and not treated as authoritative.
|
||||
filter.SelectClauses.Add(SelectField(ObjectTypeIds.AlarmConditionType, "ActiveState", "TransitionTime")); // 13
|
||||
selectClauses.Add(SelectField(ObjectTypeIds.AlarmConditionType, "ActiveState", "TransitionTime")); // 13
|
||||
|
||||
// 14–17: LimitAlarmType limit thresholds — configuration-time set-points exposed as
|
||||
// event fields by LimitAlarmType and all its subtypes (Exclusive/NonExclusive
|
||||
// Level/Deviation/RateOfChange). Absent on non-limit alarm types (e.g. discrete,
|
||||
// off-normal) — guarded by fields.Count > N below.
|
||||
filter.SelectClauses.Add(SelectField(ObjectTypeIds.LimitAlarmType, "HighHighLimit")); // 14
|
||||
filter.SelectClauses.Add(SelectField(ObjectTypeIds.LimitAlarmType, "HighLimit")); // 15
|
||||
filter.SelectClauses.Add(SelectField(ObjectTypeIds.LimitAlarmType, "LowLimit")); // 16
|
||||
filter.SelectClauses.Add(SelectField(ObjectTypeIds.LimitAlarmType, "LowLowLimit")); // 17
|
||||
// off-normal) — guarded by fields.Length > N below.
|
||||
selectClauses.Add(SelectField(ObjectTypeIds.LimitAlarmType, "HighHighLimit")); // 14
|
||||
selectClauses.Add(SelectField(ObjectTypeIds.LimitAlarmType, "HighLimit")); // 15
|
||||
selectClauses.Add(SelectField(ObjectTypeIds.LimitAlarmType, "LowLimit")); // 16
|
||||
selectClauses.Add(SelectField(ObjectTypeIds.LimitAlarmType, "LowLowLimit")); // 17
|
||||
|
||||
// 18: AcknowledgeableConditionType/AckedState/TransitionTime — the UTC instant the
|
||||
// acked-state last flipped. When the condition is currently ACKED that instant IS
|
||||
@@ -1107,8 +1108,9 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
// base ConditionType events and on servers that do not expose it, in which case
|
||||
// OpcUaAlarmMapper.DeriveAckTime falls back to the event's own Time field. Same
|
||||
// ConditionRefresh caveat as index 13: a replayed snapshot may re-stamp it.
|
||||
filter.SelectClauses.Add(
|
||||
selectClauses.Add(
|
||||
SelectField(ObjectTypeIds.AcknowledgeableConditionType, "AckedState", "TransitionTime")); // 18
|
||||
filter.SelectClauses = selectClauses;
|
||||
|
||||
// UNAVAILABLE via standard OPC UA A&C event fields (documented here so future
|
||||
// maintainers know these were considered, not overlooked):
|
||||
@@ -1159,15 +1161,13 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
for (var i = 1; i < typeIds.Count; i++)
|
||||
{
|
||||
var next = where.Push(FilterOperator.OfType, typeIds[i]);
|
||||
element = where.Push(FilterOperator.Or, element, next);
|
||||
element = where.Push(FilterOperator.Or, Variant.FromStructure(element), Variant.FromStructure(next));
|
||||
}
|
||||
}
|
||||
|
||||
private static SimpleAttributeOperand SelectField(NodeId typeDefinitionId, params string[] browse)
|
||||
{
|
||||
var path = new QualifiedNameCollection();
|
||||
foreach (var b in browse)
|
||||
path.Add(new QualifiedName(b));
|
||||
var path = browse.Select(b => new QualifiedName(b)).ToArray();
|
||||
return new SimpleAttributeOperand
|
||||
{
|
||||
TypeDefinitionId = typeDefinitionId,
|
||||
@@ -1198,11 +1198,15 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
string handle, string? subscriptionSourceReference, EventFieldList efl,
|
||||
Action<NativeAlarmTransition> onTransition)
|
||||
{
|
||||
var fields = efl.EventFields;
|
||||
if (fields == null || fields.Count < AlarmStateFields.Length)
|
||||
// SDK 2: event fields are an ArrayOf<Variant>; unbox each one to its SDK 1.5 CLR shape once,
|
||||
// so every index check below reads the same values it did before ScadaBridge#125.
|
||||
if (efl.EventFields.IsNull || efl.EventFields.Count < AlarmStateFields.Length)
|
||||
return;
|
||||
var fields = new object?[efl.EventFields.Count];
|
||||
for (var i = 0; i < fields.Length; i++)
|
||||
fields[i] = OpcUaValueBoundary.ToClr(efl.EventFields[i]);
|
||||
|
||||
var eventType = fields[0].Value as NodeId;
|
||||
NodeId? eventType = fields[0] is NodeId et ? et : null;
|
||||
|
||||
// RefreshStart/End bracket the snapshot replay.
|
||||
if (eventType == ObjectTypeIds.RefreshStartEventType)
|
||||
@@ -1226,30 +1230,30 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
// event's name), so DataConnectionActor's NodeId-keyed routing matches — see
|
||||
// OpcUaAlarmMapper.BuildIdentity. SourceName seeds the readable per-condition
|
||||
// key; fall back to the SourceNode NodeId string only when it is absent.
|
||||
var sourceName = fields[2].Value as string;
|
||||
var sourceName = fields[2] as string;
|
||||
if (string.IsNullOrEmpty(sourceName))
|
||||
sourceName = (fields[1].Value as NodeId)?.ToString() ?? "";
|
||||
var conditionName = fields.Count > 11 ? fields[11].Value as string : null;
|
||||
sourceName = fields[1] is NodeId sourceNode ? sourceNode.ToString() ?? "" : "";
|
||||
var conditionName = fields.Length > 11 ? fields[11] as string : null;
|
||||
var (sourceRef, sourceObjectRef) =
|
||||
OpcUaAlarmMapper.BuildIdentity(subscriptionSourceReference, sourceName, conditionName);
|
||||
if (string.IsNullOrEmpty(sourceRef))
|
||||
return; // not a condition event we can key
|
||||
|
||||
var time = fields[3].Value is DateTime dt ? new DateTimeOffset(dt, TimeSpan.Zero) : DateTimeOffset.UtcNow;
|
||||
var message = (fields[4].Value as LocalizedText)?.Text ?? "";
|
||||
var severity = fields[5].Value is null ? 0 : Convert.ToInt32(fields[5].Value);
|
||||
var time = fields[3] is DateTime dt ? new DateTimeOffset(dt, TimeSpan.Zero) : DateTimeOffset.UtcNow;
|
||||
var message = TextOf(fields[4]) ?? "";
|
||||
var severity = fields[5] is null ? 0 : Convert.ToInt32(fields[5]);
|
||||
|
||||
var active = fields.Count > 6 && fields[6].Value is bool a && a;
|
||||
var acked = fields.Count <= 7 || fields[7].Value is not bool ak || ak; // default acked when absent
|
||||
bool? confirmed = fields.Count > 8 && fields[8].Value is bool cf ? cf : null;
|
||||
var suppressed = fields.Count > 9 && fields[9].Value is bool sp && sp;
|
||||
var shelve = OpcUaAlarmMapper.MapShelve(fields.Count > 10 ? (fields[10].Value as LocalizedText)?.Text : null);
|
||||
var comment = fields.Count > 12 ? (fields[12].Value as LocalizedText)?.Text ?? "" : "";
|
||||
var active = fields.Length > 6 && fields[6] is bool a && a;
|
||||
var acked = fields.Length <= 7 || fields[7] is not bool ak || ak; // default acked when absent
|
||||
bool? confirmed = fields.Length > 8 && fields[8] is bool cf ? cf : null;
|
||||
var suppressed = fields.Length > 9 && fields[9] is bool sp && sp;
|
||||
var shelve = OpcUaAlarmMapper.MapShelve(fields.Length > 10 ? TextOf(fields[10]) : null);
|
||||
var comment = fields.Length > 12 ? TextOf(fields[12]) ?? "" : "";
|
||||
|
||||
// Index 13: ActiveState/TransitionTime → OriginalRaiseTime (when active-state last
|
||||
// transitioned to TRUE). Absent on non-AlarmCondition events → guard + null fallback.
|
||||
DateTimeOffset? originalRaiseTime = null;
|
||||
if (fields.Count > 13 && fields[13].Value is DateTime activeTransitionTime)
|
||||
if (fields.Length > 13 && fields[13] is DateTime activeTransitionTime)
|
||||
// OPC UA mandates UTC for DateTime fields; a TimeSpan.Zero offset treats an
|
||||
// Unspecified Kind as UTC (consistent with the Time→TransitionTime mapping above).
|
||||
originalRaiseTime = new DateTimeOffset(activeTransitionTime, TimeSpan.Zero);
|
||||
@@ -1260,16 +1264,16 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
// display-only representative limit; the caller is responsible for interpreting
|
||||
// which limit is active using AlarmTypeName or ConditionName.
|
||||
var limitValue = OpcUaAlarmMapper.PickLimitValue(
|
||||
fields.Count > 14 ? fields[14].Value : null,
|
||||
fields.Count > 15 ? fields[15].Value : null,
|
||||
fields.Count > 16 ? fields[16].Value : null,
|
||||
fields.Count > 17 ? fields[17].Value : null);
|
||||
fields.Length > 14 ? fields[14] : null,
|
||||
fields.Length > 15 ? fields[15] : null,
|
||||
fields.Length > 16 ? fields[16] : null,
|
||||
fields.Length > 17 ? fields[17] : null);
|
||||
|
||||
// Index 18: AckedState/TransitionTime → the true source ack instant when the condition
|
||||
// is currently acked. Absent on non-acknowledgeable events / servers that omit it →
|
||||
// guard + null fallback, which DeriveAckTime resolves to the event time below.
|
||||
DateTimeOffset? sourceAckTime = null;
|
||||
if (fields.Count > 18 && fields[18].Value is DateTime ackTransitionTime)
|
||||
if (fields.Length > 18 && fields[18] is DateTime ackTransitionTime)
|
||||
// OPC UA mandates UTC for DateTime fields; TimeSpan.Zero treats an Unspecified
|
||||
// Kind as UTC (consistent with the Time and ActiveState/TransitionTime mappings).
|
||||
sourceAckTime = new DateTimeOffset(ackTransitionTime, TimeSpan.Zero);
|
||||
@@ -1320,6 +1324,9 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
SnapshotId: inRefresh && _alarmSnapshotIds.TryGetValue(handle, out var epoch) ? epoch : null));
|
||||
}
|
||||
|
||||
/// <summary>The text of a LocalizedText event field (a struct in SDK 2), or null.</summary>
|
||||
private static string? TextOf(object? field) => field is LocalizedText text ? text.Text : null;
|
||||
|
||||
private static NativeAlarmTransition SnapshotBoundary(AlarmTransitionKind kind, string? source, Guid epoch) => new(
|
||||
"", source ?? string.Empty, "", kind,
|
||||
new Commons.Types.Alarms.AlarmConditionState(false, true, null, AlarmShelveState.Unshelved, false, 0),
|
||||
@@ -1369,31 +1376,33 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
outcomes[i] = new OpcUaReadOutcome(nodeIds[i], null, DateTime.UtcNow, StatusCodes.BadNodeIdUnknown, ex.Message);
|
||||
outcomes[i] = new OpcUaReadOutcome(nodeIds[i], null, DateTime.UtcNow, StatusCodes.BadNodeIdUnknown.Code, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
for (var offset = 0; offset < resolvable.Count; offset += MaxNodesPerServiceCall)
|
||||
{
|
||||
var chunk = resolvable.GetRange(offset, Math.Min(MaxNodesPerServiceCall, resolvable.Count - offset));
|
||||
var collection = new ReadValueIdCollection(chunk.Select(c => c.Read));
|
||||
ArrayOf<ReadValueId> collection = chunk.Select(c => c.Read).ToArray();
|
||||
var response = await session.ReadAsync(
|
||||
null, 0, MapTimestampsToReturn(_options.TimestampsToReturn), collection, cancellationToken);
|
||||
|
||||
var results = response.Results;
|
||||
for (var i = 0; i < chunk.Count; i++)
|
||||
{
|
||||
var (index, nodeId, _) = chunk[i];
|
||||
if (response.Results is { Count: > 0 } results && i < results.Count)
|
||||
if (!results.IsNull && i < results.Count)
|
||||
{
|
||||
var result = results[i];
|
||||
outcomes[index] = new OpcUaReadOutcome(
|
||||
nodeId, result.Value, result.SourceTimestamp, result.StatusCode.Code, null);
|
||||
nodeId, OpcUaValueBoundary.ToClr(result.WrappedValue),
|
||||
OpcUaValueBoundary.ToClrTimestamp(result.SourceTimestamp), result.StatusCode.Code, null);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Non-conformant server: fewer results than requested nodes.
|
||||
outcomes[index] = new OpcUaReadOutcome(
|
||||
nodeId, null, DateTime.UtcNow, StatusCodes.BadUnexpectedError,
|
||||
nodeId, null, DateTime.UtcNow, StatusCodes.BadUnexpectedError.Code,
|
||||
"OPC UA read returned no result for this node.");
|
||||
}
|
||||
}
|
||||
@@ -1431,7 +1440,7 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
outcomes[i] = new OpcUaWriteOutcome(values[i].NodeId, StatusCodes.BadNodeIdUnknown, ex.Message);
|
||||
outcomes[i] = new OpcUaWriteOutcome(values[i].NodeId, StatusCodes.BadNodeIdUnknown.Code, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1451,7 +1460,7 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
var coerced = OpcUaWriteCoercion.Coerce(value, builtInType);
|
||||
if (!coerced.Success)
|
||||
{
|
||||
outcomes[i] = new OpcUaWriteOutcome(values[i].NodeId, StatusCodes.BadTypeMismatch, coerced.Error);
|
||||
outcomes[i] = new OpcUaWriteOutcome(values[i].NodeId, StatusCodes.BadTypeMismatch.Code, coerced.Error);
|
||||
continue;
|
||||
}
|
||||
value = coerced.Value;
|
||||
@@ -1463,32 +1472,33 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
{
|
||||
NodeId = nodeId,
|
||||
AttributeId = Attributes.Value,
|
||||
Value = new DataValue(new Variant(value))
|
||||
Value = new DataValue(OpcUaValueBoundary.ToVariant(value))
|
||||
}));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
outcomes[i] = new OpcUaWriteOutcome(values[i].NodeId, StatusCodes.BadTypeMismatch, ex.Message);
|
||||
outcomes[i] = new OpcUaWriteOutcome(values[i].NodeId, StatusCodes.BadTypeMismatch.Code, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
for (var offset = 0; offset < resolvable.Count; offset += MaxNodesPerServiceCall)
|
||||
{
|
||||
var chunk = resolvable.GetRange(offset, Math.Min(MaxNodesPerServiceCall, resolvable.Count - offset));
|
||||
var collection = new WriteValueCollection(chunk.Select(c => c.Write));
|
||||
ArrayOf<WriteValue> collection = chunk.Select(c => c.Write).ToArray();
|
||||
var response = await session.WriteAsync(null, collection, cancellationToken);
|
||||
|
||||
var results = response.Results;
|
||||
for (var i = 0; i < chunk.Count; i++)
|
||||
{
|
||||
var (index, nodeId, _) = chunk[i];
|
||||
if (response.Results is { Count: > 0 } results && i < results.Count)
|
||||
if (!results.IsNull && i < results.Count)
|
||||
{
|
||||
outcomes[index] = new OpcUaWriteOutcome(nodeId, results[i].Code, null);
|
||||
}
|
||||
else
|
||||
{
|
||||
outcomes[index] = new OpcUaWriteOutcome(
|
||||
nodeId, StatusCodes.BadUnexpectedError,
|
||||
nodeId, StatusCodes.BadUnexpectedError.Code,
|
||||
"OPC UA write returned no result for this node.");
|
||||
}
|
||||
}
|
||||
@@ -1513,16 +1523,18 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
var chunk = missing.GetRange(offset, Math.Min(MaxNodesPerServiceCall, missing.Count - offset));
|
||||
try
|
||||
{
|
||||
var readIds = new ReadValueIdCollection(
|
||||
chunk.Select(n => new ReadValueId { NodeId = n, AttributeId = Attributes.DataType }));
|
||||
ArrayOf<ReadValueId> readIds = chunk
|
||||
.Select(n => new ReadValueId { NodeId = n, AttributeId = Attributes.DataType }).ToArray();
|
||||
var response = await session.ReadAsync(
|
||||
null, 0, TimestampsToReturn.Neither, readIds, cancellationToken).ConfigureAwait(false);
|
||||
if (response.Results is not { } results || results.Count != chunk.Count)
|
||||
var results = response.Results;
|
||||
if (results.IsNull || results.Count != chunk.Count)
|
||||
continue;
|
||||
|
||||
for (var i = 0; i < chunk.Count; i++)
|
||||
{
|
||||
if (!StatusCode.IsGood(results[i].StatusCode) || results[i].Value is not NodeId dataTypeId)
|
||||
if (!StatusCode.IsGood(results[i].StatusCode)
|
||||
|| OpcUaValueBoundary.ToClr(results[i].WrappedValue) is not NodeId dataTypeId)
|
||||
continue;
|
||||
|
||||
var builtIn = TypeInfo.GetBuiltInType(dataTypeId);
|
||||
@@ -1574,20 +1586,8 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
await DisconnectAsync();
|
||||
}
|
||||
|
||||
private static UserIdentity? BuildUserIdentity(OpcUaUserIdentityOptions? options)
|
||||
{
|
||||
if (options is null) return null;
|
||||
return options.TokenType.ToUpperInvariant() switch
|
||||
{
|
||||
"USERNAMEPASSWORD" => new UserIdentity(
|
||||
options.Username,
|
||||
System.Text.Encoding.UTF8.GetBytes(options.Password ?? "")),
|
||||
"X509CERTIFICATE" => new UserIdentity(
|
||||
X509CertificateLoader.LoadPkcs12FromFile(
|
||||
options.CertificatePath, options.CertificatePassword)),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
private static UserIdentity? BuildUserIdentity(OpcUaUserIdentityOptions? options) =>
|
||||
OpcUaUserIdentities.Build(options);
|
||||
|
||||
private static MonitoringFilter? BuildDataChangeFilter(OpcUaDeadbandOptions? deadband)
|
||||
{
|
||||
@@ -1666,12 +1666,12 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
// can be fetched; we pass back exactly the one point we were handed.
|
||||
var (_, revisedPoints, results, _) = await session.BrowseNextAsync(
|
||||
null,
|
||||
new ByteStringCollection { Convert.FromBase64String(continuationToken) },
|
||||
new[] { ByteString.From(Convert.FromBase64String(continuationToken)) },
|
||||
false,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var nextPoint = revisedPoints is { Count: > 0 } ? revisedPoints[0] : null;
|
||||
var refs = results is { Count: > 0 } ? results[0] : null;
|
||||
var nextPoint = !revisedPoints.IsNull && revisedPoints.Count > 0 ? revisedPoints[0] : default;
|
||||
var refs = results is { Count: > 0 } ? results[0] : default;
|
||||
return await BuildResultAsync(session, refs, nextPoint, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (ServiceResultException ex) when (
|
||||
@@ -1743,11 +1743,11 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
/// </summary>
|
||||
private async Task<Commons.Interfaces.Protocol.BrowseChildrenResult> BuildResultAsync(
|
||||
ISession session,
|
||||
ReferenceDescriptionCollection? references,
|
||||
byte[]? continuationPoint,
|
||||
ArrayOf<ReferenceDescription> references,
|
||||
ByteString continuationPoint,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var refs = references ?? new ReferenceDescriptionCollection();
|
||||
var refs = references.IsNull ? [] : references.ToList();
|
||||
var children = new List<Commons.Interfaces.Protocol.BrowseNode>(refs.Count);
|
||||
foreach (var r in refs)
|
||||
{
|
||||
@@ -1757,7 +1757,7 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
// read back, and a bare ns= index rots the moment the server's
|
||||
// namespace table changes. What the picker shows is what gets stored.
|
||||
NodeId: OpcUaNodeReference.ToDurable(r.NodeId, session.NamespaceUris),
|
||||
DisplayName: r.DisplayName?.Text ?? r.BrowseName?.Name ?? "(unnamed)",
|
||||
DisplayName: r.DisplayName.Text ?? r.BrowseName.Name ?? "(unnamed)",
|
||||
NodeClass: MapNodeClass(r.NodeClass),
|
||||
HasChildren: r.NodeClass == NodeClass.Object));
|
||||
}
|
||||
@@ -1775,8 +1775,8 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
// A non-empty continuation point means the server has more refs than
|
||||
// were returned on this page. Surface it as an opaque Base64 token the
|
||||
// caller passes back to fetch the next page via BrowseNext.
|
||||
var hasMore = continuationPoint != null && continuationPoint.Length > 0;
|
||||
var token = hasMore ? Convert.ToBase64String(continuationPoint!) : null;
|
||||
var hasMore = !continuationPoint.IsNull && !continuationPoint.IsEmpty;
|
||||
var token = hasMore ? Convert.ToBase64String(continuationPoint.ToArray()) : null;
|
||||
return new Commons.Interfaces.Protocol.BrowseChildrenResult(children, hasMore, token);
|
||||
}
|
||||
|
||||
@@ -1795,7 +1795,7 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
// BrowseNode list, so a swallowed failure simply means no type columns.
|
||||
private async Task<List<Commons.Interfaces.Protocol.BrowseNode>> EnrichVariableTypeInfoAsync(
|
||||
ISession session,
|
||||
ReferenceDescriptionCollection refs,
|
||||
List<ReferenceDescription> refs,
|
||||
List<Commons.Interfaces.Protocol.BrowseNode> children,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -1804,7 +1804,7 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
// Build a flat read of (DataType, ValueRank, UserAccessLevel) per
|
||||
// Variable child, remembering which children-list slot each triple
|
||||
// maps back to so the read values can be re-stitched in order.
|
||||
var readIds = new ReadValueIdCollection();
|
||||
var readIds = new List<ReadValueId>();
|
||||
var variableSlots = new List<int>();
|
||||
for (var i = 0; i < refs.Count; i++)
|
||||
{
|
||||
@@ -1814,7 +1814,7 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
}
|
||||
|
||||
var nodeId = ExpandedNodeId.ToNodeId(refs[i].NodeId, session.NamespaceUris);
|
||||
if (nodeId is null)
|
||||
if (nodeId.IsNull)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -1838,7 +1838,7 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var results = readResponse.Results;
|
||||
if (results is null || results.Count != readIds.Count)
|
||||
if (results.IsNull || results.Count != readIds.Count)
|
||||
{
|
||||
// Defensive: a non-conformant server. Skip enrichment entirely
|
||||
// rather than risk mis-aligning values to the wrong nodes.
|
||||
@@ -1853,19 +1853,22 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
var accessLevelResult = results[baseIdx + 2];
|
||||
|
||||
string? dataType = null;
|
||||
if (StatusCode.IsGood(dataTypeResult.StatusCode) && dataTypeResult.Value is NodeId dtNodeId)
|
||||
if (StatusCode.IsGood(dataTypeResult.StatusCode)
|
||||
&& OpcUaValueBoundary.ToClr(dataTypeResult.WrappedValue) is NodeId dtNodeId)
|
||||
{
|
||||
dataType = OpcUaBuiltInTypeNames.Resolve(dtNodeId);
|
||||
}
|
||||
|
||||
int? valueRank = null;
|
||||
if (StatusCode.IsGood(valueRankResult.StatusCode) && valueRankResult.Value is int vr)
|
||||
if (StatusCode.IsGood(valueRankResult.StatusCode)
|
||||
&& OpcUaValueBoundary.ToClr(valueRankResult.WrappedValue) is int vr)
|
||||
{
|
||||
valueRank = vr;
|
||||
}
|
||||
|
||||
bool? writable = null;
|
||||
if (StatusCode.IsGood(accessLevelResult.StatusCode) && accessLevelResult.Value is byte accessLevel)
|
||||
if (StatusCode.IsGood(accessLevelResult.StatusCode)
|
||||
&& OpcUaValueBoundary.ToClr(accessLevelResult.WrappedValue) is byte accessLevel)
|
||||
{
|
||||
writable = ((AccessLevelType)accessLevel).HasFlag(AccessLevelType.CurrentWrite);
|
||||
}
|
||||
@@ -1949,14 +1952,14 @@ internal static class OpcUaBuiltInTypeNames
|
||||
/// <returns>Friendly name for built-ins; NodeId string for unknowns; empty string for null.</returns>
|
||||
public static string Resolve(NodeId? dataTypeNodeId)
|
||||
{
|
||||
if (dataTypeNodeId is null)
|
||||
if (dataTypeNodeId is not { } dataType || dataType.IsNull)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return Names.TryGetValue(dataTypeNodeId, out var name)
|
||||
return Names.TryGetValue(dataType, out var name)
|
||||
? name
|
||||
: dataTypeNodeId.ToString();
|
||||
: dataType.ToString() ?? string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user