Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 47b66caa8d | |||
| c6ce30121c | |||
| 62c3ba6c37 | |||
| b583840fbc |
+22
-19
@@ -17,7 +17,7 @@ internal sealed class OpcUaClientBrowseSession : IBrowseSession
|
|||||||
private readonly NodeId _rootNodeId;
|
private readonly NodeId _rootNodeId;
|
||||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||||
private volatile bool _disposed;
|
private volatile bool _disposed;
|
||||||
private readonly IDisposable? _ownedCredential;
|
private readonly IAsyncDisposable? _ownedApplication;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Construct a browse session bound to an already-connected <paramref name="session"/>.
|
/// Construct a browse session bound to an already-connected <paramref name="session"/>.
|
||||||
@@ -26,10 +26,10 @@ internal sealed class OpcUaClientBrowseSession : IBrowseSession
|
|||||||
/// <param name="nsMap">Namespace snapshot taken at connect time; used to render outbound
|
/// <param name="nsMap">Namespace snapshot taken at connect time; used to render outbound
|
||||||
/// NodeIds in the server-stable <c>nsu=…</c> form.</param>
|
/// NodeIds in the server-stable <c>nsu=…</c> form.</param>
|
||||||
/// <param name="rootNodeId">The node under which <see cref="RootAsync"/> browses one level.</param>
|
/// <param name="rootNodeId">The node under which <see cref="RootAsync"/> browses one level.</param>
|
||||||
/// <param name="ownedCredential">A credential (the user certificate) this session owns and disposes with itself.</param>
|
/// <param name="ownedApplication">The SDK application whose certificate manager this session owns.</param>
|
||||||
internal OpcUaClientBrowseSession(ISession session, NamespaceMap nsMap, NodeId rootNodeId, IDisposable? ownedCredential = null)
|
internal OpcUaClientBrowseSession(ISession session, NamespaceMap nsMap, NodeId rootNodeId, IAsyncDisposable? ownedApplication = null)
|
||||||
{
|
{
|
||||||
_ownedCredential = ownedCredential;
|
_ownedApplication = ownedApplication;
|
||||||
_session = session;
|
_session = session;
|
||||||
_nsMap = nsMap;
|
_nsMap = nsMap;
|
||||||
_rootNodeId = rootNodeId;
|
_rootNodeId = rootNodeId;
|
||||||
@@ -73,10 +73,10 @@ internal sealed class OpcUaClientBrowseSession : IBrowseSession
|
|||||||
Attributes.NodeClass, Attributes.DisplayName, Attributes.DataType, Attributes.ValueRank,
|
Attributes.NodeClass, Attributes.DisplayName, Attributes.DataType, Attributes.ValueRank,
|
||||||
Attributes.UserAccessLevel,
|
Attributes.UserAccessLevel,
|
||||||
];
|
];
|
||||||
var toRead = new ReadValueIdCollection(attributeIds.Length);
|
var toRead = new List<ReadValueId>(attributeIds.Length);
|
||||||
foreach (var id in attributeIds) toRead.Add(new ReadValueId { NodeId = resolved, AttributeId = id });
|
foreach (var id in attributeIds) toRead.Add(new ReadValueId { NodeId = resolved, AttributeId = id });
|
||||||
|
|
||||||
DataValueCollection values;
|
ArrayOf<DataValue> values;
|
||||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -97,15 +97,15 @@ internal sealed class OpcUaClientBrowseSession : IBrowseSession
|
|||||||
DataValue? At(int i) => i < values.Count && StatusCode.IsGood(values[i].StatusCode)
|
DataValue? At(int i) => i < values.Count && StatusCode.IsGood(values[i].StatusCode)
|
||||||
? values[i] : null;
|
? values[i] : null;
|
||||||
|
|
||||||
var nodeClass = At(0)?.Value is int nc ? (NodeClass)nc : NodeClass.Unspecified;
|
var nodeClass = At(0)?.WrappedValue.AsBoxedObject() is int nc ? (NodeClass)nc : NodeClass.Unspecified;
|
||||||
if (nodeClass != NodeClass.Variable) return Array.Empty<AttributeInfo>();
|
if (nodeClass != NodeClass.Variable) return Array.Empty<AttributeInfo>();
|
||||||
|
|
||||||
var name = (At(1)?.Value as LocalizedText)?.Text ?? resolved.ToString() ?? nodeId;
|
var name = (At(1)?.WrappedValue.AsBoxedObject() as LocalizedText?)?.Text ?? resolved.ToString() ?? nodeId;
|
||||||
var dataType = At(2)?.Value is NodeId dt
|
var dataType = At(2)?.WrappedValue.AsBoxedObject() is NodeId dt
|
||||||
? UpstreamAttributeMapping.MapDataType(dt)
|
? UpstreamAttributeMapping.MapDataType(dt)
|
||||||
: ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.String;
|
: ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.String;
|
||||||
var valueRank = At(3)?.Value is int vr ? vr : ValueRanks.Scalar;
|
var valueRank = At(3)?.WrappedValue.AsBoxedObject() is int vr ? vr : ValueRanks.Scalar;
|
||||||
var access = At(4)?.Value is byte ab ? ab : (byte)0;
|
var access = At(4)?.WrappedValue.AsBoxedObject() is byte ab ? ab : (byte)0;
|
||||||
|
|
||||||
return
|
return
|
||||||
[
|
[
|
||||||
@@ -126,7 +126,7 @@ internal sealed class OpcUaClientBrowseSession : IBrowseSession
|
|||||||
await _gate.WaitAsync(ct).ConfigureAwait(false);
|
await _gate.WaitAsync(ct).ConfigureAwait(false);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var descriptions = new BrowseDescriptionCollection
|
var descriptions = new List<BrowseDescription>
|
||||||
{
|
{
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
@@ -154,7 +154,7 @@ internal sealed class OpcUaClientBrowseSession : IBrowseSession
|
|||||||
}
|
}
|
||||||
|
|
||||||
var result = resp.Results[0];
|
var result = resp.Results[0];
|
||||||
var refs = result.References;
|
var refs = result.References.ToList();
|
||||||
|
|
||||||
// Follow browse continuation points so folders larger than the server's per-call
|
// Follow browse continuation points so folders larger than the server's per-call
|
||||||
// cap aren't silently truncated.
|
// cap aren't silently truncated.
|
||||||
@@ -177,11 +177,12 @@ internal sealed class OpcUaClientBrowseSession : IBrowseSession
|
|||||||
// Best-effort — mirrors OpcUaClientDriver's pagination loop.
|
// Best-effort — mirrors OpcUaClientDriver's pagination loop.
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
using var release = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||||
await _session.BrowseNextAsync(
|
await _session.BrowseNextAsync(
|
||||||
requestHeader: null,
|
requestHeader: null,
|
||||||
releaseContinuationPoints: true,
|
releaseContinuationPoints: true,
|
||||||
continuationPoints: [cp],
|
continuationPoints: [cp],
|
||||||
ct: CancellationToken.None).ConfigureAwait(false);
|
ct: release.Token).AsTask().WaitAsync(release.Token).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
catch { /* best-effort — server may have already cleaned up */ }
|
catch { /* best-effort — server may have already cleaned up */ }
|
||||||
throw;
|
throw;
|
||||||
@@ -190,7 +191,7 @@ internal sealed class OpcUaClientBrowseSession : IBrowseSession
|
|||||||
if (next.Results.Count == 0) break;
|
if (next.Results.Count == 0) break;
|
||||||
var nextResult = next.Results[0];
|
var nextResult = next.Results[0];
|
||||||
if (nextResult.References is { Count: > 0 })
|
if (nextResult.References is { Count: > 0 })
|
||||||
refs.AddRange(nextResult.References);
|
refs.AddRange(nextResult.References.ToList());
|
||||||
cp = nextResult.ContinuationPoint;
|
cp = nextResult.ContinuationPoint;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,8 +215,8 @@ internal sealed class OpcUaClientBrowseSession : IBrowseSession
|
|||||||
{
|
{
|
||||||
var childId = ExpandedNodeId.ToNodeId(rf.NodeId, _session.NamespaceUris);
|
var childId = ExpandedNodeId.ToNodeId(rf.NodeId, _session.NamespaceUris);
|
||||||
var isObject = rf.NodeClass == NodeClass.Object;
|
var isObject = rf.NodeClass == NodeClass.Object;
|
||||||
var displayName = rf.DisplayName?.Text
|
var displayName = rf.DisplayName.Text
|
||||||
?? rf.BrowseName?.Name
|
?? rf.BrowseName.Name
|
||||||
?? childId.ToString()
|
?? childId.ToString()
|
||||||
?? "(unnamed)";
|
?? "(unnamed)";
|
||||||
return new BrowseNode(
|
return new BrowseNode(
|
||||||
@@ -246,7 +247,9 @@ internal sealed class OpcUaClientBrowseSession : IBrowseSession
|
|||||||
try { _gate.Dispose(); }
|
try { _gate.Dispose(); }
|
||||||
catch { /* best-effort */ }
|
catch { /* best-effort */ }
|
||||||
|
|
||||||
try { _ownedCredential?.Dispose(); }
|
if (_ownedApplication is not null)
|
||||||
catch { /* best-effort */ }
|
{
|
||||||
|
try { await _ownedApplication.DisposeAsync().ConfigureAwait(false); } catch { /* best-effort */ }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-36
@@ -69,9 +69,10 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser
|
|||||||
// R06:O-5 — Certificate auth is supported: the identity is built (and any secret: credential resolved) by the
|
// R06:O-5 — Certificate auth is supported: the identity is built (and any secret: credential resolved) by the
|
||||||
// same factory the runtime driver uses, BEFORE any network I/O, so a bad certificate path / password fails fast
|
// same factory the runtime driver uses, BEFORE any network I/O, so a bad certificate path / password fails fast
|
||||||
// with its own message.
|
// with its own message.
|
||||||
var (identity, ownedCertificate) = await BuildBrowseCredentialAsync(opts, _secretResolver, cancellationToken)
|
var identity = await BuildBrowseUserIdentityAsync(opts, _secretResolver, cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
ApplicationInstance? ownedApplication = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (opts.AutoAcceptCertificates)
|
if (opts.AutoAcceptCertificates)
|
||||||
@@ -81,7 +82,9 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser
|
|||||||
"driver's PKI store instead.",
|
"driver's PKI store instead.",
|
||||||
endpoint);
|
endpoint);
|
||||||
|
|
||||||
var appConfig = await BuildBrowseAppConfigurationAsync(cancellationToken).ConfigureAwait(false);
|
var app = await BuildBrowseAppConfigurationAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
ownedApplication = app;
|
||||||
|
var appConfig = app.ApplicationConfiguration!;
|
||||||
|
|
||||||
var perEndpointBudget = TimeSpan.FromSeconds(
|
var perEndpointBudget = TimeSpan.FromSeconds(
|
||||||
Math.Clamp(opts.PerEndpointConnectTimeout.TotalSeconds, 5, 30));
|
Math.Clamp(opts.PerEndpointConnectTimeout.TotalSeconds, 5, 30));
|
||||||
@@ -101,11 +104,12 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser
|
|||||||
sessionName: "OtOpcUa AdminUI Browse",
|
sessionName: "OtOpcUa AdminUI Browse",
|
||||||
(uint)opts.SessionTimeout.TotalMilliseconds,
|
(uint)opts.SessionTimeout.TotalMilliseconds,
|
||||||
identity,
|
identity,
|
||||||
preferredLocales: null,
|
preferredLocales: default,
|
||||||
connectCts.Token).ConfigureAwait(false);
|
connectCts.Token).ConfigureAwait(false);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
connectCts.Token.ThrowIfCancellationRequested();
|
||||||
var nsMap = NamespaceMap.FromSession(session);
|
var nsMap = NamespaceMap.FromSession(session);
|
||||||
var rootNodeId = string.IsNullOrEmpty(opts.BrowseRoot)
|
var rootNodeId = string.IsNullOrEmpty(opts.BrowseRoot)
|
||||||
? ObjectIds.ObjectsFolder
|
? ObjectIds.ObjectsFolder
|
||||||
@@ -115,7 +119,9 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser
|
|||||||
"AdminUI OPC UA browse session opened against {Endpoint} (policy {Policy}, mode {Mode})",
|
"AdminUI OPC UA browse session opened against {Endpoint} (policy {Policy}, mode {Mode})",
|
||||||
endpoint, opts.SecurityPolicy, opts.SecurityMode);
|
endpoint, opts.SecurityPolicy, opts.SecurityMode);
|
||||||
|
|
||||||
return new OpcUaClientBrowseSession(session, nsMap, rootNodeId, ownedCertificate);
|
var browse = new OpcUaClientBrowseSession(session, nsMap, rootNodeId, ownedApplication);
|
||||||
|
ownedApplication = null;
|
||||||
|
return browse;
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -126,9 +132,8 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser
|
|||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
// Review m9: the certificate (and its private-key handle) is owned by the browse session once handed
|
// The SDK application owns its certificate manager until the browse session takes it.
|
||||||
// over; any failure before that releases it here instead of leaving it to the GC.
|
if (ownedApplication is not null) await ownedApplication.DisposeAsync().ConfigureAwait(false);
|
||||||
ownedCertificate?.Dispose();
|
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -139,7 +144,7 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser
|
|||||||
/// trust decisions don't leak into the deployed driver's cert store.
|
/// trust decisions don't leak into the deployed driver's cert store.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="ct">Cancellation token for the configuration build.</param>
|
/// <param name="ct">Cancellation token for the configuration build.</param>
|
||||||
private static async Task<ApplicationConfiguration> BuildBrowseAppConfigurationAsync(CancellationToken ct)
|
private static async Task<ApplicationInstance> BuildBrowseAppConfigurationAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
// The ITelemetryContext overload is the non-obsolete ctor; an empty builder keeps SDK
|
// The ITelemetryContext overload is the non-obsolete ctor; an empty builder keeps SDK
|
||||||
// internals off any logging provider.
|
// internals off any logging provider.
|
||||||
@@ -153,7 +158,7 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser
|
|||||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
"OtOpcUa", "adminui-browse-pki");
|
"OtOpcUa", "adminui-browse-pki");
|
||||||
|
|
||||||
var config = new ApplicationConfiguration
|
var config = new ApplicationConfiguration(BrowseTelemetry.Context)
|
||||||
{
|
{
|
||||||
ApplicationName = "OtOpcUa AdminUI Browse",
|
ApplicationName = "OtOpcUa AdminUI Browse",
|
||||||
ApplicationType = ApplicationType.Client,
|
ApplicationType = ApplicationType.Client,
|
||||||
@@ -187,11 +192,19 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser
|
|||||||
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
|
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
|
||||||
DisableHiResClock = true,
|
DisableHiResClock = true,
|
||||||
};
|
};
|
||||||
await config.ValidateAsync(ApplicationType.Client, ct).ConfigureAwait(false);
|
|
||||||
app.ApplicationConfiguration = config;
|
app.ApplicationConfiguration = config;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await config.ValidateAsync(ApplicationType.Client, ct).ConfigureAwait(false);
|
||||||
await app.CheckApplicationInstanceCertificatesAsync(silent: true, lifeTimeInMonths: null, ct)
|
await app.CheckApplicationInstanceCertificatesAsync(silent: true, lifeTimeInMonths: null, ct)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
return config;
|
return app;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await app.DisposeAsync().ConfigureAwait(false);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -203,29 +216,11 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser
|
|||||||
/// <param name="ct">Cancellation token for the secret resolution.</param>
|
/// <param name="ct">Cancellation token for the secret resolution.</param>
|
||||||
/// <returns>The identity the browse session activates with.</returns>
|
/// <returns>The identity the browse session activates with.</returns>
|
||||||
internal static async Task<UserIdentity> BuildBrowseUserIdentityAsync(
|
internal static async Task<UserIdentity> BuildBrowseUserIdentityAsync(
|
||||||
OpcUaClientDriverOptions opts, ISecretResolver? secretResolver, CancellationToken ct) =>
|
OpcUaClientDriverOptions opts, ISecretResolver? secretResolver, CancellationToken ct)
|
||||||
(await BuildBrowseCredentialAsync(opts, secretResolver, ct).ConfigureAwait(false)).Identity;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// As <see cref="BuildBrowseUserIdentityAsync"/>, also handing back the user certificate a Certificate-auth
|
|
||||||
/// identity holds, so the browse session that uses it can dispose it (review m9: the browse opens one per picker
|
|
||||||
/// session, and an undisposed <c>X509Certificate2</c> pins a private-key handle until GC).
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="opts">Driver options carrying the form's auth fields.</param>
|
|
||||||
/// <param name="secretResolver">The secret store, or null when none is available.</param>
|
|
||||||
/// <param name="ct">Cancellation token for the secret resolution.</param>
|
|
||||||
/// <returns>The identity, and the certificate it owns (null for Anonymous / Username).</returns>
|
|
||||||
internal static async Task<(UserIdentity Identity, System.Security.Cryptography.X509Certificates.X509Certificate2? Certificate)>
|
|
||||||
BuildBrowseCredentialAsync(OpcUaClientDriverOptions opts, ISecretResolver? secretResolver, CancellationToken ct)
|
|
||||||
{
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
var resolved = await OpcUaUserIdentityFactory.ResolveSecretRefsAsync(opts, secretResolver, ct).ConfigureAwait(false);
|
var resolved = await OpcUaUserIdentityFactory.ResolveSecretRefsAsync(opts, secretResolver, ct).ConfigureAwait(false);
|
||||||
if (resolved.AuthType != OpcUaAuthType.Certificate)
|
return OpcUaUserIdentityFactory.Build(resolved);
|
||||||
{
|
|
||||||
return (OpcUaUserIdentityFactory.Build(resolved), null);
|
|
||||||
}
|
|
||||||
|
|
||||||
var certificate = OpcUaUserIdentityFactory.LoadCertificate(resolved);
|
|
||||||
return (new UserIdentity(certificate), certificate);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Select the endpoint matching the requested SecurityPolicy + SecurityMode pair.</summary>
|
/// <summary>Select the endpoint matching the requested SecurityPolicy + SecurityMode pair.</summary>
|
||||||
@@ -240,7 +235,7 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser
|
|||||||
{
|
{
|
||||||
using var client = await DiscoveryClient.CreateAsync(
|
using var client = await DiscoveryClient.CreateAsync(
|
||||||
appConfig, new Uri(url), DiagnosticsMasks.None, ct).ConfigureAwait(false);
|
appConfig, new Uri(url), DiagnosticsMasks.None, ct).ConfigureAwait(false);
|
||||||
var all = await client.GetEndpointsAsync(null, ct).ConfigureAwait(false);
|
var all = await client.GetEndpointsAsync(default, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
var wantedPolicy = MapPolicy(policy);
|
var wantedPolicy = MapPolicy(policy);
|
||||||
var wantedMode = mode switch
|
var wantedMode = mode switch
|
||||||
@@ -250,11 +245,11 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser
|
|||||||
OpcUaSecurityMode.SignAndEncrypt => MessageSecurityMode.SignAndEncrypt,
|
OpcUaSecurityMode.SignAndEncrypt => MessageSecurityMode.SignAndEncrypt,
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(mode)),
|
_ => throw new ArgumentOutOfRangeException(nameof(mode)),
|
||||||
};
|
};
|
||||||
var match = all.FirstOrDefault(e =>
|
var match = all.ToList().FirstOrDefault(e =>
|
||||||
e.SecurityPolicyUri == wantedPolicy && e.SecurityMode == wantedMode);
|
e.SecurityPolicyUri == wantedPolicy && e.SecurityMode == wantedMode);
|
||||||
if (match is null)
|
if (match is null)
|
||||||
{
|
{
|
||||||
var advertised = string.Join(", ", all.Select(e =>
|
var advertised = string.Join(", ", all.ToList().Select(e =>
|
||||||
$"{ShortName(e.SecurityPolicyUri)}/{e.SecurityMode}"));
|
$"{ShortName(e.SecurityPolicyUri)}/{e.SecurityMode}"));
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
$"No endpoint at '{url}' matches SecurityPolicy={policy} + SecurityMode={mode}. " +
|
$"No endpoint at '{url}' matches SecurityPolicy={policy} + SecurityMode={mode}. " +
|
||||||
@@ -278,7 +273,7 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser
|
|||||||
|
|
||||||
/// <summary>Render an OPC UA security-policy URI as its short suffix for diag messages.</summary>
|
/// <summary>Render an OPC UA security-policy URI as its short suffix for diag messages.</summary>
|
||||||
/// <param name="uri">Full policy URI to shorten.</param>
|
/// <param name="uri">Full policy URI to shorten.</param>
|
||||||
private static string ShortName(string uri) =>
|
private static string ShortName(string? uri) =>
|
||||||
uri?.Substring(uri.LastIndexOf('#') + 1) ?? "(null)";
|
uri?.Substring(uri.LastIndexOf('#') + 1) ?? "(null)";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
-1
@@ -13,7 +13,6 @@
|
|||||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session.csproj" />
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.MigrationAnalyzer" PrivateAssets="all" IncludeAssets="analyzers;build;buildtransitive" />
|
|
||||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" />
|
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" />
|
||||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Configuration" />
|
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Configuration" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||||
|
|||||||
@@ -196,7 +196,6 @@ public sealed class NamespaceMap
|
|||||||
/// <returns>The server-stable reference string for <paramref name="nodeId"/>.</returns>
|
/// <returns>The server-stable reference string for <paramref name="nodeId"/>.</returns>
|
||||||
public string ToStableReference(NodeId nodeId)
|
public string ToStableReference(NodeId nodeId)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(nodeId);
|
|
||||||
// Namespace 0 is fixed by the spec — the compact form is already stable.
|
// Namespace 0 is fixed by the spec — the compact form is already stable.
|
||||||
if (nodeId.NamespaceIndex == 0)
|
if (nodeId.NamespaceIndex == 0)
|
||||||
return nodeId.ToString() ?? string.Empty;
|
return nodeId.ToString() ?? string.Empty;
|
||||||
@@ -238,7 +237,7 @@ public sealed class NamespaceMap
|
|||||||
// reorder since the reference was captured resolves correctly. Plain ns=N forms
|
// reorder since the reference was captured resolves correctly. Plain ns=N forms
|
||||||
// resolve directly.
|
// resolve directly.
|
||||||
nodeId = NodeId.Parse(currentSession.MessageContext, reference);
|
nodeId = NodeId.Parse(currentSession.MessageContext, reference);
|
||||||
return !NodeId.IsNull(nodeId);
|
return !nodeId.IsNull;
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -249,10 +248,10 @@ public sealed class NamespaceMap
|
|||||||
/// <summary>Render just the identifier portion (<c>s=…</c>, <c>i=…</c>, <c>g=…</c>, <c>b=…</c>) of a NodeId.</summary>
|
/// <summary>Render just the identifier portion (<c>s=…</c>, <c>i=…</c>, <c>g=…</c>, <c>b=…</c>) of a NodeId.</summary>
|
||||||
private static string IdentifierPart(NodeId nodeId) => nodeId.IdType switch
|
private static string IdentifierPart(NodeId nodeId) => nodeId.IdType switch
|
||||||
{
|
{
|
||||||
IdType.Numeric => $"i={nodeId.Identifier}",
|
IdType.Numeric => $"i={nodeId.IdentifierAsString}",
|
||||||
IdType.String => $"s={nodeId.Identifier}",
|
IdType.String => $"s={nodeId.IdentifierAsString}",
|
||||||
IdType.Guid => $"g={nodeId.Identifier}",
|
IdType.Guid => $"g={nodeId.IdentifierAsString}",
|
||||||
IdType.Opaque => $"b={Convert.ToBase64String((byte[])nodeId.Identifier)}",
|
IdType.Opaque => $"b={nodeId.IdentifierAsString}",
|
||||||
_ => $"s={nodeId.Identifier}",
|
_ => throw new ArgumentOutOfRangeException(nameof(nodeId)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-1
@@ -1,4 +1,5 @@
|
|||||||
using Opc.Ua;
|
using Opc.Ua;
|
||||||
|
using Opc.Ua.Security.Certificates;
|
||||||
using ZB.MOM.WW.Secrets.Abstractions;
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||||
@@ -37,7 +38,37 @@ public static class OpcUaUserIdentityFactory
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="options">Driver options containing certificate configuration.</param>
|
/// <param name="options">Driver options containing certificate configuration.</param>
|
||||||
/// <returns>The certificate-backed <see cref="UserIdentity"/>.</returns>
|
/// <returns>The certificate-backed <see cref="UserIdentity"/>.</returns>
|
||||||
public static UserIdentity FromCertificate(OpcUaClientDriverOptions options) => new(LoadCertificate(options));
|
public static UserIdentity FromCertificate(OpcUaClientDriverOptions options)
|
||||||
|
{
|
||||||
|
using var certificate = LoadCertificate(options);
|
||||||
|
var identifier = new CertificateIdentifier { Thumbprint = certificate.Thumbprint };
|
||||||
|
return new UserIdentity(new X509IdentityTokenHandler(identifier,
|
||||||
|
new CertificatePasswordProvider(), new FileCertificateProvider(options, certificate.Thumbprint)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The configured credential is a PFX file, rather than an SDK certificate store. SDK 2 resolves
|
||||||
|
// signing keys on demand and disposes each returned Certificate. Pin the public identity for this
|
||||||
|
// lifecycle; a file/secret rotation is picked up only by the next initialise/open.
|
||||||
|
private sealed class FileCertificateProvider(OpcUaClientDriverOptions options, 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 = LoadCertificate(options);
|
||||||
|
if (!string.Equals(certificate.Thumbprint, thumbprint, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| !string.Equals(identifier.Thumbprint, thumbprint, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
certificate.Dispose();
|
||||||
|
throw new ServiceResultException(StatusCodes.BadIdentityTokenInvalid,
|
||||||
|
"The user certificate changed; reopen the session to use the rotated credential.");
|
||||||
|
}
|
||||||
|
return ValueTask.FromResult<Certificate?>(Certificate.From(certificate));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Loads (and validates) the user-authentication certificate <see cref="FromCertificate"/> wraps. The caller owns
|
/// Loads (and validates) the user-authentication certificate <see cref="FromCertificate"/> wraps. The caller owns
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using Opc.Ua;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||||
|
|
||||||
|
/// <summary>Detaches SDK values at the driver boundary while preserving deployed CLR payload types.</summary>
|
||||||
|
public static class UpstreamValueMapping
|
||||||
|
{
|
||||||
|
/// <summary>Materializes scalar and one-dimensional array values without flattening matrices.</summary>
|
||||||
|
/// <param name="value">The upstream value.</param>
|
||||||
|
/// <returns>The independent CLR payload.</returns>
|
||||||
|
public static object? ToClr(Variant value)
|
||||||
|
{
|
||||||
|
if (value.TypeInfo.IsMatrix)
|
||||||
|
throw new NotSupportedException("Matrix payloads are not supported by the OPC UA driver.");
|
||||||
|
return Detach(value.AsBoxedObject(Variant.BoxingBehavior.Legacy));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object? Detach(object? value) => value switch
|
||||||
|
{
|
||||||
|
ByteString bytes => bytes.IsNull ? null : bytes.ToArray(),
|
||||||
|
DateTimeUtc time => time.ToDateTime(),
|
||||||
|
Uuid uuid => (Guid)uuid,
|
||||||
|
ByteString[] bytes => bytes.Select(b => b.IsNull ? null : b.ToArray()).ToArray(),
|
||||||
|
DateTimeUtc[] times => times.Select(t => t.ToDateTime()).ToArray(),
|
||||||
|
Uuid[] uuids => uuids.Select(u => (Guid)u).ToArray(),
|
||||||
|
Variant[] variants => variants.Select(ToClr).ToArray(),
|
||||||
|
byte[][] bytes => bytes.Select(b => b?.ToArray()).ToArray(),
|
||||||
|
object?[] objects when objects.GetType() == typeof(object[]) => objects.Select(Detach).ToArray(),
|
||||||
|
Array array => array.Clone(),
|
||||||
|
_ => value,
|
||||||
|
};
|
||||||
|
}
|
||||||
-1
@@ -34,7 +34,6 @@
|
|||||||
ContractsIsDependencyLightTests in Driver.OpcUaClient.Tests.
|
ContractsIsDependencyLightTests in Driver.OpcUaClient.Tests.
|
||||||
-->
|
-->
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.MigrationAnalyzer" PrivateAssets="all" IncludeAssets="analyzers;build;buildtransitive" />
|
|
||||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" />
|
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" />
|
||||||
<!-- OpcUaUserIdentityFactory: the secret: resolution of the credential fields feeding the SDK UserIdentity is
|
<!-- OpcUaUserIdentityFactory: the secret: resolution of the credential fields feeding the SDK UserIdentity is
|
||||||
shared by the driver and the AdminUI browse (review 2026-10-21 O-5), so the browse presents exactly the
|
shared by the driver and the AdminUI browse (review 2026-10-21 O-5), so the browse presents exactly the
|
||||||
|
|||||||
@@ -274,12 +274,13 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Cert-validation delegate wired when <see cref="OpcUaClientDriverOptions.AutoAcceptCertificates"/>
|
/// Cert-validation delegate wired when <see cref="OpcUaClientDriverOptions.AutoAcceptCertificates"/>
|
||||||
/// is <c>true</c>. Stored so <see cref="Dispose"/> / <see cref="DisposeAsync"/> can
|
/// is <c>true</c>. Stored so <see cref="Dispose"/> / <see cref="DisposeAsync"/> can
|
||||||
/// detach it from the (potentially process-shared) <see cref="CertificateValidator"/>
|
/// detach it from the (potentially process-shared) <see cref="ICertificateManager"/>
|
||||||
/// and avoid leaking the closure.
|
/// and avoid leaking the closure.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private CertificateValidationEventHandler? _certValidationHandler;
|
private Func<Opc.Ua.Security.Certificates.Certificate, ServiceResult, bool>? _certValidationHandler;
|
||||||
/// <summary>The <see cref="CertificateValidator"/> that owns <see cref="_certValidationHandler"/>.</summary>
|
/// <summary>The <see cref="ICertificateManager"/> that owns <see cref="_certValidationHandler"/>.</summary>
|
||||||
private CertificateValidator? _certValidatorRef;
|
private ICertificateManager? _certValidatorRef;
|
||||||
|
private ApplicationInstance? _ownedApplication;
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 1 while the CURRENT session's keep-alive is bad (an outage is in progress), else 0. The
|
/// 1 while the CURRENT session's keep-alive is bad (an outage is in progress), else 0. The
|
||||||
/// 0→1 edge publishes the outage once (Bad per subscribed ref + a quality-only alarm annotation); the 1→0
|
/// 0→1 edge publishes the outage once (Bad per subscribed ref + a quality-only alarm annotation); the 1→0
|
||||||
@@ -547,7 +548,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
|||||||
// G-2b: resolve any secret:-prefixed Password / UserCertificatePassword through the
|
// G-2b: resolve any secret:-prefixed Password / UserCertificatePassword through the
|
||||||
// shared secret store ONCE, here in the async connect flow, just before the credentials
|
// shared secret store ONCE, here in the async connect flow, just before the credentials
|
||||||
// are consumed. _options stays the raw config (with secret: refs) — only this
|
// are consumed. _options stays the raw config (with secret: refs) — only this
|
||||||
// connect-scoped local carries plaintext, and only for the duration of the connect.
|
// session identity carries the resolved credential while activation/signing needs it.
|
||||||
// Resolving on every InitializeAsync (a full reconnect calls it) picks up rotations.
|
// Resolving on every InitializeAsync (a full reconnect calls it) picks up rotations.
|
||||||
// Both credential consumers — the Username password below and the Certificate password
|
// Both credential consumers — the Username password below and the Certificate password
|
||||||
// in BuildCertificateIdentity — flow through BuildUserIdentity(resolvedOptions), so a
|
// in BuildCertificateIdentity — flow through BuildUserIdentity(resolvedOptions), so a
|
||||||
@@ -565,6 +566,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
|||||||
string? connectedUrl = null;
|
string? connectedUrl = null;
|
||||||
foreach (var url in candidates)
|
foreach (var url in candidates)
|
||||||
{
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
session = OpenSessionForTest is { } open
|
session = OpenSessionForTest is { } open
|
||||||
@@ -572,9 +574,11 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
|||||||
: await OpenSessionOnEndpointAsync(
|
: await OpenSessionOnEndpointAsync(
|
||||||
appConfig, url, _options.SecurityPolicy, _options.SecurityMode,
|
appConfig, url, _options.SecurityPolicy, _options.SecurityMode,
|
||||||
identity, cancellationToken).ConfigureAwait(false);
|
identity, cancellationToken).ConfigureAwait(false);
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
connectedUrl = url;
|
connectedUrl = url;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
attemptErrors.Add($"{url} -> {ex.GetType().Name}: {ex.Message}");
|
attemptErrors.Add($"{url} -> {ex.GetType().Name}: {ex.Message}");
|
||||||
@@ -622,6 +626,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
|||||||
DetachCertValidation(_certValidatorRef, _certValidationHandler);
|
DetachCertValidation(_certValidatorRef, _certValidationHandler);
|
||||||
_certValidationHandler = certValidation.Handler;
|
_certValidationHandler = certValidation.Handler;
|
||||||
_certValidatorRef = certValidation.Validator;
|
_certValidatorRef = certValidation.Validator;
|
||||||
|
_ownedApplication = certValidation.Application;
|
||||||
installed = true;
|
installed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -642,7 +647,12 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
|||||||
// superseded check) does not orphan it and its keep-alive timer until GC.
|
// superseded check) does not orphan it and its keep-alive timer until GC.
|
||||||
if (session is not null && !installed)
|
if (session is not null && !installed)
|
||||||
await DiscardSessionAsync(session, keepAliveHandler).ConfigureAwait(false);
|
await DiscardSessionAsync(session, keepAliveHandler).ConfigureAwait(false);
|
||||||
if (!installed) DetachCertValidation(certValidation.Validator, certValidation.Handler);
|
if (!installed)
|
||||||
|
{
|
||||||
|
DetachCertValidation(certValidation.Validator, certValidation.Handler);
|
||||||
|
if (certValidation.Application is { } application)
|
||||||
|
await application.DisposeAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
if (Volatile.Read(ref _lifecycleGeneration) == generation)
|
if (Volatile.Read(ref _lifecycleGeneration) == generation)
|
||||||
{
|
{
|
||||||
if (installed)
|
if (installed)
|
||||||
@@ -657,14 +667,15 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
|||||||
/// <summary>The auto-accept certificate-validation handler one initialise attached, handed back to it (review m2).</summary>
|
/// <summary>The auto-accept certificate-validation handler one initialise attached, handed back to it (review m2).</summary>
|
||||||
private sealed class CertValidationHook
|
private sealed class CertValidationHook
|
||||||
{
|
{
|
||||||
public CertificateValidationEventHandler? Handler { get; set; }
|
public Func<Opc.Ua.Security.Certificates.Certificate, ServiceResult, bool>? Handler { get; set; }
|
||||||
public CertificateValidator? Validator { get; set; }
|
public ICertificateManager? Validator { get; set; }
|
||||||
|
public ApplicationInstance? Application { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void DetachCertValidation(CertificateValidator? validator, CertificateValidationEventHandler? handler)
|
private static void DetachCertValidation(ICertificateManager? validator, Func<Opc.Ua.Security.Certificates.Certificate, ServiceResult, bool>? handler)
|
||||||
{
|
{
|
||||||
if (validator is null || handler is null) return;
|
if (validator is null || handler is null) return;
|
||||||
try { validator.CertificateValidation -= handler; } catch { /* best-effort */ }
|
try { if (ReferenceEquals(validator.AcceptError, handler)) validator.AcceptError = null; } catch { /* best-effort */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -735,11 +746,12 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
|||||||
ApplicationType = ApplicationType.Client,
|
ApplicationType = ApplicationType.Client,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
certValidation.Application = app;
|
||||||
var pkiRoot = Path.Combine(
|
var pkiRoot = Path.Combine(
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
"OtOpcUa", "pki");
|
"OtOpcUa", "pki");
|
||||||
|
|
||||||
var config = new ApplicationConfiguration
|
var config = new ApplicationConfiguration(_sdkTelemetry)
|
||||||
{
|
{
|
||||||
ApplicationName = _options.SessionName,
|
ApplicationName = _options.SessionName,
|
||||||
ApplicationType = ApplicationType.Client,
|
ApplicationType = ApplicationType.Client,
|
||||||
@@ -782,6 +794,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
|||||||
DisableHiResClock = true,
|
DisableHiResClock = true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
app.ApplicationConfiguration = config;
|
||||||
await config.ValidateAsync(ApplicationType.Client, ct).ConfigureAwait(false);
|
await config.ValidateAsync(ApplicationType.Client, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
// AutoAccept=true is a dev-only escape hatch. Emit a prominent warning so a
|
// AutoAccept=true is a dev-only escape hatch. Emit a prominent warning so a
|
||||||
@@ -801,28 +814,26 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
|||||||
// the latter would silently fail for those certs. A REVOKED certificate is the one
|
// the latter would silently fail for those certs. A REVOKED certificate is the one
|
||||||
// exception: auto-accept is for dev self-signed certs, never for a cert its issuer
|
// exception: auto-accept is for dev self-signed certs, never for a cert its issuer
|
||||||
// has explicitly withdrawn.
|
// has explicitly withdrawn.
|
||||||
CertificateValidationEventHandler handler = (_, e) =>
|
Func<Opc.Ua.Security.Certificates.Certificate, ServiceResult, bool> handler = (certificate, error) =>
|
||||||
{
|
{
|
||||||
if (IsRevocationFailure(e.Error.StatusCode))
|
if (IsRevocationFailure(error.StatusCode))
|
||||||
{
|
{
|
||||||
_logger.LogWarning(
|
_logger.LogWarning(
|
||||||
"OpcUaClientDriver '{DriverInstanceId}': REVOKED server certificate rejected " +
|
"OpcUaClientDriver '{DriverInstanceId}': REVOKED server certificate rejected " +
|
||||||
"(subject={Subject}, status=0x{Status:X8}) — AutoAcceptCertificates does not apply to revoked certs.",
|
"(subject={Subject}, status=0x{Status:X8}) — AutoAcceptCertificates does not apply to revoked certs.",
|
||||||
_driverInstanceId, e.Certificate?.Subject, e.Error.StatusCode.Code);
|
_driverInstanceId, certificate.Subject, error.StatusCode.Code);
|
||||||
e.Accept = false;
|
return false;
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
e.Accept = true;
|
return true;
|
||||||
};
|
};
|
||||||
config.CertificateValidator.CertificateValidation += handler;
|
config.CertificateManager.AcceptError = handler;
|
||||||
// Handed back to the caller, which stores the refs (so ShutdownAsync + Dispose can detach the delegate)
|
// Handed back to the caller, which stores the refs (so ShutdownAsync + Dispose can detach the delegate)
|
||||||
// only while its initialise is the current lifecycle generation.
|
// only while its initialise is the current lifecycle generation.
|
||||||
certValidation.Handler = handler;
|
certValidation.Handler = handler;
|
||||||
certValidation.Validator = config.CertificateValidator;
|
certValidation.Validator = config.CertificateManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure an application certificate exists. The SDK auto-generates one if missing.
|
// Ensure an application certificate exists. The SDK auto-generates one if missing.
|
||||||
app.ApplicationConfiguration = config;
|
|
||||||
await app.CheckApplicationInstanceCertificatesAsync(silent: true, lifeTimeInMonths: null, ct)
|
await app.CheckApplicationInstanceCertificatesAsync(silent: true, lifeTimeInMonths: null, ct)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
@@ -888,31 +899,31 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
|||||||
var wantedPolicyUri = MapSecurityPolicy(policy);
|
var wantedPolicyUri = MapSecurityPolicy(policy);
|
||||||
var wantedMode = ToMessageSecurityMode(mode);
|
var wantedMode = ToMessageSecurityMode(mode);
|
||||||
|
|
||||||
ApplicationDescriptionCollection servers;
|
ArrayOf<ApplicationDescription> servers;
|
||||||
using (var lds = await DiscoveryClient.CreateAsync(
|
using (var lds = await DiscoveryClient.CreateAsync(
|
||||||
appConfig, new Uri(discoveryUrl), Opc.Ua.DiagnosticsMasks.None, ct).ConfigureAwait(false))
|
appConfig, new Uri(discoveryUrl), Opc.Ua.DiagnosticsMasks.None, ct).ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
servers = await lds.FindServersAsync(null, ct).ConfigureAwait(false);
|
servers = await lds.FindServersAsync(default, ct).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
var found = new List<DiscoveredEndpoint>();
|
var found = new List<DiscoveredEndpoint>();
|
||||||
foreach (var server in servers)
|
foreach (var server in servers.ToList())
|
||||||
{
|
{
|
||||||
if (server.DiscoveryUrls is null) continue;
|
if (server.DiscoveryUrls.IsNull) continue;
|
||||||
foreach (var serverDiscoveryUrl in server.DiscoveryUrls)
|
foreach (var serverDiscoveryUrl in server.DiscoveryUrls.ToList())
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(serverDiscoveryUrl)) continue;
|
if (string.IsNullOrWhiteSpace(serverDiscoveryUrl)) continue;
|
||||||
EndpointDescriptionCollection endpoints;
|
ArrayOf<EndpointDescription> endpoints;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var ep = await DiscoveryClient.CreateAsync(
|
using var ep = await DiscoveryClient.CreateAsync(
|
||||||
appConfig, new Uri(serverDiscoveryUrl), Opc.Ua.DiagnosticsMasks.None, ct).ConfigureAwait(false);
|
appConfig, new Uri(serverDiscoveryUrl), Opc.Ua.DiagnosticsMasks.None, ct).ConfigureAwait(false);
|
||||||
endpoints = await ep.GetEndpointsAsync(null, ct).ConfigureAwait(false);
|
endpoints = await ep.GetEndpointsAsync(default, ct).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
|
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
|
||||||
catch { continue; }
|
catch { continue; }
|
||||||
|
|
||||||
found.AddRange(FilterEndpointUrls(endpoints, wantedPolicyUri, wantedMode)
|
found.AddRange(FilterEndpointUrls(endpoints.ToList(), wantedPolicyUri, wantedMode)
|
||||||
.Select(u => new DiscoveredEndpoint(server.ApplicationUri ?? string.Empty, u)));
|
.Select(u => new DiscoveredEndpoint(server.ApplicationUri ?? string.Empty, u)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -965,7 +976,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
|||||||
endpoints
|
endpoints
|
||||||
.Where(e => e.SecurityPolicyUri == wantedPolicyUri && e.SecurityMode == wantedMode
|
.Where(e => e.SecurityPolicyUri == wantedPolicyUri && e.SecurityMode == wantedMode
|
||||||
&& !string.IsNullOrWhiteSpace(e.EndpointUrl))
|
&& !string.IsNullOrWhiteSpace(e.EndpointUrl))
|
||||||
.Select(e => e.EndpointUrl);
|
.Select(e => e.EndpointUrl!);
|
||||||
|
|
||||||
private static MessageSecurityMode ToMessageSecurityMode(OpcUaSecurityMode mode) => mode switch
|
private static MessageSecurityMode ToMessageSecurityMode(OpcUaSecurityMode mode) => mode switch
|
||||||
{
|
{
|
||||||
@@ -1052,7 +1063,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
|||||||
_options.SessionName,
|
_options.SessionName,
|
||||||
(uint)_options.SessionTimeout.TotalMilliseconds,
|
(uint)_options.SessionTimeout.TotalMilliseconds,
|
||||||
identity,
|
identity,
|
||||||
null, // preferredLocales
|
default, // preferredLocales
|
||||||
cts.Token).ConfigureAwait(false);
|
cts.Token).ConfigureAwait(false);
|
||||||
|
|
||||||
session.KeepAliveInterval = (int)_options.KeepAliveInterval.TotalMilliseconds;
|
session.KeepAliveInterval = (int)_options.KeepAliveInterval.TotalMilliseconds;
|
||||||
@@ -1080,17 +1091,17 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
|||||||
// all deprecated.
|
// all deprecated.
|
||||||
using var client = await DiscoveryClient.CreateAsync(
|
using var client = await DiscoveryClient.CreateAsync(
|
||||||
appConfig, new Uri(endpointUrl), Opc.Ua.DiagnosticsMasks.None, ct).ConfigureAwait(false);
|
appConfig, new Uri(endpointUrl), Opc.Ua.DiagnosticsMasks.None, ct).ConfigureAwait(false);
|
||||||
var all = await client.GetEndpointsAsync(null, ct).ConfigureAwait(false);
|
var all = await client.GetEndpointsAsync(default, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
var wantedPolicyUri = MapSecurityPolicy(policy);
|
var wantedPolicyUri = MapSecurityPolicy(policy);
|
||||||
var wantedMode = ToMessageSecurityMode(mode);
|
var wantedMode = ToMessageSecurityMode(mode);
|
||||||
|
|
||||||
var match = all.FirstOrDefault(e =>
|
var match = all.ToList().FirstOrDefault(e =>
|
||||||
e.SecurityPolicyUri == wantedPolicyUri && e.SecurityMode == wantedMode);
|
e.SecurityPolicyUri == wantedPolicyUri && e.SecurityMode == wantedMode);
|
||||||
|
|
||||||
if (match is null)
|
if (match is null)
|
||||||
{
|
{
|
||||||
var advertised = string.Join(", ", all
|
var advertised = string.Join(", ", all.ToList()
|
||||||
.Select(e => $"{ShortPolicyName(e.SecurityPolicyUri)}/{e.SecurityMode}"));
|
.Select(e => $"{ShortPolicyName(e.SecurityPolicyUri)}/{e.SecurityMode}"));
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
$"No endpoint at '{endpointUrl}' matches SecurityPolicy={policy} + SecurityMode={mode}. " +
|
$"No endpoint at '{endpointUrl}' matches SecurityPolicy={policy} + SecurityMode={mode}. " +
|
||||||
@@ -1157,7 +1168,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
|||||||
_ => throw new ArgumentOutOfRangeException(nameof(policy), policy, null),
|
_ => throw new ArgumentOutOfRangeException(nameof(policy), policy, null),
|
||||||
};
|
};
|
||||||
|
|
||||||
private static string ShortPolicyName(string policyUri) =>
|
private static string ShortPolicyName(string? policyUri) =>
|
||||||
policyUri?.Substring(policyUri.LastIndexOf('#') + 1) ?? "(null)";
|
policyUri?.Substring(policyUri.LastIndexOf('#') + 1) ?? "(null)";
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -1182,10 +1193,13 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
|||||||
// Take the session reference under _probeLock and clear Session to null so any concurrent caller that
|
// Take the session reference under _probeLock and clear Session to null so any concurrent caller that
|
||||||
// checks inside _gate sees null immediately after release begins.
|
// checks inside _gate sees null immediately after release begins.
|
||||||
ISession? sessionToClose;
|
ISession? sessionToClose;
|
||||||
|
ApplicationInstance? applicationToDispose;
|
||||||
lock (_probeLock)
|
lock (_probeLock)
|
||||||
{
|
{
|
||||||
if (Volatile.Read(ref _lifecycleGeneration) != generation) return;
|
if (Volatile.Read(ref _lifecycleGeneration) != generation) return;
|
||||||
sessionToClose = Session;
|
sessionToClose = Session;
|
||||||
|
applicationToDispose = _ownedApplication;
|
||||||
|
_ownedApplication = null;
|
||||||
if (_keepAliveHandler is not null && sessionToClose is not null)
|
if (_keepAliveHandler is not null && sessionToClose is not null)
|
||||||
{
|
{
|
||||||
try { sessionToClose.KeepAlive -= _keepAliveHandler; } catch { }
|
try { sessionToClose.KeepAlive -= _keepAliveHandler; } catch { }
|
||||||
@@ -1210,6 +1224,10 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
|||||||
}
|
}
|
||||||
catch { /* best-effort */ }
|
catch { /* best-effort */ }
|
||||||
try { sessionToClose?.Dispose(); } catch { }
|
try { sessionToClose?.Dispose(); } catch { }
|
||||||
|
if (applicationToDispose is not null)
|
||||||
|
{
|
||||||
|
try { await applicationToDispose.DisposeAsync().ConfigureAwait(false); } catch { }
|
||||||
|
}
|
||||||
Interlocked.Exchange(ref _sessionLost, 0);
|
Interlocked.Exchange(ref _sessionLost, 0);
|
||||||
Interlocked.Exchange(ref _subscriptionLost, 0);
|
Interlocked.Exchange(ref _subscriptionLost, 0);
|
||||||
Interlocked.Exchange(ref _publishingStoppedSinceTicks, 0);
|
Interlocked.Exchange(ref _publishingStoppedSinceTicks, 0);
|
||||||
@@ -1347,7 +1365,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
|||||||
_namespaceMap = null;
|
_namespaceMap = null;
|
||||||
_connectedEndpointUrl = null;
|
_connectedEndpointUrl = null;
|
||||||
|
|
||||||
// Detach the cert-validation handler so the (potentially process-shared) CertificateValidator doesn't
|
// Detach the cert-validation handler so the (potentially process-shared) ICertificateManager doesn't
|
||||||
// hold a delegate to a shutting-down driver — generation-checked like the map (r1 N-3): a newer init
|
// hold a delegate to a shutting-down driver — generation-checked like the map (r1 N-3): a newer init
|
||||||
// that installed its own handler meanwhile keeps it.
|
// that installed its own handler meanwhile keeps it.
|
||||||
DetachCertValidation(_certValidatorRef, _certValidationHandler);
|
DetachCertValidation(_certValidatorRef, _certValidationHandler);
|
||||||
@@ -1424,7 +1442,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
|||||||
// A RawPath the authored RawTags table doesn't know — or a node id that won't parse —
|
// A RawPath the authored RawTags table doesn't know — or a node id that won't parse —
|
||||||
// gets BadNodeIdInvalid and is omitted from the wire request, saving a round-trip for a
|
// gets BadNodeIdInvalid and is omitted from the wire request, saving a round-trip for a
|
||||||
// fault the driver can detect locally.
|
// fault the driver can detect locally.
|
||||||
var toSend = new ReadValueIdCollection();
|
var toSend = new List<ReadValueId>();
|
||||||
var indexMap = new List<int>(fullReferences.Count); // maps wire-index -> results-index
|
var indexMap = new List<int>(fullReferences.Count); // maps wire-index -> results-index
|
||||||
for (var i = 0; i < fullReferences.Count; i++)
|
for (var i = 0; i < fullReferences.Count; i++)
|
||||||
{
|
{
|
||||||
@@ -1456,11 +1474,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
|||||||
// Preserve the upstream StatusCode verbatim — including Bad codes per
|
// Preserve the upstream StatusCode verbatim — including Bad codes per
|
||||||
// §8's cascading-quality rule. Also preserve SourceTimestamp so downstream
|
// §8's cascading-quality rule. Also preserve SourceTimestamp so downstream
|
||||||
// clients can detect stale upstream data.
|
// clients can detect stale upstream data.
|
||||||
results[r] = new DataValueSnapshot(
|
results[r] = ToSnapshot(dv, now);
|
||||||
Value: dv.Value,
|
|
||||||
StatusCode: dv.StatusCode.Code,
|
|
||||||
SourceTimestampUtc: dv.SourceTimestamp == DateTime.MinValue ? null : dv.SourceTimestamp,
|
|
||||||
ServerTimestampUtc: dv.ServerTimestamp == DateTime.MinValue ? now : dv.ServerTimestamp);
|
|
||||||
}
|
}
|
||||||
RecordDeviceAnswered(generation, now);
|
RecordDeviceAnswered(generation, now);
|
||||||
}
|
}
|
||||||
@@ -1506,7 +1520,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
var toSend = new WriteValueCollection();
|
var toSend = new List<WriteValue>();
|
||||||
var indexMap = new List<int>(writes.Count);
|
var indexMap = new List<int>(writes.Count);
|
||||||
for (var i = 0; i < writes.Count; i++)
|
for (var i = 0; i < writes.Count; i++)
|
||||||
{
|
{
|
||||||
@@ -1515,11 +1529,16 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
|||||||
results[i] = new WriteResult(StatusBadNodeIdInvalid);
|
results[i] = new WriteResult(StatusBadNodeIdInvalid);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (!VariantHelper.TryCastFrom(writes[i].Value, out Variant value))
|
||||||
|
{
|
||||||
|
results[i] = new WriteResult(StatusCodes.BadTypeMismatch.Code);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
toSend.Add(new WriteValue
|
toSend.Add(new WriteValue
|
||||||
{
|
{
|
||||||
NodeId = nodeId,
|
NodeId = nodeId,
|
||||||
AttributeId = Attributes.Value,
|
AttributeId = Attributes.Value,
|
||||||
Value = new DataValue(new Variant(writes[i].Value)),
|
Value = new DataValue(value),
|
||||||
});
|
});
|
||||||
indexMap.Add(i);
|
indexMap.Add(i);
|
||||||
}
|
}
|
||||||
@@ -1674,7 +1693,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
|||||||
if (discovered() >= _options.MaxDiscoveredNodes) return;
|
if (discovered() >= _options.MaxDiscoveredNodes) return;
|
||||||
if (!visited.Add(node)) return;
|
if (!visited.Add(node)) return;
|
||||||
|
|
||||||
var browseDescriptions = new BrowseDescriptionCollection
|
var browseDescriptions = new List<BrowseDescription>
|
||||||
{
|
{
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
@@ -1688,7 +1707,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
ReferenceDescriptionCollection refs;
|
List<ReferenceDescription> refs;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var resp = await session.BrowseAsync(
|
var resp = await session.BrowseAsync(
|
||||||
@@ -1700,7 +1719,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
|||||||
|
|
||||||
if (resp.Results.Count == 0) return;
|
if (resp.Results.Count == 0) return;
|
||||||
var result = resp.Results[0];
|
var result = resp.Results[0];
|
||||||
refs = result.References;
|
refs = result.References.ToList();
|
||||||
|
|
||||||
// Follow browse continuation points. OPC UA servers cap the references returned
|
// Follow browse continuation points. OPC UA servers cap the references returned
|
||||||
// per node in a single response; when a folder has more children than the cap,
|
// per node in a single response; when a folder has more children than the cap,
|
||||||
@@ -1728,11 +1747,12 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
|||||||
// the release fails silently and the cursor times out server-side anyway.
|
// the release fails silently and the cursor times out server-side anyway.
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
using var release = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||||
await session.BrowseNextAsync(
|
await session.BrowseNextAsync(
|
||||||
requestHeader: null,
|
requestHeader: null,
|
||||||
releaseContinuationPoints: true,
|
releaseContinuationPoints: true,
|
||||||
continuationPoints: [continuationPoint],
|
continuationPoints: [continuationPoint],
|
||||||
ct: CancellationToken.None).ConfigureAwait(false);
|
ct: release.Token).AsTask().WaitAsync(release.Token).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
catch { /* best-effort — server may have already cleaned up */ }
|
catch { /* best-effort — server may have already cleaned up */ }
|
||||||
throw;
|
throw;
|
||||||
@@ -1741,7 +1761,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
|||||||
if (next.Results.Count == 0) break;
|
if (next.Results.Count == 0) break;
|
||||||
var nextResult = next.Results[0];
|
var nextResult = next.Results[0];
|
||||||
if (nextResult.References is { Count: > 0 })
|
if (nextResult.References is { Count: > 0 })
|
||||||
refs.AddRange(nextResult.References);
|
refs.AddRange(nextResult.References.ToList());
|
||||||
continuationPoint = nextResult.ContinuationPoint;
|
continuationPoint = nextResult.ContinuationPoint;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1764,10 +1784,10 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
|||||||
if (discovered() >= _options.MaxDiscoveredNodes) break;
|
if (discovered() >= _options.MaxDiscoveredNodes) break;
|
||||||
|
|
||||||
var childId = ExpandedNodeId.ToNodeId(rf.NodeId, session.NamespaceUris);
|
var childId = ExpandedNodeId.ToNodeId(rf.NodeId, session.NamespaceUris);
|
||||||
if (NodeId.IsNull(childId)) continue;
|
if (childId.IsNull) continue;
|
||||||
|
|
||||||
var browseName = rf.BrowseName?.Name ?? childId.ToString();
|
var browseName = rf.BrowseName.Name ?? childId.ToString();
|
||||||
var displayName = rf.DisplayName?.Text ?? browseName;
|
var displayName = rf.DisplayName.Text ?? browseName;
|
||||||
|
|
||||||
if (rf.NodeClass == NodeClass.Object)
|
if (rf.NodeClass == NodeClass.Object)
|
||||||
{
|
{
|
||||||
@@ -1812,7 +1832,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
|||||||
if (pending.Count == 0) return;
|
if (pending.Count == 0) return;
|
||||||
|
|
||||||
// 4 attributes per variable: DataType, ValueRank, UserAccessLevel, Historizing.
|
// 4 attributes per variable: DataType, ValueRank, UserAccessLevel, Historizing.
|
||||||
var nodesToRead = new ReadValueIdCollection(pending.Count * 4);
|
var nodesToRead = new List<ReadValueId>(pending.Count * 4);
|
||||||
foreach (var pv in pending)
|
foreach (var pv in pending)
|
||||||
{
|
{
|
||||||
nodesToRead.Add(new ReadValueId { NodeId = pv.NodeId, AttributeId = Attributes.DataType });
|
nodesToRead.Add(new ReadValueId { NodeId = pv.NodeId, AttributeId = Attributes.DataType });
|
||||||
@@ -1821,7 +1841,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
|||||||
nodesToRead.Add(new ReadValueId { NodeId = pv.NodeId, AttributeId = Attributes.Historizing });
|
nodesToRead.Add(new ReadValueId { NodeId = pv.NodeId, AttributeId = Attributes.Historizing });
|
||||||
}
|
}
|
||||||
|
|
||||||
DataValueCollection values;
|
ArrayOf<DataValue> values;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var resp = await session.ReadAsync(
|
var resp = await session.ReadAsync(
|
||||||
@@ -1851,10 +1871,10 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
|||||||
var accessDv = values[baseIdx + 2];
|
var accessDv = values[baseIdx + 2];
|
||||||
var histDv = values[baseIdx + 3];
|
var histDv = values[baseIdx + 3];
|
||||||
|
|
||||||
var dataType = StatusCode.IsGood(dataTypeDv.StatusCode) && dataTypeDv.Value is NodeId dtId
|
var dataType = StatusCode.IsGood(dataTypeDv.StatusCode) && UpstreamValueMapping.ToClr(dataTypeDv.WrappedValue) is NodeId dtId
|
||||||
? MapUpstreamDataType(dtId)
|
? MapUpstreamDataType(dtId)
|
||||||
: DriverDataType.Int32;
|
: DriverDataType.Int32;
|
||||||
var valueRank = StatusCode.IsGood(valueRankDv.StatusCode) && valueRankDv.Value is int vr ? vr : -1;
|
var valueRank = StatusCode.IsGood(valueRankDv.StatusCode) && UpstreamValueMapping.ToClr(valueRankDv.WrappedValue) is int vr ? vr : -1;
|
||||||
// OPC UA Part 3 ValueRank constants: -3 = ScalarOrOneDimension, -2 = Any,
|
// OPC UA Part 3 ValueRank constants: -3 = ScalarOrOneDimension, -2 = Any,
|
||||||
// -1 = Scalar, 0 = OneOrMoreDimensions, 1 = OneDimension, >1 = N specific dimensions.
|
// -1 = Scalar, 0 = OneOrMoreDimensions, 1 = OneDimension, >1 = N specific dimensions.
|
||||||
// Deliberate choice: treat anything >= 0 as an array (the spec guarantees -3/-2/-1
|
// Deliberate choice: treat anything >= 0 as an array (the spec guarantees -3/-2/-1
|
||||||
@@ -1863,9 +1883,9 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
|||||||
// as scalar — array-of-one is exposed as scalar to the local address space until
|
// as scalar — array-of-one is exposed as scalar to the local address space until
|
||||||
// the upstream variable carries a concrete dimensioned rank.
|
// the upstream variable carries a concrete dimensioned rank.
|
||||||
var isArray = UpstreamAttributeMapping.IsArray(valueRank);
|
var isArray = UpstreamAttributeMapping.IsArray(valueRank);
|
||||||
var access = StatusCode.IsGood(accessDv.StatusCode) && accessDv.Value is byte ab ? ab : (byte)0;
|
var access = StatusCode.IsGood(accessDv.StatusCode) && UpstreamValueMapping.ToClr(accessDv.WrappedValue) is byte ab ? ab : (byte)0;
|
||||||
var securityClass = MapAccessLevelToSecurityClass(access);
|
var securityClass = MapAccessLevelToSecurityClass(access);
|
||||||
var historizing = StatusCode.IsGood(histDv.StatusCode) && histDv.Value is bool b && b;
|
var historizing = StatusCode.IsGood(histDv.StatusCode) && UpstreamValueMapping.ToClr(histDv.WrappedValue) is bool b && b;
|
||||||
|
|
||||||
pv.ParentFolder.Variable(pv.BrowseName, pv.DisplayName, new DriverAttributeInfo(
|
pv.ParentFolder.Variable(pv.BrowseName, pv.DisplayName, new DriverAttributeInfo(
|
||||||
FullName: StableReference(pv.NodeId),
|
FullName: StableReference(pv.NodeId),
|
||||||
@@ -2077,10 +2097,22 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
|||||||
// subscriptions; extract its DataValue. The Handle property carries our tag string.
|
// subscriptions; extract its DataValue. The Handle property carries our tag string.
|
||||||
if (args.NotificationValue is not MonitoredItemNotification mn) return;
|
if (args.NotificationValue is not MonitoredItemNotification mn) return;
|
||||||
var dv = mn.Value;
|
var dv = mn.Value;
|
||||||
if (dv is null) return;
|
|
||||||
PublishNotification(handle, RefOf(item), dv);
|
PublishNotification(handle, RefOf(item), dv);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static DataValueSnapshot ToSnapshot(DataValue value, DateTime now)
|
||||||
|
{
|
||||||
|
// Matrix payloads have no driver-domain contract. SDK 2's Legacy boxing materializes
|
||||||
|
// them into CLR matrices, so refuse that unsupported shape before boxing. 1D arrays
|
||||||
|
// retain their deployed pass-through behavior, including scalar-shaped authored tags.
|
||||||
|
var unsupportedShape = value.WrappedValue.TypeInfo.IsMatrix;
|
||||||
|
return new DataValueSnapshot(
|
||||||
|
Value: unsupportedShape ? null : UpstreamValueMapping.ToClr(value.WrappedValue),
|
||||||
|
StatusCode: unsupportedShape ? StatusCodes.BadTypeMismatch.Code : value.StatusCode.Code,
|
||||||
|
SourceTimestampUtc: value.SourceTimestamp == DateTimeUtc.MinValue ? null : value.SourceTimestamp.ToDateTime(),
|
||||||
|
ServerTimestampUtc: value.ServerTimestamp == DateTimeUtc.MinValue ? now : value.ServerTimestamp.ToDateTime());
|
||||||
|
}
|
||||||
|
|
||||||
private static string RefOf(MonitoredItem item) => (item.Handle as string) ?? item.DisplayName ?? string.Empty;
|
private static string RefOf(MonitoredItem item) => (item.Handle as string) ?? item.DisplayName ?? string.Empty;
|
||||||
|
|
||||||
private void PublishNotification(OpcUaSubscriptionHandle handle, string fullRef, DataValue dv)
|
private void PublishNotification(OpcUaSubscriptionHandle handle, string fullRef, DataValue dv)
|
||||||
@@ -2090,11 +2122,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
|||||||
// a delivered notification is liveness evidence — the subscription path never calls ReadAsync, so
|
// a delivered notification is liveness evidence — the subscription path never calls ReadAsync, so
|
||||||
// without this a busy subscription-only driver's LastSuccessfulRead stayed at its connect time.
|
// without this a busy subscription-only driver's LastSuccessfulRead stayed at its connect time.
|
||||||
RecordLivenessEvidence();
|
RecordLivenessEvidence();
|
||||||
var snapshot = new DataValueSnapshot(
|
var snapshot = ToSnapshot(dv, DateTime.UtcNow);
|
||||||
Value: dv.Value,
|
|
||||||
StatusCode: dv.StatusCode.Code,
|
|
||||||
SourceTimestampUtc: dv.SourceTimestamp == DateTime.MinValue ? null : dv.SourceTimestamp,
|
|
||||||
ServerTimestampUtc: dv.ServerTimestamp == DateTime.MinValue ? DateTime.UtcNow : dv.ServerTimestamp);
|
|
||||||
// Review of 3b88056f: the replay check and the publish are one critical section shared with the recovery and
|
// Review of 3b88056f: the replay check and the publish are one critical section shared with the recovery and
|
||||||
// trailing reads, so an older value can never be delivered after a newer one (see _deliveryLock).
|
// trailing reads, so an older value can never be delivered after a newer one (see _deliveryLock).
|
||||||
lock (_deliveryLock)
|
lock (_deliveryLock)
|
||||||
@@ -2415,7 +2443,8 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
|||||||
// constants so the notification handler indexes in O(1) without re-examining the
|
// constants so the notification handler indexes in O(1) without re-examining the
|
||||||
// QualifiedName BrowsePaths.
|
// QualifiedName BrowsePaths.
|
||||||
var filter = new EventFilter();
|
var filter = new EventFilter();
|
||||||
void AddField(string browseName) => filter.SelectClauses.Add(new SimpleAttributeOperand
|
var selectClauses = new List<SimpleAttributeOperand>();
|
||||||
|
void AddField(string browseName) => selectClauses.Add(new SimpleAttributeOperand
|
||||||
{
|
{
|
||||||
TypeDefinitionId = ObjectTypeIds.BaseEventType,
|
TypeDefinitionId = ObjectTypeIds.BaseEventType,
|
||||||
BrowsePath = [new QualifiedName(browseName)],
|
BrowsePath = [new QualifiedName(browseName)],
|
||||||
@@ -2429,7 +2458,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
|||||||
AddField("Time");
|
AddField("Time");
|
||||||
// ConditionId on ConditionType nodes is the branch identifier for
|
// ConditionId on ConditionType nodes is the branch identifier for
|
||||||
// acknowledgeable conditions. Not a BaseEventType field — reach it via the typed path.
|
// acknowledgeable conditions. Not a BaseEventType field — reach it via the typed path.
|
||||||
filter.SelectClauses.Add(new SimpleAttributeOperand
|
selectClauses.Add(new SimpleAttributeOperand
|
||||||
{
|
{
|
||||||
TypeDefinitionId = ObjectTypeIds.ConditionType,
|
TypeDefinitionId = ObjectTypeIds.ConditionType,
|
||||||
BrowsePath = [], // empty path = the condition node itself
|
BrowsePath = [], // empty path = the condition node itself
|
||||||
@@ -2437,13 +2466,13 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
|||||||
});
|
});
|
||||||
// the host derives Active/Acked solely from AlarmEventArgs.Kind, so the condition state
|
// the host derives Active/Acked solely from AlarmEventArgs.Kind, so the condition state
|
||||||
// must be selected. TWO-element browse paths — a single "ActiveState/Id" QualifiedName names nothing.
|
// must be selected. TWO-element browse paths — a single "ActiveState/Id" QualifiedName names nothing.
|
||||||
filter.SelectClauses.Add(new SimpleAttributeOperand
|
selectClauses.Add(new SimpleAttributeOperand
|
||||||
{
|
{
|
||||||
TypeDefinitionId = ObjectTypeIds.AlarmConditionType,
|
TypeDefinitionId = ObjectTypeIds.AlarmConditionType,
|
||||||
BrowsePath = [new QualifiedName(BrowseNames.ActiveState), new QualifiedName(BrowseNames.Id)],
|
BrowsePath = [new QualifiedName(BrowseNames.ActiveState), new QualifiedName(BrowseNames.Id)],
|
||||||
AttributeId = Attributes.Value,
|
AttributeId = Attributes.Value,
|
||||||
});
|
});
|
||||||
filter.SelectClauses.Add(new SimpleAttributeOperand
|
selectClauses.Add(new SimpleAttributeOperand
|
||||||
{
|
{
|
||||||
TypeDefinitionId = ObjectTypeIds.AcknowledgeableConditionType,
|
TypeDefinitionId = ObjectTypeIds.AcknowledgeableConditionType,
|
||||||
BrowsePath = [new QualifiedName(BrowseNames.AckedState), new QualifiedName(BrowseNames.Id)],
|
BrowsePath = [new QualifiedName(BrowseNames.AckedState), new QualifiedName(BrowseNames.Id)],
|
||||||
@@ -2451,13 +2480,15 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
|||||||
});
|
});
|
||||||
// BranchId — a non-null one marks a condition BRANCH event (a previous state, with its own
|
// BranchId — a non-null one marks a condition BRANCH event (a previous state, with its own
|
||||||
// EventId). Only the trunk (null BranchId) is the condition's current state.
|
// EventId). Only the trunk (null BranchId) is the condition's current state.
|
||||||
filter.SelectClauses.Add(new SimpleAttributeOperand
|
selectClauses.Add(new SimpleAttributeOperand
|
||||||
{
|
{
|
||||||
TypeDefinitionId = ObjectTypeIds.ConditionType,
|
TypeDefinitionId = ObjectTypeIds.ConditionType,
|
||||||
BrowsePath = [new QualifiedName(BrowseNames.BranchId)],
|
BrowsePath = [new QualifiedName(BrowseNames.BranchId)],
|
||||||
AttributeId = Attributes.Value,
|
AttributeId = Attributes.Value,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
filter.SelectClauses = selectClauses;
|
||||||
|
|
||||||
// Set by CreateAlarmEventItemAsync so a failure after the handler is attached can detach it.
|
// Set by CreateAlarmEventItemAsync so a failure after the handler is attached can detach it.
|
||||||
MonitoredItem? eventItem = null;
|
MonitoredItem? eventItem = null;
|
||||||
MonitoredItemNotificationEventHandler? notifHandler = null;
|
MonitoredItemNotificationEventHandler? notifHandler = null;
|
||||||
@@ -2598,7 +2629,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
|||||||
// OPC UA A&C: call the AcknowledgeableConditionType.Acknowledge method on each
|
// OPC UA A&C: call the AcknowledgeableConditionType.Acknowledge method on each
|
||||||
// condition node with EventId + Comment arguments. CallAsync accepts a batch —
|
// condition node with EventId + Comment arguments. CallAsync accepts a batch —
|
||||||
// one CallMethodRequest per ack.
|
// one CallMethodRequest per ack.
|
||||||
var callRequests = new CallMethodRequestCollection();
|
var callRequests = new List<CallMethodRequest>();
|
||||||
foreach (var ack in acknowledgements)
|
foreach (var ack in acknowledgements)
|
||||||
{
|
{
|
||||||
// the host acks by RawPath; Acknowledge must hit the upstream CONDITION node.
|
// the host acks by RawPath; Acknowledge must hit the upstream CONDITION node.
|
||||||
@@ -2624,7 +2655,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
|||||||
ObjectId = conditionId,
|
ObjectId = conditionId,
|
||||||
MethodId = MethodIds.AcknowledgeableConditionType_Acknowledge,
|
MethodId = MethodIds.AcknowledgeableConditionType_Acknowledge,
|
||||||
InputArguments = [
|
InputArguments = [
|
||||||
new Variant(eventId), // Part 9 §5.7.3: the EventId being acknowledged (empty only when none seen)
|
Variant.From(eventId.ToByteString()), // Part 9 §5.7.3: the EventId being acknowledged (empty only when none seen)
|
||||||
new Variant(new LocalizedText(ack.Comment ?? string.Empty)),
|
new Variant(new LocalizedText(ack.Comment ?? string.Empty)),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -2711,7 +2742,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
|||||||
// without this it would feed RouteAlarmEvent alongside the real one.
|
// without this it would feed RouteAlarmEvent alongside the real one.
|
||||||
if (!_alarmSubscriptions.ContainsKey(handle.Id)) return;
|
if (!_alarmSubscriptions.ContainsKey(handle.Id)) return;
|
||||||
RecordLivenessEvidence();
|
RecordLivenessEvidence();
|
||||||
RouteAlarmEvent(efl.EventFields, sourceFilter, handle);
|
RouteAlarmEvent(efl.EventFields.ToList(), sourceFilter, handle);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -2805,7 +2836,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
|||||||
/// <param name="fields">The event's fields.</param>
|
/// <param name="fields">The event's fields.</param>
|
||||||
/// <returns>Whether the event is a RefreshRequired marker.</returns>
|
/// <returns>Whether the event is a RefreshRequired marker.</returns>
|
||||||
internal static bool IsRefreshRequired(IReadOnlyList<Variant> fields) =>
|
internal static bool IsRefreshRequired(IReadOnlyList<Variant> fields) =>
|
||||||
fields.Count > AlarmFieldEventType && fields[AlarmFieldEventType].Value is NodeId t
|
fields.Count > AlarmFieldEventType && UpstreamValueMapping.ToClr(fields[AlarmFieldEventType]) is NodeId t
|
||||||
&& t == ObjectTypeIds.RefreshRequiredEventType;
|
&& t == ObjectTypeIds.RefreshRequiredEventType;
|
||||||
|
|
||||||
/// <summary>True for the three ConditionRefresh marker event types — never alarms.</summary>
|
/// <summary>True for the three ConditionRefresh marker event types — never alarms.</summary>
|
||||||
@@ -3017,21 +3048,21 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
|||||||
state = null;
|
state = null;
|
||||||
if (fields.Count <= AlarmFieldConditionId) return null;
|
if (fields.Count <= AlarmFieldConditionId) return null;
|
||||||
// ConditionRefresh brackets its re-delivery with RefreshStart/RefreshEnd — markers, never alarms.
|
// ConditionRefresh brackets its re-delivery with RefreshStart/RefreshEnd — markers, never alarms.
|
||||||
if (IsRefreshMarker(fields[AlarmFieldEventType].Value)) return null;
|
if (IsRefreshMarker(UpstreamValueMapping.ToClr(fields[AlarmFieldEventType]))) return null;
|
||||||
// A condition BRANCH event reports a previous state with its own EventId: projecting it would flip the
|
// A condition BRANCH event reports a previous state with its own EventId: projecting it would flip the
|
||||||
// trunk's state, remembering it would make Acknowledge ack the branch.
|
// trunk's state, remembering it would make Acknowledge ack the branch.
|
||||||
if (fields.Count > AlarmFieldBranchId && fields[AlarmFieldBranchId].Value is NodeId branch && !NodeId.IsNull(branch))
|
if (fields.Count > AlarmFieldBranchId && UpstreamValueMapping.ToClr(fields[AlarmFieldBranchId]) is NodeId branch && !branch.IsNull)
|
||||||
return null;
|
return null;
|
||||||
if (fields.Count > AlarmFieldAckedState
|
if (fields.Count > AlarmFieldAckedState
|
||||||
&& fields[AlarmFieldActiveState].Value is bool active
|
&& UpstreamValueMapping.ToClr(fields[AlarmFieldActiveState]) is bool active
|
||||||
&& fields[AlarmFieldAckedState].Value is bool acked)
|
&& UpstreamValueMapping.ToClr(fields[AlarmFieldAckedState]) is bool acked)
|
||||||
state = (active, acked);
|
state = (active, acked);
|
||||||
|
|
||||||
// Pass-through (no authored refs) keeps the raw serialized ids; an authored index compares
|
// Pass-through (no authored refs) keeps the raw serialized ids; an authored index compares
|
||||||
// server-stable keys (see AlarmIndexKey), matching the CONDITION node first — the only way to tell
|
// server-stable keys (see AlarmIndexKey), matching the CONDITION node first — the only way to tell
|
||||||
// several conditions on one source apart — and then the source node.
|
// several conditions on one source apart — and then the source node.
|
||||||
var sourceField = fields[AlarmFieldSourceNode].Value;
|
var sourceField = UpstreamValueMapping.ToClr(fields[AlarmFieldSourceNode]);
|
||||||
var conditionField = fields[AlarmFieldConditionId].Value;
|
var conditionField = UpstreamValueMapping.ToClr(fields[AlarmFieldConditionId]);
|
||||||
string reported, upstreamCondition;
|
string reported, upstreamCondition;
|
||||||
if (sourceIndex.Count == 0)
|
if (sourceIndex.Count == 0)
|
||||||
{
|
{
|
||||||
@@ -3040,23 +3071,23 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
upstreamCondition = conditionField is NodeId cn && !NodeId.IsNull(cn) ? keyOf(cn) : string.Empty;
|
upstreamCondition = conditionField is NodeId cn && !cn.IsNull ? keyOf(cn) : string.Empty;
|
||||||
var sourceKey = sourceField is NodeId sn && !NodeId.IsNull(sn) ? keyOf(sn) : string.Empty;
|
var sourceKey = sourceField is NodeId sn && !sn.IsNull ? keyOf(sn) : string.Empty;
|
||||||
if (!(upstreamCondition.Length > 0 && sourceIndex.TryGetValue(upstreamCondition, out reported!))
|
if (!(upstreamCondition.Length > 0 && sourceIndex.TryGetValue(upstreamCondition, out reported!))
|
||||||
&& !(sourceKey.Length > 0 && sourceIndex.TryGetValue(sourceKey, out reported!)))
|
&& !(sourceKey.Length > 0 && sourceIndex.TryGetValue(sourceKey, out reported!)))
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var eventType = fields[AlarmFieldEventType].Value?.ToString() ?? "BaseEventType";
|
var eventType = UpstreamValueMapping.ToClr(fields[AlarmFieldEventType])?.ToString() ?? "BaseEventType";
|
||||||
var message = (fields[AlarmFieldMessage].Value as LocalizedText)?.Text ?? string.Empty;
|
var message = (UpstreamValueMapping.ToClr(fields[AlarmFieldMessage]) as LocalizedText?)?.Text ?? string.Empty;
|
||||||
var severity = fields[AlarmFieldSeverity].Value is ushort sev ? sev : (ushort)0;
|
var severity = UpstreamValueMapping.ToClr(fields[AlarmFieldSeverity]) is ushort sev ? sev : (ushort)0;
|
||||||
var time = fields[AlarmFieldTime].Value is DateTime t ? t : DateTime.UtcNow;
|
var time = UpstreamValueMapping.ToClr(fields[AlarmFieldTime]) is DateTime t ? t : DateTime.UtcNow;
|
||||||
|
|
||||||
if (sourceIndex.Count != 0 && upstreamCondition.Length > 0)
|
if (sourceIndex.Count != 0 && upstreamCondition.Length > 0)
|
||||||
rememberCondition(reported, new AlarmAckTarget(
|
rememberCondition(reported, new AlarmAckTarget(
|
||||||
upstreamCondition,
|
upstreamCondition,
|
||||||
// A copy: the decoded notification's array is not ours to alias for the ack's lifetime.
|
// A copy: the decoded notification's array is not ours to alias for the ack's lifetime.
|
||||||
(fields[AlarmFieldEventId].Value as byte[])?.ToArray() ?? Array.Empty<byte>()));
|
(UpstreamValueMapping.ToClr(fields[AlarmFieldEventId]) as byte[])?.ToArray() ?? Array.Empty<byte>()));
|
||||||
|
|
||||||
return new AlarmEventArgs(
|
return new AlarmEventArgs(
|
||||||
SubscriptionHandle: handle,
|
SubscriptionHandle: handle,
|
||||||
@@ -3166,10 +3197,10 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
|||||||
catch { /* best-effort — SDK may have already cleared its invocation lists on session loss */ }
|
catch { /* best-effort — SDK may have already cleared its invocation lists on session loss */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int CountNotifications(ExtensionObject nd) => nd.Body switch
|
private static int CountNotifications(ExtensionObject nd) => (nd.TryGetValue(out IEncodeable? body) ? body : null) switch
|
||||||
{
|
{
|
||||||
DataChangeNotification dc => dc.MonitoredItems?.Count ?? 0,
|
DataChangeNotification dc => dc.MonitoredItems.Count,
|
||||||
EventNotificationList ev => ev.Events?.Count ?? 0,
|
EventNotificationList ev => ev.Events.Count,
|
||||||
StatusChangeNotification => 1,
|
StatusChangeNotification => 1,
|
||||||
_ => 1,
|
_ => 1,
|
||||||
};
|
};
|
||||||
@@ -3268,7 +3299,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
|||||||
PublishCommsLoss(now);
|
PublishCommsLoss(now);
|
||||||
}
|
}
|
||||||
if (definitive)
|
if (definitive)
|
||||||
RaiseConnectionLost($"upstream session keep-alive reported {StatusCodes.GetBrowseName(keepAliveCode.Code)}");
|
RaiseConnectionLost($"upstream session keep-alive reported {keepAliveCode.SymbolicId}");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -3696,8 +3727,8 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
|||||||
/// hook synchronously (no async work on this hot path), then fires the cert-validation
|
/// hook synchronously (no async work on this hot path), then fires the cert-validation
|
||||||
/// handler detach. The async session-close is intentionally skipped — it requires a
|
/// handler detach. The async session-close is intentionally skipped — it requires a
|
||||||
/// live session + network round-trip and is unsafe to block-on from a potentially
|
/// live session + network round-trip and is unsafe to block-on from a potentially
|
||||||
/// single-threaded context (OPC UA stack thread). The session will be cleaned up by
|
/// single-threaded context (OPC UA stack thread). Local session and certificate-manager
|
||||||
/// the SDK's own finalizer on GC (no sync-over-async).
|
/// resources are disposed synchronously.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
@@ -3715,15 +3746,20 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
|||||||
try { Session.KeepAlive -= _keepAliveHandler; } catch { }
|
try { Session.KeepAlive -= _keepAliveHandler; } catch { }
|
||||||
}
|
}
|
||||||
_keepAliveHandler = null;
|
_keepAliveHandler = null;
|
||||||
|
var session = Session;
|
||||||
Session = null;
|
Session = null;
|
||||||
|
if (session is not null) DetachDiagnostics(session);
|
||||||
|
try { session?.Dispose(); } catch { }
|
||||||
|
try { (_ownedApplication?.ApplicationConfiguration?.CertificateManager as IDisposable)?.Dispose(); } catch { }
|
||||||
|
_ownedApplication = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detach the cert-validation handler registered during InitializeAsync so the
|
// Detach the cert-validation handler registered during InitializeAsync so the
|
||||||
// CertificateValidator (which may be process-shared) doesn't hold a reference to
|
// ICertificateManager (which may be process-shared) doesn't hold a reference to
|
||||||
// a disposed driver.
|
// a disposed driver.
|
||||||
if (_certValidationHandler is not null && _certValidatorRef is not null)
|
if (_certValidationHandler is not null && _certValidatorRef is not null)
|
||||||
{
|
{
|
||||||
try { _certValidatorRef.CertificateValidation -= _certValidationHandler; } catch { }
|
try { if (ReferenceEquals(_certValidatorRef.AcceptError, _certValidationHandler)) _certValidatorRef.AcceptError = null; } catch { }
|
||||||
_certValidationHandler = null;
|
_certValidationHandler = null;
|
||||||
_certValidatorRef = null;
|
_certValidatorRef = null;
|
||||||
}
|
}
|
||||||
@@ -3764,7 +3800,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
|||||||
// Detach the cert-validation handler.
|
// Detach the cert-validation handler.
|
||||||
if (_certValidationHandler is not null && _certValidatorRef is not null)
|
if (_certValidationHandler is not null && _certValidatorRef is not null)
|
||||||
{
|
{
|
||||||
try { _certValidatorRef.CertificateValidation -= _certValidationHandler; } catch { }
|
try { if (ReferenceEquals(_certValidatorRef.AcceptError, _certValidationHandler)) _certValidatorRef.AcceptError = null; } catch { }
|
||||||
_certValidationHandler = null;
|
_certValidationHandler = null;
|
||||||
_certValidatorRef = null;
|
_certValidatorRef = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ public sealed class OpcUaClientDriverProbe : IDriverProbe
|
|||||||
var appConfig = BuildMinimalAppConfig();
|
var appConfig = BuildMinimalAppConfig();
|
||||||
using var client = await DiscoveryClient.CreateAsync(
|
using var client = await DiscoveryClient.CreateAsync(
|
||||||
appConfig, new Uri(endpointUrl), DiagnosticsMasks.None, cts.Token).ConfigureAwait(false);
|
appConfig, new Uri(endpointUrl), DiagnosticsMasks.None, cts.Token).ConfigureAwait(false);
|
||||||
var endpoints = await client.GetEndpointsAsync(null, cts.Token).ConfigureAwait(false);
|
var endpoints = await client.GetEndpointsAsync(default, cts.Token).ConfigureAwait(false);
|
||||||
sw.Stop();
|
sw.Stop();
|
||||||
|
|
||||||
if (endpoints is { Count: > 0 })
|
if (endpoints is { Count: > 0 })
|
||||||
@@ -130,7 +130,7 @@ public sealed class OpcUaClientDriverProbe : IDriverProbe
|
|||||||
/// is plain-text and does not require a client certificate.
|
/// is plain-text and does not require a client certificate.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static ApplicationConfiguration BuildMinimalAppConfig() =>
|
private static ApplicationConfiguration BuildMinimalAppConfig() =>
|
||||||
new()
|
new(SdkTelemetry.Context)
|
||||||
{
|
{
|
||||||
ApplicationName = "OtOpcUa.Probe",
|
ApplicationName = "OtOpcUa.Probe",
|
||||||
ApplicationType = ApplicationType.Client,
|
ApplicationType = ApplicationType.Client,
|
||||||
|
|||||||
-1
@@ -21,7 +21,6 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.MigrationAnalyzer" PrivateAssets="all" IncludeAssets="analyzers;build;buildtransitive" />
|
|
||||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client"/>
|
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client"/>
|
||||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Configuration"/>
|
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Configuration"/>
|
||||||
<PackageReference Include="ZB.MOM.WW.Secrets.Abstractions"/>
|
<PackageReference Include="ZB.MOM.WW.Secrets.Abstractions"/>
|
||||||
|
|||||||
+10
-10
@@ -20,23 +20,23 @@ public sealed class OpcUaClientBrowseSessionAttributesTests
|
|||||||
private static Mock<ISession> SessionReturning(NodeClass nodeClass, NodeId dataType, int valueRank, byte userAccess)
|
private static Mock<ISession> SessionReturning(NodeClass nodeClass, NodeId dataType, int valueRank, byte userAccess)
|
||||||
{
|
{
|
||||||
var mock = new Mock<ISession>(MockBehavior.Loose);
|
var mock = new Mock<ISession>(MockBehavior.Loose);
|
||||||
mock.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext(BrowseTelemetry.Context));
|
mock.SetupGet(s => s.MessageContext).Returns(ServiceMessageContext.Create(BrowseTelemetry.Context));
|
||||||
mock.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
|
mock.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
|
||||||
mock.Setup(s => s.ReadAsync(
|
mock.Setup(s => s.ReadAsync(
|
||||||
It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
|
It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
|
||||||
It.IsAny<ReadValueIdCollection>(), It.IsAny<CancellationToken>()))
|
It.IsAny<ArrayOf<ReadValueId>>(), It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync((RequestHeader _, double _, TimestampsToReturn _, ReadValueIdCollection nodes, CancellationToken _) =>
|
.ReturnsAsync((RequestHeader _, double _, TimestampsToReturn _, ArrayOf<ReadValueId> nodes, CancellationToken _) =>
|
||||||
new ReadResponse
|
new ReadResponse
|
||||||
{
|
{
|
||||||
ResponseHeader = new ResponseHeader(),
|
ResponseHeader = new ResponseHeader(),
|
||||||
Results = [.. nodes.Select(n => n.AttributeId switch
|
Results = [.. nodes.ToList().Select(n => n.AttributeId switch
|
||||||
{
|
{
|
||||||
Attributes.NodeClass => new DataValue(new Variant((int)nodeClass)),
|
Attributes.NodeClass => new DataValue(new Variant((int)nodeClass)),
|
||||||
Attributes.DisplayName => new DataValue(new Variant(new LocalizedText("Speed"))),
|
Attributes.DisplayName => new DataValue(new Variant(new LocalizedText("Speed"))),
|
||||||
Attributes.DataType => new DataValue(new Variant(dataType)),
|
Attributes.DataType => new DataValue(new Variant(dataType)),
|
||||||
Attributes.ValueRank => new DataValue(new Variant(valueRank)),
|
Attributes.ValueRank => new DataValue(new Variant(valueRank)),
|
||||||
Attributes.UserAccessLevel => new DataValue(new Variant(userAccess)),
|
Attributes.UserAccessLevel => new DataValue(new Variant(userAccess)),
|
||||||
_ => new DataValue(StatusCodes.BadAttributeIdInvalid),
|
_ => DataValue.FromStatusCode(StatusCodes.BadAttributeIdInvalid),
|
||||||
})],
|
})],
|
||||||
DiagnosticInfos = [],
|
DiagnosticInfos = [],
|
||||||
});
|
});
|
||||||
@@ -101,19 +101,19 @@ public sealed class OpcUaClientBrowseSessionAttributesTests
|
|||||||
session.AttributesAsync("nsu=urn:not-in-table;s=X", TestContext.Current.CancellationToken));
|
session.AttributesAsync("nsu=urn:not-in-table;s=X", TestContext.Current.CancellationToken));
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class TrackingCredential : IDisposable
|
private sealed class TrackingApplication : IAsyncDisposable
|
||||||
{
|
{
|
||||||
public int Disposed { get; private set; }
|
public int Disposed { get; private set; }
|
||||||
|
|
||||||
public void Dispose() => Disposed++;
|
public ValueTask DisposeAsync() { Disposed++; return ValueTask.CompletedTask; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Review m9: the browse session disposes the user certificate it was handed, with itself.</summary>
|
/// <summary>Review m9: the browse session disposes the SDK application it was handed, with itself.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task The_session_disposes_the_credential_it_owns()
|
public async Task The_session_disposes_the_application_it_owns()
|
||||||
{
|
{
|
||||||
var mock = SessionReturning(NodeClass.Variable, DataTypeIds.Int32, -1, 1);
|
var mock = SessionReturning(NodeClass.Variable, DataTypeIds.Int32, -1, 1);
|
||||||
var credential = new TrackingCredential();
|
var credential = new TrackingApplication();
|
||||||
var session = new OpcUaClientBrowseSession(
|
var session = new OpcUaClientBrowseSession(
|
||||||
mock.Object, NamespaceMap.FromTable(new NamespaceTable()), ObjectIds.ObjectsFolder, credential);
|
mock.Object, NamespaceMap.FromTable(new NamespaceTable()), ObjectIds.ObjectsFolder, credential);
|
||||||
|
|
||||||
|
|||||||
+15
-15
@@ -20,7 +20,7 @@ public sealed class OpcUaClientBrowseSessionPaginationTests
|
|||||||
private static Mock<ISession> BuildSession(bool cancelNext)
|
private static Mock<ISession> BuildSession(bool cancelNext)
|
||||||
{
|
{
|
||||||
var mock = new Mock<ISession>(MockBehavior.Loose);
|
var mock = new Mock<ISession>(MockBehavior.Loose);
|
||||||
mock.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext(BrowseTelemetry.Context));
|
mock.SetupGet(s => s.MessageContext).Returns(ServiceMessageContext.Create(BrowseTelemetry.Context));
|
||||||
mock.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
|
mock.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
|
||||||
|
|
||||||
var firstRef = new ReferenceDescription
|
var firstRef = new ReferenceDescription
|
||||||
@@ -32,44 +32,44 @@ public sealed class OpcUaClientBrowseSessionPaginationTests
|
|||||||
};
|
};
|
||||||
mock.Setup(s => s.BrowseAsync(
|
mock.Setup(s => s.BrowseAsync(
|
||||||
It.IsAny<RequestHeader>(), It.IsAny<ViewDescription>(), It.IsAny<uint>(),
|
It.IsAny<RequestHeader>(), It.IsAny<ViewDescription>(), It.IsAny<uint>(),
|
||||||
It.IsAny<BrowseDescriptionCollection>(), It.IsAny<CancellationToken>()))
|
It.IsAny<ArrayOf<BrowseDescription>>(), It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new BrowseResponse
|
.ReturnsAsync(new BrowseResponse
|
||||||
{
|
{
|
||||||
ResponseHeader = new ResponseHeader(),
|
ResponseHeader = new ResponseHeader(),
|
||||||
Results = new BrowseResultCollection
|
Results = new List<BrowseResult>
|
||||||
{
|
{
|
||||||
new BrowseResult
|
new BrowseResult
|
||||||
{
|
{
|
||||||
ContinuationPoint = FakeContinuationPoint,
|
ContinuationPoint = FakeContinuationPoint.ToByteString(),
|
||||||
References = new ReferenceDescriptionCollection { firstRef },
|
References = new List<ReferenceDescription> { firstRef },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
DiagnosticInfos = new DiagnosticInfoCollection(),
|
DiagnosticInfos = new List<DiagnosticInfo>(),
|
||||||
});
|
});
|
||||||
|
|
||||||
var emptyPage = new BrowseNextResponse
|
var emptyPage = new BrowseNextResponse
|
||||||
{
|
{
|
||||||
ResponseHeader = new ResponseHeader(),
|
ResponseHeader = new ResponseHeader(),
|
||||||
Results = new BrowseResultCollection
|
Results = new List<BrowseResult>
|
||||||
{
|
{
|
||||||
new BrowseResult
|
new BrowseResult
|
||||||
{
|
{
|
||||||
ContinuationPoint = Array.Empty<byte>(),
|
ContinuationPoint = ByteString.Empty,
|
||||||
References = new ReferenceDescriptionCollection(),
|
References = new List<ReferenceDescription>(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
DiagnosticInfos = new DiagnosticInfoCollection(),
|
DiagnosticInfos = new List<DiagnosticInfo>(),
|
||||||
};
|
};
|
||||||
|
|
||||||
var pageSetup = mock.Setup(s => s.BrowseNextAsync(
|
var pageSetup = mock.Setup(s => s.BrowseNextAsync(
|
||||||
It.IsAny<RequestHeader>(), It.Is<bool>(r => !r),
|
It.IsAny<RequestHeader>(), It.Is<bool>(r => !r),
|
||||||
It.IsAny<ByteStringCollection>(), It.IsAny<CancellationToken>()));
|
It.IsAny<ArrayOf<ByteString>>(), It.IsAny<CancellationToken>()));
|
||||||
if (cancelNext) pageSetup.ThrowsAsync(new OperationCanceledException("cancelled mid-page"));
|
if (cancelNext) pageSetup.ThrowsAsync(new OperationCanceledException("cancelled mid-page"));
|
||||||
else pageSetup.ReturnsAsync(emptyPage);
|
else pageSetup.ReturnsAsync(emptyPage);
|
||||||
|
|
||||||
mock.Setup(s => s.BrowseNextAsync(
|
mock.Setup(s => s.BrowseNextAsync(
|
||||||
It.IsAny<RequestHeader>(), It.Is<bool>(r => r),
|
It.IsAny<RequestHeader>(), It.Is<bool>(r => r),
|
||||||
It.IsAny<ByteStringCollection>(), It.IsAny<CancellationToken>()))
|
It.IsAny<ArrayOf<ByteString>>(), It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(emptyPage);
|
.ReturnsAsync(emptyPage);
|
||||||
return mock;
|
return mock;
|
||||||
}
|
}
|
||||||
@@ -89,7 +89,7 @@ public sealed class OpcUaClientBrowseSessionPaginationTests
|
|||||||
|
|
||||||
mock.Verify(s => s.BrowseNextAsync(
|
mock.Verify(s => s.BrowseNextAsync(
|
||||||
It.IsAny<RequestHeader>(), It.Is<bool>(r => r),
|
It.IsAny<RequestHeader>(), It.Is<bool>(r => r),
|
||||||
It.IsAny<ByteStringCollection>(), It.IsAny<CancellationToken>()), Times.Once);
|
It.IsAny<ArrayOf<ByteString>>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Positive control: an uncancelled pagination never issues a release call.</summary>
|
/// <summary>Positive control: an uncancelled pagination never issues a release call.</summary>
|
||||||
@@ -104,9 +104,9 @@ public sealed class OpcUaClientBrowseSessionPaginationTests
|
|||||||
nodes.Count.ShouldBe(1);
|
nodes.Count.ShouldBe(1);
|
||||||
mock.Verify(s => s.BrowseNextAsync(
|
mock.Verify(s => s.BrowseNextAsync(
|
||||||
It.IsAny<RequestHeader>(), It.Is<bool>(r => !r),
|
It.IsAny<RequestHeader>(), It.Is<bool>(r => !r),
|
||||||
It.IsAny<ByteStringCollection>(), It.IsAny<CancellationToken>()), Times.Once);
|
It.IsAny<ArrayOf<ByteString>>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||||
mock.Verify(s => s.BrowseNextAsync(
|
mock.Verify(s => s.BrowseNextAsync(
|
||||||
It.IsAny<RequestHeader>(), It.Is<bool>(r => r),
|
It.IsAny<RequestHeader>(), It.Is<bool>(r => r),
|
||||||
It.IsAny<ByteStringCollection>(), It.IsAny<CancellationToken>()), Times.Never);
|
It.IsAny<ArrayOf<ByteString>>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -120,7 +120,7 @@ public sealed class OpcUaClientDriverBrowserTests
|
|||||||
// A loose mock answers the attribute read with no response, so nothing is known about the node.
|
// A loose mock answers the attribute read with no response, so nothing is known about the node.
|
||||||
var mockSession = new Mock<ISession>(MockBehavior.Loose);
|
var mockSession = new Mock<ISession>(MockBehavior.Loose);
|
||||||
mockSession.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
|
mockSession.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
|
||||||
mockSession.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext(BrowseTelemetry.Context));
|
mockSession.SetupGet(s => s.MessageContext).Returns(ServiceMessageContext.Create(BrowseTelemetry.Context));
|
||||||
var nsMap = NamespaceMap.FromTable(new NamespaceTable());
|
var nsMap = NamespaceMap.FromTable(new NamespaceTable());
|
||||||
var sut = new OpcUaClientBrowseSession(mockSession.Object, nsMap, ObjectIds.ObjectsFolder);
|
var sut = new OpcUaClientBrowseSession(mockSession.Object, nsMap, ObjectIds.ObjectsFolder);
|
||||||
|
|
||||||
|
|||||||
+28
-8
@@ -21,7 +21,7 @@ public sealed class OpcUaClientAcknowledgeFailureTests
|
|||||||
private static Mock<ISession> NewSession()
|
private static Mock<ISession> NewSession()
|
||||||
{
|
{
|
||||||
var mock = new Mock<ISession>(MockBehavior.Loose);
|
var mock = new Mock<ISession>(MockBehavior.Loose);
|
||||||
mock.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext(SdkTelemetry.Context));
|
mock.SetupGet(s => s.MessageContext).Returns(ServiceMessageContext.Create(SdkTelemetry.Context));
|
||||||
mock.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
|
mock.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
|
||||||
return mock;
|
return mock;
|
||||||
}
|
}
|
||||||
@@ -34,7 +34,7 @@ public sealed class OpcUaClientAcknowledgeFailureTests
|
|||||||
{
|
{
|
||||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-o8");
|
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-o8");
|
||||||
var session = NewSession();
|
var session = NewSession();
|
||||||
session.Setup(s => s.CallAsync(It.IsAny<RequestHeader>(), It.IsAny<CallMethodRequestCollection>(), It.IsAny<CancellationToken>()))
|
session.Setup(s => s.CallAsync(It.IsAny<RequestHeader>(), It.IsAny<ArrayOf<CallMethodRequest>>(), It.IsAny<CancellationToken>()))
|
||||||
.ThrowsAsync(new ServiceResultException(StatusCodes.BadConnectionClosed));
|
.ThrowsAsync(new ServiceResultException(StatusCodes.BadConnectionClosed));
|
||||||
drv.SetSessionForTest(session.Object);
|
drv.SetSessionForTest(session.Object);
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ public sealed class OpcUaClientAcknowledgeFailureTests
|
|||||||
{
|
{
|
||||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-o8-ok");
|
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-o8-ok");
|
||||||
var session = NewSession();
|
var session = NewSession();
|
||||||
session.Setup(s => s.CallAsync(It.IsAny<RequestHeader>(), It.IsAny<CallMethodRequestCollection>(), It.IsAny<CancellationToken>()))
|
session.Setup(s => s.CallAsync(It.IsAny<RequestHeader>(), It.IsAny<ArrayOf<CallMethodRequest>>(), It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new CallResponse
|
.ReturnsAsync(new CallResponse
|
||||||
{
|
{
|
||||||
ResponseHeader = new ResponseHeader(),
|
ResponseHeader = new ResponseHeader(),
|
||||||
@@ -61,10 +61,30 @@ public sealed class OpcUaClientAcknowledgeFailureTests
|
|||||||
|
|
||||||
await drv.AcknowledgeAsync(Ack(), TestContext.Current.CancellationToken);
|
await drv.AcknowledgeAsync(Ack(), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
session.Verify(s => s.CallAsync(It.IsAny<RequestHeader>(), It.IsAny<CallMethodRequestCollection>(), It.IsAny<CancellationToken>()),
|
session.Verify(s => s.CallAsync(It.IsAny<RequestHeader>(), It.IsAny<ArrayOf<CallMethodRequest>>(), It.IsAny<CancellationToken>()),
|
||||||
Times.Once);
|
Times.Once);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Acknowledge_encodes_EventId_as_scalar_ByteString_and_never_retries_a_transport_failure()
|
||||||
|
{
|
||||||
|
using var driver = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-ack-types");
|
||||||
|
var session = NewSession();
|
||||||
|
ArrayOf<CallMethodRequest> sent = default;
|
||||||
|
session.Setup(s => s.CallAsync(It.IsAny<RequestHeader>(), It.IsAny<ArrayOf<CallMethodRequest>>(), It.IsAny<CancellationToken>()))
|
||||||
|
.Callback<RequestHeader, ArrayOf<CallMethodRequest>, CancellationToken>((_, calls, _) => sent = calls)
|
||||||
|
.ThrowsAsync(new ServiceResultException(StatusCodes.BadConnectionClosed));
|
||||||
|
driver.SetSessionForTest(session.Object);
|
||||||
|
await Should.ThrowAsync<ServiceResultException>(() => driver.AcknowledgeAsync(Ack(), TestContext.Current.CancellationToken));
|
||||||
|
var arguments = sent[0].InputArguments;
|
||||||
|
arguments[0].TypeInfo.BuiltInType.ShouldBe(BuiltInType.ByteString);
|
||||||
|
arguments[0].TypeInfo.ValueRank.ShouldBe(ValueRanks.Scalar);
|
||||||
|
arguments[0].GetByteString().IsEmpty.ShouldBeTrue();
|
||||||
|
arguments[1].TypeInfo.BuiltInType.ShouldBe(BuiltInType.LocalizedText);
|
||||||
|
arguments[1].GetLocalizedText().Text.ShouldBe("operator ack");
|
||||||
|
session.Verify(s => s.CallAsync(It.IsAny<RequestHeader>(), It.IsAny<ArrayOf<CallMethodRequest>>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>A session lost while the ack queued on the gate fails the ack rather than reporting it applied.</summary>
|
/// <summary>A session lost while the ack queued on the gate fails the ack rather than reporting it applied.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task A_session_lost_while_queued_fails_the_ack()
|
public async Task A_session_lost_while_queued_fails_the_ack()
|
||||||
@@ -110,7 +130,7 @@ public sealed class OpcUaClientAcknowledgeFailureTests
|
|||||||
|
|
||||||
await drv.AcknowledgeAsync(Unresolvable(), TestContext.Current.CancellationToken);
|
await drv.AcknowledgeAsync(Unresolvable(), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
session.Verify(s => s.CallAsync(It.IsAny<RequestHeader>(), It.IsAny<CallMethodRequestCollection>(), It.IsAny<CancellationToken>()),
|
session.Verify(s => s.CallAsync(It.IsAny<RequestHeader>(), It.IsAny<ArrayOf<CallMethodRequest>>(), It.IsAny<CancellationToken>()),
|
||||||
Times.Never);
|
Times.Never);
|
||||||
lock (logger.Entries)
|
lock (logger.Entries)
|
||||||
logger.Entries.ShouldContain(e => e.Level == Microsoft.Extensions.Logging.LogLevel.Warning && e.Message.Contains("resolved none"));
|
logger.Entries.ShouldContain(e => e.Level == Microsoft.Extensions.Logging.LogLevel.Warning && e.Message.Contains("resolved none"));
|
||||||
@@ -127,7 +147,7 @@ public sealed class OpcUaClientAcknowledgeFailureTests
|
|||||||
var ct = TestContext.Current.CancellationToken;
|
var ct = TestContext.Current.CancellationToken;
|
||||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-n1");
|
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-n1");
|
||||||
var session = NewSession();
|
var session = NewSession();
|
||||||
session.Setup(s => s.CallAsync(It.IsAny<RequestHeader>(), It.IsAny<CallMethodRequestCollection>(), It.IsAny<CancellationToken>()))
|
session.Setup(s => s.CallAsync(It.IsAny<RequestHeader>(), It.IsAny<ArrayOf<CallMethodRequest>>(), It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new CallResponse
|
.ReturnsAsync(new CallResponse
|
||||||
{
|
{
|
||||||
ResponseHeader = new ResponseHeader(),
|
ResponseHeader = new ResponseHeader(),
|
||||||
@@ -154,7 +174,7 @@ public sealed class OpcUaClientAcknowledgeFailureTests
|
|||||||
await invoker.ExecuteAsync(DriverCapability.AlarmAcknowledge, "host",
|
await invoker.ExecuteAsync(DriverCapability.AlarmAcknowledge, "host",
|
||||||
async c => await drv.AcknowledgeAsync(Ack(), c), ct);
|
async c => await drv.AcknowledgeAsync(Ack(), c), ct);
|
||||||
|
|
||||||
session.Verify(s => s.CallAsync(It.IsAny<RequestHeader>(), It.IsAny<CallMethodRequestCollection>(), It.IsAny<CancellationToken>()),
|
session.Verify(s => s.CallAsync(It.IsAny<RequestHeader>(), It.IsAny<ArrayOf<CallMethodRequest>>(), It.IsAny<CancellationToken>()),
|
||||||
Times.Once, "the genuine ack must still reach the server");
|
Times.Once, "the genuine ack must still reach the server");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,7 +186,7 @@ public sealed class OpcUaClientAcknowledgeFailureTests
|
|||||||
var stamp = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
var stamp = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||||
drv.SetHealthForTest(new DriverHealth(DriverState.Healthy, stamp, null));
|
drv.SetHealthForTest(new DriverHealth(DriverState.Healthy, stamp, null));
|
||||||
var session = NewSession();
|
var session = NewSession();
|
||||||
session.Setup(s => s.CallAsync(It.IsAny<RequestHeader>(), It.IsAny<CallMethodRequestCollection>(), It.IsAny<CancellationToken>()))
|
session.Setup(s => s.CallAsync(It.IsAny<RequestHeader>(), It.IsAny<ArrayOf<CallMethodRequest>>(), It.IsAny<CancellationToken>()))
|
||||||
.ThrowsAsync(new ServiceResultException(StatusCodes.BadConnectionClosed));
|
.ThrowsAsync(new ServiceResultException(StatusCodes.BadConnectionClosed));
|
||||||
drv.SetSessionForTest(session.Object);
|
drv.SetSessionForTest(session.Object);
|
||||||
|
|
||||||
|
|||||||
@@ -76,9 +76,9 @@ public sealed class OpcUaClientAlarmTests
|
|||||||
|
|
||||||
// ---- #619: v3 RawPath <-> upstream-source translation on the native-alarm path ----
|
// ---- #619: v3 RawPath <-> upstream-source translation on the native-alarm path ----
|
||||||
|
|
||||||
private static VariantCollection EventFields(string sourceNode, string conditionNode) =>
|
private static List<Variant> EventFields(string sourceNode, string conditionNode) =>
|
||||||
[
|
[
|
||||||
new Variant(new byte[] { 1 }), // EventId
|
Variant.From(new byte[] { 1 }.ToByteString()), // scalar ByteString EventId
|
||||||
new Variant(new NodeId("TripAlarmType", 3)), // EventType
|
new Variant(new NodeId("TripAlarmType", 3)), // EventType
|
||||||
new Variant(NodeId.Parse(sourceNode)), // SourceNode
|
new Variant(NodeId.Parse(sourceNode)), // SourceNode
|
||||||
new Variant(new LocalizedText("tripped")), // Message
|
new Variant(new LocalizedText("tripped")), // Message
|
||||||
@@ -310,18 +310,18 @@ public sealed class OpcUaClientAlarmTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static VariantCollection ConditionFields(
|
private static List<Variant> ConditionFields(
|
||||||
bool active, bool acked, string message = "tripped", ushort severity = 700, byte[]? eventId = null,
|
bool active, bool acked, string message = "tripped", ushort severity = 700, byte[]? eventId = null,
|
||||||
NodeId? branchId = null, NodeId? eventType = null)
|
NodeId? branchId = null, NodeId? eventType = null)
|
||||||
{
|
{
|
||||||
var f = EventFields("ns=3;s=Alarms.Trip1", "ns=3;s=Alarms.Trip1.Cond");
|
var f = EventFields("ns=3;s=Alarms.Trip1", "ns=3;s=Alarms.Trip1.Cond");
|
||||||
f[0] = new Variant(eventId ?? new byte[] { 1 });
|
f[0] = Variant.From(new ByteString((eventId ?? new byte[] { 1 }).AsMemory()));
|
||||||
if (eventType is not null) f[1] = new Variant(eventType);
|
if (eventType is not null) f[1] = Variant.From(eventType.Value);
|
||||||
f[3] = new Variant(new LocalizedText(message));
|
f[3] = new Variant(new LocalizedText(message));
|
||||||
f[4] = new Variant(severity);
|
f[4] = new Variant(severity);
|
||||||
f.Add(new Variant(active));
|
f.Add(new Variant(active));
|
||||||
f.Add(new Variant(acked));
|
f.Add(new Variant(acked));
|
||||||
f.Add(branchId is null ? Variant.Null : new Variant(branchId));
|
f.Add(branchId is null ? Variant.Null : Variant.From(branchId.Value));
|
||||||
return f;
|
return f;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,7 +382,7 @@ public sealed class OpcUaClientAlarmTests
|
|||||||
OpcUaClientDriver.IsRefreshRequired(EventFieldsOfType(ObjectTypeIds.RefreshEndEventType)).ShouldBeFalse();
|
OpcUaClientDriver.IsRefreshRequired(EventFieldsOfType(ObjectTypeIds.RefreshEndEventType)).ShouldBeFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static VariantCollection EventFieldsOfType(NodeId type)
|
private static List<Variant> EventFieldsOfType(NodeId type)
|
||||||
{
|
{
|
||||||
var f = EventFields("i=2253", "i=0");
|
var f = EventFields("i=2253", "i=0");
|
||||||
f[1] = new Variant(type);
|
f[1] = new Variant(type);
|
||||||
|
|||||||
@@ -65,4 +65,49 @@ public sealed class OpcUaClientCertAuthTests
|
|||||||
try { File.Delete(tmpPath); } catch { /* best-effort */ }
|
try { File.Delete(tmpPath); } catch { /* best-effort */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
[Fact]
|
||||||
|
public async Task Certificate_identity_signs_repeatedly_and_requires_reopen_after_file_rotation()
|
||||||
|
{
|
||||||
|
var path = Path.Combine(Path.GetTempPath(), $"opcua-cert-rotation-{Guid.NewGuid():N}.pfx");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var key = RSA.Create(2048);
|
||||||
|
var request = new CertificateRequest("CN=OpcUaSdk2Signing", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||||
|
using var original = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddHours(1));
|
||||||
|
File.WriteAllBytes(path, original.Export(X509ContentType.Pfx, "test-password"));
|
||||||
|
var options = new OpcUaClientDriverOptions { AuthType = OpcUaAuthType.Certificate,
|
||||||
|
UserCertificatePath = path, UserCertificatePassword = "test-password" };
|
||||||
|
var identity = OpcUaClientDriver.BuildCertificateIdentity(options);
|
||||||
|
byte[] challenge = [1, 2, 3, 4];
|
||||||
|
for (var i = 0; i < 2; i++)
|
||||||
|
{
|
||||||
|
var signature = await identity.TokenHandler.SignAsync(challenge, Opc.Ua.SecurityPolicies.Basic256Sha256,
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
using var publicKey = original.GetRSAPublicKey();
|
||||||
|
publicKey.ShouldNotBeNull().VerifyData(challenge, signature.Signature.Span,
|
||||||
|
HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1).ShouldBeTrue();
|
||||||
|
}
|
||||||
|
using var cancelled = new CancellationTokenSource();
|
||||||
|
cancelled.Cancel();
|
||||||
|
await Should.ThrowAsync<OperationCanceledException>(async () =>
|
||||||
|
await identity.TokenHandler.SignAsync(challenge, Opc.Ua.SecurityPolicies.Basic256Sha256, cancelled.Token));
|
||||||
|
|
||||||
|
using var rotatedKey = RSA.Create(2048);
|
||||||
|
var rotatedRequest = new CertificateRequest("CN=OpcUaSdk2Signing", rotatedKey, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||||
|
using var rotated = rotatedRequest.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddHours(1));
|
||||||
|
File.WriteAllBytes(path, rotated.Export(X509ContentType.Pfx, "test-password"));
|
||||||
|
var error = await Should.ThrowAsync<Opc.Ua.ServiceResultException>(async () =>
|
||||||
|
await identity.TokenHandler.SignAsync(challenge, Opc.Ua.SecurityPolicies.Basic256Sha256,
|
||||||
|
TestContext.Current.CancellationToken));
|
||||||
|
error.StatusCode.ShouldBe(Opc.Ua.StatusCodes.BadIdentityTokenInvalid);
|
||||||
|
var fresh = OpcUaClientDriver.BuildCertificateIdentity(options);
|
||||||
|
var freshSignature = await fresh.TokenHandler.SignAsync(challenge, Opc.Ua.SecurityPolicies.Basic256Sha256,
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
using var rotatedPublicKey = rotated.GetRSAPublicKey();
|
||||||
|
rotatedPublicKey.ShouldNotBeNull().VerifyData(challenge, freshSignature.Signature.Span,
|
||||||
|
HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1).ShouldBeTrue();
|
||||||
|
}
|
||||||
|
finally { File.Delete(path); }
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-22
@@ -59,7 +59,7 @@ public sealed class OpcUaClientConnectionLossSignalTests
|
|||||||
private static Mock<ISession> NewSession()
|
private static Mock<ISession> NewSession()
|
||||||
{
|
{
|
||||||
var mock = new Mock<ISession>(MockBehavior.Loose);
|
var mock = new Mock<ISession>(MockBehavior.Loose);
|
||||||
mock.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext(SdkTelemetry.Context));
|
mock.SetupGet(s => s.MessageContext).Returns(ServiceMessageContext.Create(SdkTelemetry.Context));
|
||||||
mock.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
|
mock.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
|
||||||
return mock;
|
return mock;
|
||||||
}
|
}
|
||||||
@@ -77,18 +77,18 @@ public sealed class OpcUaClientConnectionLossSignalTests
|
|||||||
return (drv, session, clock, log);
|
return (drv, session, clock, log);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static DataValue Good(object value, DateTime ts) => new(new Variant(value), StatusCodes.Good, ts, ts);
|
private static DataValue Good(object value, DateTime ts) => new(VariantHelper.CastFrom(value), StatusCodes.Good, ts, ts);
|
||||||
|
|
||||||
/// <summary>Every read returns <paramref name="values"/> (one per requested node, in order) at <paramref name="at"/>.</summary>
|
/// <summary>Every read returns <paramref name="values"/> (one per requested node, in order) at <paramref name="at"/>.</summary>
|
||||||
private static void SetupRead(Mock<ISession> session, Func<DateTime> at, params object[] values) =>
|
private static void SetupRead(Mock<ISession> session, Func<DateTime> at, params object[] values) =>
|
||||||
session.Setup(s => s.ReadAsync(
|
session.Setup(s => s.ReadAsync(
|
||||||
It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
|
It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
|
||||||
It.IsAny<ReadValueIdCollection>(), It.IsAny<CancellationToken>()))
|
It.IsAny<ArrayOf<ReadValueId>>(), It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync((RequestHeader _, double _, TimestampsToReturn _, ReadValueIdCollection nodes, CancellationToken _) =>
|
.ReturnsAsync((RequestHeader _, double _, TimestampsToReturn _, ArrayOf<ReadValueId> nodes, CancellationToken _) =>
|
||||||
new ReadResponse
|
new ReadResponse
|
||||||
{
|
{
|
||||||
ResponseHeader = new ResponseHeader(),
|
ResponseHeader = new ResponseHeader(),
|
||||||
Results = [.. nodes.Select((_, i) => Good(values[i], at()))],
|
Results = [.. nodes.ToList().Select((_, i) => Good(values[i], at()))],
|
||||||
DiagnosticInfos = [],
|
DiagnosticInfos = [],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -96,18 +96,18 @@ public sealed class OpcUaClientConnectionLossSignalTests
|
|||||||
|
|
||||||
public static TheoryData<uint> DefinitiveStatuses =>
|
public static TheoryData<uint> DefinitiveStatuses =>
|
||||||
[
|
[
|
||||||
StatusCodes.BadSessionIdInvalid,
|
StatusCodes.BadSessionIdInvalid.Code,
|
||||||
StatusCodes.BadSessionClosed,
|
StatusCodes.BadSessionClosed.Code,
|
||||||
StatusCodes.BadSessionNotActivated,
|
StatusCodes.BadSessionNotActivated.Code,
|
||||||
StatusCodes.BadServerHalted,
|
StatusCodes.BadServerHalted.Code,
|
||||||
];
|
];
|
||||||
|
|
||||||
public static TheoryData<uint> AmbiguousStatuses =>
|
public static TheoryData<uint> AmbiguousStatuses =>
|
||||||
[
|
[
|
||||||
StatusCodes.BadNoCommunication,
|
StatusCodes.BadNoCommunication.Code,
|
||||||
StatusCodes.BadNotConnected,
|
StatusCodes.BadNotConnected.Code,
|
||||||
StatusCodes.BadRequestTimeout,
|
StatusCodes.BadRequestTimeout.Code,
|
||||||
StatusCodes.BadTimeout,
|
StatusCodes.BadTimeout.Code,
|
||||||
];
|
];
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
@@ -127,7 +127,7 @@ public sealed class OpcUaClientConnectionLossSignalTests
|
|||||||
drv.RaiseKeepAliveForTest(StatusCodes.BadSessionIdInvalid);
|
drv.RaiseKeepAliveForTest(StatusCodes.BadSessionIdInvalid);
|
||||||
|
|
||||||
raised.ShouldHaveSingleItem("once per session, however many definitive keep-alives follow")
|
raised.ShouldHaveSingleItem("once per session, however many definitive keep-alives follow")
|
||||||
.Reason.ShouldContain(StatusCodes.GetBrowseName(status));
|
.Reason.ShouldContain(new StatusCode(status).SymbolicId!);
|
||||||
order.ShouldBe([$"bad:{BadNoCommunication:X8}", "lost"], "subscribers see the Bad before the host tears down");
|
order.ShouldBe([$"bad:{BadNoCommunication:X8}", "lost"], "subscribers see the Bad before the host tears down");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,7 +404,7 @@ public sealed class OpcUaClientConnectionLossSignalTests
|
|||||||
var reads = 0;
|
var reads = 0;
|
||||||
session.Setup(s => s.ReadAsync(
|
session.Setup(s => s.ReadAsync(
|
||||||
It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
|
It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
|
||||||
It.IsAny<ReadValueIdCollection>(), It.IsAny<CancellationToken>()))
|
It.IsAny<ArrayOf<ReadValueId>>(), It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(() =>
|
.ReturnsAsync(() =>
|
||||||
{
|
{
|
||||||
var trailing = Interlocked.Increment(ref reads) > 1;
|
var trailing = Interlocked.Increment(ref reads) > 1;
|
||||||
@@ -447,7 +447,7 @@ public sealed class OpcUaClientConnectionLossSignalTests
|
|||||||
var reads = 0;
|
var reads = 0;
|
||||||
session.Setup(s => s.ReadAsync(
|
session.Setup(s => s.ReadAsync(
|
||||||
It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
|
It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
|
||||||
It.IsAny<ReadValueIdCollection>(), It.IsAny<CancellationToken>()))
|
It.IsAny<ArrayOf<ReadValueId>>(), It.IsAny<CancellationToken>()))
|
||||||
.Returns(async () =>
|
.Returns(async () =>
|
||||||
{
|
{
|
||||||
if (Interlocked.Increment(ref reads) > 1) await gate.Task; // hold the trailing read
|
if (Interlocked.Increment(ref reads) > 1) await gate.Task; // hold the trailing read
|
||||||
@@ -495,7 +495,7 @@ public sealed class OpcUaClientConnectionLossSignalTests
|
|||||||
|
|
||||||
session.Verify(s => s.ReadAsync(
|
session.Verify(s => s.ReadAsync(
|
||||||
It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
|
It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
|
||||||
It.IsAny<ReadValueIdCollection>(), It.IsAny<CancellationToken>()), Times.Once,
|
It.IsAny<ArrayOf<ReadValueId>>(), It.IsAny<CancellationToken>()), Times.Once,
|
||||||
"only the recovery read — the trailing read must not run against a session that is no longer current");
|
"only the recovery read — the trailing read must not run against a session that is no longer current");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -506,7 +506,7 @@ public sealed class OpcUaClientConnectionLossSignalTests
|
|||||||
var reads = 0;
|
var reads = 0;
|
||||||
session.Setup(s => s.ReadAsync(
|
session.Setup(s => s.ReadAsync(
|
||||||
It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
|
It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
|
||||||
It.IsAny<ReadValueIdCollection>(), It.IsAny<CancellationToken>()))
|
It.IsAny<ArrayOf<ReadValueId>>(), It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(() => new ReadResponse
|
.ReturnsAsync(() => new ReadResponse
|
||||||
{
|
{
|
||||||
ResponseHeader = new ResponseHeader(),
|
ResponseHeader = new ResponseHeader(),
|
||||||
@@ -559,7 +559,7 @@ public sealed class OpcUaClientConnectionLossSignalTests
|
|||||||
var handle = drv.AddSubscriptionForTest([SpeedRef]);
|
var handle = drv.AddSubscriptionForTest([SpeedRef]);
|
||||||
var t0 = clock.Now.UtcDateTime;
|
var t0 = clock.Now.UtcDateTime;
|
||||||
DataValue NoSource(object v, DateTime server) =>
|
DataValue NoSource(object v, DateTime server) =>
|
||||||
new(new Variant(v), StatusCodes.Good, DateTime.MinValue, server);
|
new(VariantHelper.CastFrom(v), StatusCodes.Good, DateTime.MinValue, server);
|
||||||
drv.DeliverNotificationForTest(handle, SpeedRef, NoSource(10f, t0));
|
drv.DeliverNotificationForTest(handle, SpeedRef, NoSource(10f, t0));
|
||||||
var reads = SetupRecoveryThenTrailing(session, NoSource(10f, t0.AddSeconds(1)), NoSource(40f, t0.AddSeconds(9)));
|
var reads = SetupRecoveryThenTrailing(session, NoSource(10f, t0.AddSeconds(1)), NoSource(40f, t0.AddSeconds(9)));
|
||||||
|
|
||||||
@@ -587,7 +587,7 @@ public sealed class OpcUaClientConnectionLossSignalTests
|
|||||||
var reads = 0;
|
var reads = 0;
|
||||||
session.Setup(s => s.ReadAsync(
|
session.Setup(s => s.ReadAsync(
|
||||||
It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
|
It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
|
||||||
It.IsAny<ReadValueIdCollection>(), It.IsAny<CancellationToken>()))
|
It.IsAny<ArrayOf<ReadValueId>>(), It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(() => new ReadResponse
|
.ReturnsAsync(() => new ReadResponse
|
||||||
{
|
{
|
||||||
ResponseHeader = new ResponseHeader(),
|
ResponseHeader = new ResponseHeader(),
|
||||||
@@ -677,12 +677,12 @@ public sealed class OpcUaClientConnectionLossSignalTests
|
|||||||
drv.PostRecoveryReconcileDelay = () => TimeSpan.Zero;
|
drv.PostRecoveryReconcileDelay = () => TimeSpan.Zero;
|
||||||
var handle = drv.AddSubscriptionForTest([SpeedRef]);
|
var handle = drv.AddSubscriptionForTest([SpeedRef]);
|
||||||
var t0 = clock.Now.UtcDateTime;
|
var t0 = clock.Now.UtcDateTime;
|
||||||
DataValue NoSource(object v, DateTime server) => new(new Variant(v), StatusCodes.Good, DateTime.MinValue, server);
|
DataValue NoSource(object v, DateTime server) => new(VariantHelper.CastFrom(v), StatusCodes.Good, DateTime.MinValue, server);
|
||||||
drv.DeliverNotificationForTest(handle, SpeedRef, NoSource(10f, t0));
|
drv.DeliverNotificationForTest(handle, SpeedRef, NoSource(10f, t0));
|
||||||
var reads = 0;
|
var reads = 0;
|
||||||
session.Setup(s => s.ReadAsync(
|
session.Setup(s => s.ReadAsync(
|
||||||
It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
|
It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
|
||||||
It.IsAny<ReadValueIdCollection>(), It.IsAny<CancellationToken>()))
|
It.IsAny<ArrayOf<ReadValueId>>(), It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(() =>
|
.ReturnsAsync(() =>
|
||||||
{
|
{
|
||||||
DataValue result;
|
DataValue result;
|
||||||
|
|||||||
+21
-21
@@ -37,7 +37,7 @@ public sealed class OpcUaClientContinuationPointReleaseTests
|
|||||||
private static Mock<ISession> BuildCancellingSessionMock()
|
private static Mock<ISession> BuildCancellingSessionMock()
|
||||||
{
|
{
|
||||||
var mock = new Mock<ISession>(MockBehavior.Loose);
|
var mock = new Mock<ISession>(MockBehavior.Loose);
|
||||||
mock.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext(ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.SdkTelemetry.Context));
|
mock.SetupGet(s => s.MessageContext).Returns(ServiceMessageContext.Create(ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.SdkTelemetry.Context));
|
||||||
mock.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
|
mock.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
|
||||||
|
|
||||||
// BrowseAsync — return one Object child plus a non-empty continuation point so
|
// BrowseAsync — return one Object child plus a non-empty continuation point so
|
||||||
@@ -52,20 +52,20 @@ public sealed class OpcUaClientContinuationPointReleaseTests
|
|||||||
};
|
};
|
||||||
var browseResult = new BrowseResult
|
var browseResult = new BrowseResult
|
||||||
{
|
{
|
||||||
ContinuationPoint = FakeContinuationPoint,
|
ContinuationPoint = FakeContinuationPoint.ToByteString(),
|
||||||
References = new ReferenceDescriptionCollection { firstRef },
|
References = new List<ReferenceDescription> { firstRef },
|
||||||
};
|
};
|
||||||
var browseResp = new BrowseResponse
|
var browseResp = new BrowseResponse
|
||||||
{
|
{
|
||||||
ResponseHeader = new ResponseHeader(),
|
ResponseHeader = new ResponseHeader(),
|
||||||
Results = new BrowseResultCollection { browseResult },
|
Results = new List<BrowseResult> { browseResult },
|
||||||
DiagnosticInfos = new DiagnosticInfoCollection(),
|
DiagnosticInfos = new List<DiagnosticInfo>(),
|
||||||
};
|
};
|
||||||
mock.Setup(s => s.BrowseAsync(
|
mock.Setup(s => s.BrowseAsync(
|
||||||
It.IsAny<RequestHeader>(),
|
It.IsAny<RequestHeader>(),
|
||||||
It.IsAny<ViewDescription>(),
|
It.IsAny<ViewDescription>(),
|
||||||
It.IsAny<uint>(),
|
It.IsAny<uint>(),
|
||||||
It.IsAny<BrowseDescriptionCollection>(),
|
It.IsAny<ArrayOf<BrowseDescription>>(),
|
||||||
It.IsAny<CancellationToken>()))
|
It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(browseResp);
|
.ReturnsAsync(browseResp);
|
||||||
|
|
||||||
@@ -73,7 +73,7 @@ public sealed class OpcUaClientContinuationPointReleaseTests
|
|||||||
mock.Setup(s => s.BrowseNextAsync(
|
mock.Setup(s => s.BrowseNextAsync(
|
||||||
It.IsAny<RequestHeader>(),
|
It.IsAny<RequestHeader>(),
|
||||||
It.Is<bool>(release => !release),
|
It.Is<bool>(release => !release),
|
||||||
It.IsAny<ByteStringCollection>(),
|
It.IsAny<ArrayOf<ByteString>>(),
|
||||||
It.IsAny<CancellationToken>()))
|
It.IsAny<CancellationToken>()))
|
||||||
.ThrowsAsync(new OperationCanceledException("ct cancelled during BrowseNext"));
|
.ThrowsAsync(new OperationCanceledException("ct cancelled during BrowseNext"));
|
||||||
|
|
||||||
@@ -82,19 +82,19 @@ public sealed class OpcUaClientContinuationPointReleaseTests
|
|||||||
// there is no separate BrowseNextResult type in the OPC UA SDK.
|
// there is no separate BrowseNextResult type in the OPC UA SDK.
|
||||||
var emptyReleaseResult = new BrowseResult
|
var emptyReleaseResult = new BrowseResult
|
||||||
{
|
{
|
||||||
ContinuationPoint = Array.Empty<byte>(),
|
ContinuationPoint = ByteString.Empty,
|
||||||
References = new ReferenceDescriptionCollection(),
|
References = new List<ReferenceDescription>(),
|
||||||
};
|
};
|
||||||
var releaseResp = new BrowseNextResponse
|
var releaseResp = new BrowseNextResponse
|
||||||
{
|
{
|
||||||
ResponseHeader = new ResponseHeader(),
|
ResponseHeader = new ResponseHeader(),
|
||||||
Results = new BrowseResultCollection { emptyReleaseResult },
|
Results = new List<BrowseResult> { emptyReleaseResult },
|
||||||
DiagnosticInfos = new DiagnosticInfoCollection(),
|
DiagnosticInfos = new List<DiagnosticInfo>(),
|
||||||
};
|
};
|
||||||
mock.Setup(s => s.BrowseNextAsync(
|
mock.Setup(s => s.BrowseNextAsync(
|
||||||
It.IsAny<RequestHeader>(),
|
It.IsAny<RequestHeader>(),
|
||||||
It.Is<bool>(release => release),
|
It.Is<bool>(release => release),
|
||||||
It.IsAny<ByteStringCollection>(),
|
It.IsAny<ArrayOf<ByteString>>(),
|
||||||
It.IsAny<CancellationToken>()))
|
It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(releaseResp);
|
.ReturnsAsync(releaseResp);
|
||||||
|
|
||||||
@@ -127,7 +127,7 @@ public sealed class OpcUaClientContinuationPointReleaseTests
|
|||||||
sessionMock.Verify(s => s.BrowseNextAsync(
|
sessionMock.Verify(s => s.BrowseNextAsync(
|
||||||
It.IsAny<RequestHeader>(),
|
It.IsAny<RequestHeader>(),
|
||||||
It.Is<bool>(release => release),
|
It.Is<bool>(release => release),
|
||||||
It.IsAny<ByteStringCollection>(),
|
It.IsAny<ArrayOf<ByteString>>(),
|
||||||
It.IsAny<CancellationToken>()),
|
It.IsAny<CancellationToken>()),
|
||||||
Times.Once,
|
Times.Once,
|
||||||
"DiscoverAsync must release the server-side continuation point on cancellation " +
|
"DiscoverAsync must release the server-side continuation point on cancellation " +
|
||||||
@@ -148,7 +148,7 @@ public sealed class OpcUaClientContinuationPointReleaseTests
|
|||||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-cp-norelease");
|
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-cp-norelease");
|
||||||
|
|
||||||
var mock = new Mock<ISession>(MockBehavior.Loose);
|
var mock = new Mock<ISession>(MockBehavior.Loose);
|
||||||
mock.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext(ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.SdkTelemetry.Context));
|
mock.SetupGet(s => s.MessageContext).Returns(ServiceMessageContext.Create(ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.SdkTelemetry.Context));
|
||||||
mock.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
|
mock.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
|
||||||
|
|
||||||
// BrowseAsync — return a result with a continuation point so the loop is entered.
|
// BrowseAsync — return a result with a continuation point so the loop is entered.
|
||||||
@@ -161,27 +161,27 @@ public sealed class OpcUaClientContinuationPointReleaseTests
|
|||||||
};
|
};
|
||||||
var browseResult = new BrowseResult
|
var browseResult = new BrowseResult
|
||||||
{
|
{
|
||||||
ContinuationPoint = FakeContinuationPoint,
|
ContinuationPoint = FakeContinuationPoint.ToByteString(),
|
||||||
References = new ReferenceDescriptionCollection { firstRef },
|
References = new List<ReferenceDescription> { firstRef },
|
||||||
};
|
};
|
||||||
mock.Setup(s => s.BrowseAsync(
|
mock.Setup(s => s.BrowseAsync(
|
||||||
It.IsAny<RequestHeader>(),
|
It.IsAny<RequestHeader>(),
|
||||||
It.IsAny<ViewDescription>(),
|
It.IsAny<ViewDescription>(),
|
||||||
It.IsAny<uint>(),
|
It.IsAny<uint>(),
|
||||||
It.IsAny<BrowseDescriptionCollection>(),
|
It.IsAny<ArrayOf<BrowseDescription>>(),
|
||||||
It.IsAny<CancellationToken>()))
|
It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new BrowseResponse
|
.ReturnsAsync(new BrowseResponse
|
||||||
{
|
{
|
||||||
ResponseHeader = new ResponseHeader(),
|
ResponseHeader = new ResponseHeader(),
|
||||||
Results = new BrowseResultCollection { browseResult },
|
Results = new List<BrowseResult> { browseResult },
|
||||||
DiagnosticInfos = new DiagnosticInfoCollection(),
|
DiagnosticInfos = new List<DiagnosticInfo>(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// BrowseNextAsync — throw a non-cancellation transport error.
|
// BrowseNextAsync — throw a non-cancellation transport error.
|
||||||
mock.Setup(s => s.BrowseNextAsync(
|
mock.Setup(s => s.BrowseNextAsync(
|
||||||
It.IsAny<RequestHeader>(),
|
It.IsAny<RequestHeader>(),
|
||||||
It.Is<bool>(release => !release),
|
It.Is<bool>(release => !release),
|
||||||
It.IsAny<ByteStringCollection>(),
|
It.IsAny<ArrayOf<ByteString>>(),
|
||||||
It.IsAny<CancellationToken>()))
|
It.IsAny<CancellationToken>()))
|
||||||
.ThrowsAsync(new ServiceResultException(StatusCodes.BadCommunicationError));
|
.ThrowsAsync(new ServiceResultException(StatusCodes.BadCommunicationError));
|
||||||
|
|
||||||
@@ -195,7 +195,7 @@ public sealed class OpcUaClientContinuationPointReleaseTests
|
|||||||
mock.Verify(s => s.BrowseNextAsync(
|
mock.Verify(s => s.BrowseNextAsync(
|
||||||
It.IsAny<RequestHeader>(),
|
It.IsAny<RequestHeader>(),
|
||||||
It.Is<bool>(release => release),
|
It.Is<bool>(release => release),
|
||||||
It.IsAny<ByteStringCollection>(),
|
It.IsAny<ArrayOf<ByteString>>(),
|
||||||
It.IsAny<CancellationToken>()),
|
It.IsAny<CancellationToken>()),
|
||||||
Times.Never,
|
Times.Never,
|
||||||
"A transient transport failure must not trigger a release BrowseNext call");
|
"A transient transport failure must not trigger a release BrowseNext call");
|
||||||
|
|||||||
+9
-9
@@ -28,16 +28,16 @@ public sealed class OpcUaClientGateSplitTests
|
|||||||
{
|
{
|
||||||
var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions { RawTags = Tags }, "opcua-o6");
|
var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions { RawTags = Tags }, "opcua-o6");
|
||||||
var session = new Mock<ISession>(MockBehavior.Loose);
|
var session = new Mock<ISession>(MockBehavior.Loose);
|
||||||
session.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext(SdkTelemetry.Context));
|
session.SetupGet(s => s.MessageContext).Returns(ServiceMessageContext.Create(SdkTelemetry.Context));
|
||||||
session.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
|
session.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
|
||||||
session.Setup(s => s.ReadAsync(
|
session.Setup(s => s.ReadAsync(
|
||||||
It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
|
It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
|
||||||
It.IsAny<ReadValueIdCollection>(), It.IsAny<CancellationToken>()))
|
It.IsAny<ArrayOf<ReadValueId>>(), It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync((RequestHeader _, double _, TimestampsToReturn _, ReadValueIdCollection nodes, CancellationToken _) =>
|
.ReturnsAsync((RequestHeader _, double _, TimestampsToReturn _, ArrayOf<ReadValueId> nodes, CancellationToken _) =>
|
||||||
new ReadResponse
|
new ReadResponse
|
||||||
{
|
{
|
||||||
ResponseHeader = new ResponseHeader(),
|
ResponseHeader = new ResponseHeader(),
|
||||||
Results = [.. nodes.Select(_ => new DataValue(new Variant(42), StatusCodes.Good, DateTime.UtcNow, DateTime.UtcNow))],
|
Results = [.. nodes.ToList().Select(_ => new DataValue(new Variant(42), StatusCodes.Good, DateTime.UtcNow, DateTime.UtcNow))],
|
||||||
DiagnosticInfos = [],
|
DiagnosticInfos = [],
|
||||||
});
|
});
|
||||||
drv.SetSessionForTest(session.Object);
|
drv.SetSessionForTest(session.Object);
|
||||||
@@ -60,15 +60,15 @@ public sealed class OpcUaClientGateSplitTests
|
|||||||
using var _ = drv;
|
using var _ = drv;
|
||||||
var writeOnWire = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
var writeOnWire = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
var releaseWrite = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
var releaseWrite = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
session.Setup(s => s.WriteAsync(It.IsAny<RequestHeader>(), It.IsAny<WriteValueCollection>(), It.IsAny<CancellationToken>()))
|
session.Setup(s => s.WriteAsync(It.IsAny<RequestHeader>(), It.IsAny<ArrayOf<WriteValue>>(), It.IsAny<CancellationToken>()))
|
||||||
.Returns(async (RequestHeader _, WriteValueCollection nodes, CancellationToken _) =>
|
.Returns(async (RequestHeader _, ArrayOf<WriteValue> nodes, CancellationToken _) =>
|
||||||
{
|
{
|
||||||
writeOnWire.TrySetResult();
|
writeOnWire.TrySetResult();
|
||||||
await releaseWrite.Task;
|
await releaseWrite.Task;
|
||||||
return new WriteResponse
|
return new WriteResponse
|
||||||
{
|
{
|
||||||
ResponseHeader = new ResponseHeader(),
|
ResponseHeader = new ResponseHeader(),
|
||||||
Results = [.. nodes.Select(_ => (StatusCode)StatusCodes.Good)],
|
Results = [.. nodes.ToList().Select(_ => (StatusCode)StatusCodes.Good)],
|
||||||
DiagnosticInfos = [],
|
DiagnosticInfos = [],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -111,8 +111,8 @@ public sealed class OpcUaClientGateSplitTests
|
|||||||
var releaseBrowse = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
var releaseBrowse = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
session.Setup(s => s.BrowseAsync(
|
session.Setup(s => s.BrowseAsync(
|
||||||
It.IsAny<RequestHeader>(), It.IsAny<ViewDescription>(), It.IsAny<uint>(),
|
It.IsAny<RequestHeader>(), It.IsAny<ViewDescription>(), It.IsAny<uint>(),
|
||||||
It.IsAny<BrowseDescriptionCollection>(), It.IsAny<CancellationToken>()))
|
It.IsAny<ArrayOf<BrowseDescription>>(), It.IsAny<CancellationToken>()))
|
||||||
.Returns(async (RequestHeader _, ViewDescription _, uint _, BrowseDescriptionCollection _, CancellationToken _) =>
|
.Returns(async (RequestHeader _, ViewDescription _, uint _, ArrayOf<BrowseDescription> _, CancellationToken _) =>
|
||||||
{
|
{
|
||||||
browseOnWire.TrySetResult();
|
browseOnWire.TrySetResult();
|
||||||
await releaseBrowse.Task;
|
await releaseBrowse.Task;
|
||||||
|
|||||||
+1
-1
@@ -28,7 +28,7 @@ public sealed class OpcUaClientIdlePublishErrorTests
|
|||||||
{
|
{
|
||||||
var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions { RawTags = Tags }, instance);
|
var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions { RawTags = Tags }, instance);
|
||||||
var session = new Mock<ISession>(MockBehavior.Loose);
|
var session = new Mock<ISession>(MockBehavior.Loose);
|
||||||
session.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext(SdkTelemetry.Context));
|
session.SetupGet(s => s.MessageContext).Returns(ServiceMessageContext.Create(SdkTelemetry.Context));
|
||||||
session.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
|
session.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
|
||||||
drv.SetSessionForTest(session.Object);
|
drv.SetSessionForTest(session.Object);
|
||||||
drv.SetNamespaceMapForTest(NamespaceMap.FromTable(new NamespaceTable(), Tags));
|
drv.SetNamespaceMapForTest(NamespaceMap.FromTable(new NamespaceTable(), Tags));
|
||||||
|
|||||||
+25
-5
@@ -29,15 +29,15 @@ public sealed class OpcUaClientLifecycleGenerationTests
|
|||||||
private static Mock<ISession> NewSession()
|
private static Mock<ISession> NewSession()
|
||||||
{
|
{
|
||||||
var mock = new Mock<ISession>(MockBehavior.Loose);
|
var mock = new Mock<ISession>(MockBehavior.Loose);
|
||||||
mock.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext(SdkTelemetry.Context));
|
mock.SetupGet(s => s.MessageContext).Returns(ServiceMessageContext.Create(SdkTelemetry.Context));
|
||||||
mock.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
|
mock.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
|
||||||
return mock;
|
return mock;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ReadResponse GoodRead(ReadValueIdCollection nodes) => new()
|
private static ReadResponse GoodRead(ArrayOf<ReadValueId> nodes) => new()
|
||||||
{
|
{
|
||||||
ResponseHeader = new ResponseHeader(),
|
ResponseHeader = new ResponseHeader(),
|
||||||
Results = [.. nodes.Select(_ => new DataValue(new Variant(1), StatusCodes.Good, DateTime.UtcNow, DateTime.UtcNow))],
|
Results = [.. nodes.ToList().Select(_ => new DataValue(new Variant(1), StatusCodes.Good, DateTime.UtcNow, DateTime.UtcNow))],
|
||||||
DiagnosticInfos = [],
|
DiagnosticInfos = [],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -118,6 +118,26 @@ public sealed class OpcUaClientLifecycleGenerationTests
|
|||||||
drv.GetHealth().State.ShouldBe(DriverState.Unknown, "the abandoned init must not overwrite the newer health");
|
drv.GetHealth().State.ShouldBe(DriverState.Unknown, "the abandoned init must not overwrite the newer health");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Cancellation_at_session_ownership_transfer_discards_session_without_failover_or_install()
|
||||||
|
{
|
||||||
|
var session = NewSession();
|
||||||
|
using var cancellation = new CancellationTokenSource();
|
||||||
|
using var driver = new OpcUaClientDriver(new OpcUaClientDriverOptions
|
||||||
|
{ EndpointUrls = ["opc.tcp://first:4840", "opc.tcp://second:4840"] }, "sdk2-cancel-open");
|
||||||
|
var opens = 0;
|
||||||
|
driver.OpenSessionForTest = (_, _) =>
|
||||||
|
{
|
||||||
|
opens++;
|
||||||
|
cancellation.Cancel();
|
||||||
|
return Task.FromResult(session.Object);
|
||||||
|
};
|
||||||
|
await Should.ThrowAsync<OperationCanceledException>(() => driver.InitializeAsync("{}", cancellation.Token));
|
||||||
|
opens.ShouldBe(1);
|
||||||
|
driver.Session.ShouldBeNull();
|
||||||
|
session.Verify(s => s.Dispose(), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>A read whose response arrives after a shutdown does not write Healthy over the shutdown.</summary>
|
/// <summary>A read whose response arrives after a shutdown does not write Healthy over the shutdown.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task A_read_that_outlives_a_shutdown_does_not_overwrite_the_shutdown_health()
|
public async Task A_read_that_outlives_a_shutdown_does_not_overwrite_the_shutdown_health()
|
||||||
@@ -160,8 +180,8 @@ public sealed class OpcUaClientLifecycleGenerationTests
|
|||||||
var respond = new System.Collections.Concurrent.ConcurrentQueue<TaskCompletionSource>();
|
var respond = new System.Collections.Concurrent.ConcurrentQueue<TaskCompletionSource>();
|
||||||
session.Setup(s => s.ReadAsync(
|
session.Setup(s => s.ReadAsync(
|
||||||
It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
|
It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
|
||||||
It.IsAny<ReadValueIdCollection>(), It.IsAny<CancellationToken>()))
|
It.IsAny<ArrayOf<ReadValueId>>(), It.IsAny<CancellationToken>()))
|
||||||
.Returns(async (RequestHeader _, double _, TimestampsToReturn _, ReadValueIdCollection nodes, CancellationToken _) =>
|
.Returns(async (RequestHeader _, double _, TimestampsToReturn _, ArrayOf<ReadValueId> nodes, CancellationToken _) =>
|
||||||
{
|
{
|
||||||
var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
respond.Enqueue(gate);
|
respond.Enqueue(gate);
|
||||||
|
|||||||
@@ -74,6 +74,35 @@ public sealed class OpcUaClientNamespaceTests
|
|||||||
NamespaceMap.TryResolve(null!, "ns=1;s=X", out _).ShouldBeFalse();
|
NamespaceMap.TryResolve(null!, "ns=1;s=X", out _).ShouldBeFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static TheoryData<NodeId> IdentifierKinds =>
|
||||||
|
[
|
||||||
|
new NodeId(123u, 1),
|
||||||
|
new NodeId("literal;i=123", 1),
|
||||||
|
new NodeId(Guid.Parse("db8d57c4-316f-4517-819f-194c3a6db955"), 1),
|
||||||
|
new NodeId(new byte[] { 0, 1, 127, 255 }.ToByteString(), 1),
|
||||||
|
];
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[MemberData(nameof(IdentifierKinds))]
|
||||||
|
public void Stable_reference_rebinds_namespace_without_changing_identifier_type(NodeId original)
|
||||||
|
{
|
||||||
|
var stable = TestMap("http://opcfoundation.org/UA/", "urn:remote:server").ToStableReference(original);
|
||||||
|
var context = ServiceMessageContext.Create(SdkTelemetry.Context);
|
||||||
|
context.NamespaceUris.Append("urn:other");
|
||||||
|
context.NamespaceUris.Append("urn:remote:server");
|
||||||
|
var session = new Moq.Mock<Opc.Ua.Client.ISession>();
|
||||||
|
session.SetupGet(s => s.MessageContext).Returns(context);
|
||||||
|
NamespaceMap.TryResolve(session.Object, stable, out var rebound).ShouldBeTrue();
|
||||||
|
rebound.NamespaceIndex.ShouldBe((ushort)2);
|
||||||
|
rebound.IdType.ShouldBe(original.IdType);
|
||||||
|
rebound.IdentifierAsString.ShouldBe(original.IdentifierAsString);
|
||||||
|
if (original.IdType == IdType.Opaque)
|
||||||
|
{
|
||||||
|
rebound.TryGetValue(out ByteString identifier).ShouldBeTrue();
|
||||||
|
identifier.ToArray().ShouldBe(new byte[] { 0, 1, 127, 255 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- v3: RawPath → upstream node id resolution table ----
|
// ---- v3: RawPath → upstream node id resolution table ----
|
||||||
|
|
||||||
/// <summary>Verifies a RawPath resolves to the upstream node id authored in its TagConfig.nodeId.</summary>
|
/// <summary>Verifies a RawPath resolves to the upstream node id authored in its TagConfig.nodeId.</summary>
|
||||||
|
|||||||
+173
@@ -0,0 +1,173 @@
|
|||||||
|
using Moq;
|
||||||
|
using Opc.Ua;
|
||||||
|
using Opc.Ua.Client;
|
||||||
|
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 OpcUaClientSdkValueBoundaryTests
|
||||||
|
{
|
||||||
|
private const string Reference = "Plant/Upstream/Value";
|
||||||
|
private static readonly RawTagEntry[] Tags = [new(Reference, """{"nodeId":"i=2258"}""", false)];
|
||||||
|
|
||||||
|
private static (OpcUaClientDriver Driver, Mock<ISession> Session) Connected()
|
||||||
|
{
|
||||||
|
var session = new Mock<ISession>();
|
||||||
|
session.SetupGet(s => s.MessageContext).Returns(ServiceMessageContext.Create(SdkTelemetry.Context));
|
||||||
|
session.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
|
||||||
|
var driver = new OpcUaClientDriver(new OpcUaClientDriverOptions { RawTags = Tags }, "sdk2-values");
|
||||||
|
driver.SetSessionForTest(session.Object);
|
||||||
|
driver.SetNamespaceMapForTest(NamespaceMap.FromTable(new NamespaceTable(), Tags));
|
||||||
|
return (driver, session);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TheoryData<Variant, object?> Payloads =>
|
||||||
|
[
|
||||||
|
new(Variant.From((sbyte)-3), (sbyte)-3),
|
||||||
|
new(Variant.From((byte)250), (byte)250),
|
||||||
|
new(Variant.From((short)-30), (short)-30),
|
||||||
|
new(Variant.From((ushort)60000), (ushort)60000),
|
||||||
|
new(Variant.From(-100), -100),
|
||||||
|
new(Variant.From(uint.MaxValue), uint.MaxValue),
|
||||||
|
new(Variant.From(long.MinValue), long.MinValue),
|
||||||
|
new(Variant.From(ulong.MaxValue), ulong.MaxValue),
|
||||||
|
new(Variant.From(1.5f), 1.5f),
|
||||||
|
new(Variant.From(1.5d), 1.5d),
|
||||||
|
new(Variant.From(true), true),
|
||||||
|
new(Variant.From("text"), "text"),
|
||||||
|
new(Variant.Null, null),
|
||||||
|
new(Variant.From(new DateTimeUtc(new DateTime(2026, 11, 10, 1, 2, 3, DateTimeKind.Utc))), new DateTime(2026, 11, 10, 1, 2, 3, DateTimeKind.Utc)),
|
||||||
|
new(Variant.From(new Uuid(Guid.Parse("db8d57c4-316f-4517-819f-194c3a6db955"))), Guid.Parse("db8d57c4-316f-4517-819f-194c3a6db955")),
|
||||||
|
new(Variant.From(new byte[] { 1, 2 }.ToByteString()), new byte[] { 1, 2 }),
|
||||||
|
new(Variant.From(default(ByteString)), null),
|
||||||
|
new(Variant.From(ByteString.Empty), Array.Empty<byte>()),
|
||||||
|
new(Variant.From((ArrayOf<bool>)[true, false]), new[] { true, false }),
|
||||||
|
new(Variant.From((ArrayOf<sbyte>)[-1, 2]), new sbyte[] { -1, 2 }),
|
||||||
|
new(Variant.From((ArrayOf<byte>)[1, 250]), new byte[] { 1, 250 }),
|
||||||
|
new(Variant.From((ArrayOf<short>)[1, 2]), new short[] { 1, 2 }),
|
||||||
|
new(Variant.From((ArrayOf<ushort>)[1, 60000]), new ushort[] { 1, 60000 }),
|
||||||
|
new(Variant.From((ArrayOf<int>)[-1, 2]), new int[] { -1, 2 }),
|
||||||
|
new(Variant.From((ArrayOf<long>)[long.MinValue, long.MaxValue]), new long[] { long.MinValue, long.MaxValue }),
|
||||||
|
new(Variant.From((ArrayOf<ulong>)[0, ulong.MaxValue]), new ulong[] { 0, ulong.MaxValue }),
|
||||||
|
new(Variant.From((ArrayOf<double>)[1, 2]), new double[] { 1, 2 }),
|
||||||
|
new(Variant.From((ArrayOf<uint>)[1, 2]), new uint[] { 1, 2 }),
|
||||||
|
new(Variant.From((ArrayOf<float>)[1, 2]), new float[] { 1, 2 }),
|
||||||
|
new(Variant.From((ArrayOf<string>)["a", "b"]), new[] { "a", "b" }),
|
||||||
|
new(Variant.From((ArrayOf<DateTimeUtc>)[new DateTimeUtc(new DateTime(2026, 11, 10, 1, 2, 3, DateTimeKind.Utc))]), new[] { new DateTime(2026, 11, 10, 1, 2, 3, DateTimeKind.Utc) }),
|
||||||
|
new(Variant.From((ArrayOf<Uuid>)[new Uuid(Guid.Empty)]), new[] { Guid.Empty }),
|
||||||
|
new(Variant.From((ArrayOf<ByteString>)[new byte[] { 3 }.ToByteString(), ByteString.Empty]), new byte[][] { [3], [] }),
|
||||||
|
new(Variant.From(default(ArrayOf<short>)), null),
|
||||||
|
new(Variant.From((ArrayOf<short>)[]), Array.Empty<short>()),
|
||||||
|
];
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[MemberData(nameof(Payloads))]
|
||||||
|
public async Task Read_and_notification_preserve_CLR_type_status_and_timestamp_rules(Variant upstream, object? expected)
|
||||||
|
{
|
||||||
|
var (driver, session) = Connected();
|
||||||
|
using var _ = driver;
|
||||||
|
session.Setup(s => s.ReadAsync(It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
|
||||||
|
It.IsAny<ArrayOf<ReadValueId>>(), It.IsAny<CancellationToken>())).ReturnsAsync(new ReadResponse
|
||||||
|
{ Results = [new DataValue(upstream, StatusCodes.UncertainLastUsableValue, DateTimeUtc.MinValue, DateTimeUtc.MinValue)] });
|
||||||
|
var before = DateTime.UtcNow;
|
||||||
|
var read = (await driver.ReadAsync([Reference], TestContext.Current.CancellationToken)).Single();
|
||||||
|
AssertPayload(read, expected, before);
|
||||||
|
DataValueSnapshot? notified = null;
|
||||||
|
driver.OnDataChange += (_, e) => notified = e.Snapshot;
|
||||||
|
var handle = driver.AddSubscriptionForTest([Reference]);
|
||||||
|
driver.DeliverNotificationForTest(handle, Reference,
|
||||||
|
new DataValue(upstream, StatusCodes.UncertainLastUsableValue, DateTimeUtc.MinValue, DateTimeUtc.MinValue));
|
||||||
|
AssertPayload(notified.ShouldNotBeNull(), expected, before);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AssertPayload(DataValueSnapshot snapshot, object? expected, DateTime before)
|
||||||
|
{
|
||||||
|
snapshot.Value.ShouldBe(expected);
|
||||||
|
snapshot.Value?.GetType().ShouldBe(expected?.GetType());
|
||||||
|
snapshot.StatusCode.ShouldBe(StatusCodes.UncertainLastUsableValue.Code);
|
||||||
|
snapshot.SourceTimestampUtc.ShouldBeNull();
|
||||||
|
snapshot.ServerTimestampUtc.ShouldBeGreaterThanOrEqualTo(before);
|
||||||
|
snapshot.ServerTimestampUtc.Kind.ShouldBe(DateTimeKind.Utc);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0)]
|
||||||
|
[InlineData(1)]
|
||||||
|
[InlineData(2)]
|
||||||
|
public async Task Read_and_notification_detach_upstream_buffers(int shape)
|
||||||
|
{
|
||||||
|
var (driver, session) = Connected();
|
||||||
|
using var _ = driver;
|
||||||
|
byte[] bytes = [1, 2];
|
||||||
|
short[] numbers = [3, 4];
|
||||||
|
// ReadOnlyMemory constructors deliberately alias the backing SDK buffer. Copying the
|
||||||
|
// test input before the adapter sees it would make the ownership assertion vacuous.
|
||||||
|
var value = shape switch
|
||||||
|
{
|
||||||
|
0 => Variant.From(new ByteString(bytes.AsMemory())),
|
||||||
|
1 => Variant.From(new ArrayOf<short>(numbers.AsMemory())),
|
||||||
|
_ => Variant.From((ArrayOf<ByteString>)[new ByteString(bytes.AsMemory())]),
|
||||||
|
};
|
||||||
|
object expected = shape switch { 0 => new byte[] { 1, 2 }, 1 => new short[] { 3, 4 }, _ => new byte[][] { [1, 2] } };
|
||||||
|
session.Setup(s => s.ReadAsync(It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
|
||||||
|
It.IsAny<ArrayOf<ReadValueId>>(), It.IsAny<CancellationToken>())).ReturnsAsync(new ReadResponse { Results = [new DataValue(value)] });
|
||||||
|
var read = (await driver.ReadAsync([Reference], TestContext.Current.CancellationToken)).Single();
|
||||||
|
DataValueSnapshot? notified = null;
|
||||||
|
driver.OnDataChange += (_, e) => notified = e.Snapshot;
|
||||||
|
driver.DeliverNotificationForTest(driver.AddSubscriptionForTest([Reference]), Reference, new DataValue(value));
|
||||||
|
bytes[0] = 99;
|
||||||
|
numbers[0] = 99;
|
||||||
|
read.Value.ShouldBe(expected);
|
||||||
|
notified.ShouldNotBeNull().Value.ShouldBe(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Matrix_read_and_notification_are_refused_per_item_without_materialization()
|
||||||
|
{
|
||||||
|
var (driver, session) = Connected();
|
||||||
|
using var _ = driver;
|
||||||
|
var sourceTime = new DateTime(2026, 11, 10, 1, 2, 3, DateTimeKind.Utc);
|
||||||
|
var serverTime = sourceTime.AddSeconds(1);
|
||||||
|
var matrix = Variant.From(new int[,] { { 1, 2 }, { 3, 4 } }.ToMatrixOf());
|
||||||
|
session.Setup(s => s.ReadAsync(It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
|
||||||
|
It.IsAny<ArrayOf<ReadValueId>>(), It.IsAny<CancellationToken>())).ReturnsAsync(new ReadResponse
|
||||||
|
{ Results = [new DataValue(matrix, StatusCodes.Good, sourceTime, serverTime),
|
||||||
|
new DataValue(Variant.From(42), StatusCodes.Good, sourceTime, serverTime)] });
|
||||||
|
var results = await driver.ReadAsync([Reference, Reference], TestContext.Current.CancellationToken);
|
||||||
|
results[0].Value.ShouldBeNull();
|
||||||
|
results[0].StatusCode.ShouldBe(StatusCodes.BadTypeMismatch.Code);
|
||||||
|
results[0].SourceTimestampUtc.ShouldBe(sourceTime);
|
||||||
|
results[0].ServerTimestampUtc.ShouldBe(serverTime);
|
||||||
|
results[1].Value.ShouldBe(42);
|
||||||
|
results[1].StatusCode.ShouldBe(StatusCodes.Good.Code);
|
||||||
|
DataValueSnapshot? notified = null;
|
||||||
|
driver.OnDataChange += (_, e) => notified = e.Snapshot;
|
||||||
|
driver.DeliverNotificationForTest(driver.AddSubscriptionForTest([Reference]), Reference,
|
||||||
|
new DataValue(matrix, StatusCodes.UncertainLastUsableValue, sourceTime, serverTime));
|
||||||
|
notified.ShouldNotBeNull().Value.ShouldBeNull();
|
||||||
|
notified.StatusCode.ShouldBe(StatusCodes.BadTypeMismatch.Code);
|
||||||
|
notified.SourceTimestampUtc.ShouldBe(sourceTime);
|
||||||
|
notified.ServerTimestampUtc.ShouldBe(serverTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Write_passes_scalar_shaped_arrays_once_and_rejects_unsupported_values_before_IO()
|
||||||
|
{
|
||||||
|
var (driver, session) = Connected();
|
||||||
|
using var _ = driver;
|
||||||
|
short[] caller = [7, 8];
|
||||||
|
ArrayOf<WriteValue> sent = default;
|
||||||
|
session.Setup(s => s.WriteAsync(It.IsAny<RequestHeader>(), It.IsAny<ArrayOf<WriteValue>>(), It.IsAny<CancellationToken>()))
|
||||||
|
.Callback<RequestHeader, ArrayOf<WriteValue>, CancellationToken>((_, writes, _) => { sent = writes; caller[0] = 99; })
|
||||||
|
.ReturnsAsync(new WriteResponse { Results = [StatusCodes.Good] });
|
||||||
|
var result = await driver.WriteAsync([new(Reference, caller), new(Reference, new object()), new(Reference, new int[1, 1])], TestContext.Current.CancellationToken);
|
||||||
|
result.Select(r => r.StatusCode).ShouldBe([StatusCodes.Good.Code, StatusCodes.BadTypeMismatch.Code, StatusCodes.BadTypeMismatch.Code]);
|
||||||
|
sent.Count.ShouldBe(1);
|
||||||
|
sent[0].Value.WrappedValue.TypeInfo.ValueRank.ShouldBe(ValueRanks.OneDimension);
|
||||||
|
UpstreamValueMapping.ToClr(sent[0].Value.WrappedValue).ShouldBe(new short[] { 7, 8 });
|
||||||
|
session.Verify(s => s.WriteAsync(It.IsAny<RequestHeader>(), It.IsAny<ArrayOf<WriteValue>>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-8
@@ -41,7 +41,7 @@ public sealed class OpcUaClientSessionLossTests
|
|||||||
{
|
{
|
||||||
var mock = new Mock<ISession>(MockBehavior.Loose);
|
var mock = new Mock<ISession>(MockBehavior.Loose);
|
||||||
mock.SetupGet(s => s.MessageContext)
|
mock.SetupGet(s => s.MessageContext)
|
||||||
.Returns(new ServiceMessageContext(SdkTelemetry.Context));
|
.Returns(ServiceMessageContext.Create(SdkTelemetry.Context));
|
||||||
mock.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
|
mock.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
|
||||||
return mock;
|
return mock;
|
||||||
}
|
}
|
||||||
@@ -60,17 +60,17 @@ public sealed class OpcUaClientSessionLossTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static DataValue Good(object value, DateTime ts) =>
|
private static DataValue Good(object value, DateTime ts) =>
|
||||||
new(new Variant(value), StatusCodes.Good, ts, ts);
|
new(VariantHelper.CastFrom(value), StatusCodes.Good, ts, ts);
|
||||||
|
|
||||||
private static void SetupRead(Mock<ISession> session, params object[] values) =>
|
private static void SetupRead(Mock<ISession> session, params object[] values) =>
|
||||||
session.Setup(s => s.ReadAsync(
|
session.Setup(s => s.ReadAsync(
|
||||||
It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
|
It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
|
||||||
It.IsAny<ReadValueIdCollection>(), It.IsAny<CancellationToken>()))
|
It.IsAny<ArrayOf<ReadValueId>>(), It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync((RequestHeader _, double _, TimestampsToReturn _, ReadValueIdCollection nodes, CancellationToken _) =>
|
.ReturnsAsync((RequestHeader _, double _, TimestampsToReturn _, ArrayOf<ReadValueId> nodes, CancellationToken _) =>
|
||||||
new ReadResponse
|
new ReadResponse
|
||||||
{
|
{
|
||||||
ResponseHeader = new ResponseHeader(),
|
ResponseHeader = new ResponseHeader(),
|
||||||
Results = [.. nodes.Select((_, i) => new DataValue(new Variant(values[i]), StatusCodes.Good, DateTime.UtcNow, DateTime.UtcNow))],
|
Results = [.. nodes.ToList().Select((_, i) => new DataValue(VariantHelper.CastFrom(values[i]), StatusCodes.Good, DateTime.UtcNow, DateTime.UtcNow))],
|
||||||
DiagnosticInfos = [],
|
DiagnosticInfos = [],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -500,11 +500,11 @@ public sealed class OpcUaClientSessionLossTests
|
|||||||
private static void SetupReadAt(Mock<ISession> session, object value, DateTime sourceTime) =>
|
private static void SetupReadAt(Mock<ISession> session, object value, DateTime sourceTime) =>
|
||||||
session.Setup(s => s.ReadAsync(
|
session.Setup(s => s.ReadAsync(
|
||||||
It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
|
It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
|
||||||
It.IsAny<ReadValueIdCollection>(), It.IsAny<CancellationToken>()))
|
It.IsAny<ArrayOf<ReadValueId>>(), It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new ReadResponse
|
.ReturnsAsync(new ReadResponse
|
||||||
{
|
{
|
||||||
ResponseHeader = new ResponseHeader(),
|
ResponseHeader = new ResponseHeader(),
|
||||||
Results = [new DataValue(new Variant(value), StatusCodes.Good, sourceTime, sourceTime)],
|
Results = [new DataValue(VariantHelper.CastFrom(value), StatusCodes.Good, sourceTime, sourceTime)],
|
||||||
DiagnosticInfos = [],
|
DiagnosticInfos = [],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -702,7 +702,7 @@ public sealed class OpcUaClientSessionLossTests
|
|||||||
var readAt = t0.AddSeconds(10);
|
var readAt = t0.AddSeconds(10);
|
||||||
session.Setup(s => s.ReadAsync(
|
session.Setup(s => s.ReadAsync(
|
||||||
It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
|
It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
|
||||||
It.IsAny<ReadValueIdCollection>(), It.IsAny<CancellationToken>()))
|
It.IsAny<ArrayOf<ReadValueId>>(), It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new ReadResponse
|
.ReturnsAsync(new ReadResponse
|
||||||
{
|
{
|
||||||
ResponseHeader = new ResponseHeader(),
|
ResponseHeader = new ResponseHeader(),
|
||||||
|
|||||||
+2
-2
@@ -42,7 +42,7 @@ public sealed class OpcUaClientStaleNoSubscriptionTests
|
|||||||
{
|
{
|
||||||
var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions { RawTags = Tags }, "opcua-763");
|
var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions { RawTags = Tags }, "opcua-763");
|
||||||
var session = new Mock<ISession>(MockBehavior.Loose);
|
var session = new Mock<ISession>(MockBehavior.Loose);
|
||||||
session.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext(SdkTelemetry.Context));
|
session.SetupGet(s => s.MessageContext).Returns(ServiceMessageContext.Create(SdkTelemetry.Context));
|
||||||
session.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
|
session.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
|
||||||
var clock = new ManualClock(new DateTimeOffset(2026, 11, 9, 12, 0, 0, TimeSpan.Zero));
|
var clock = new ManualClock(new DateTimeOffset(2026, 11, 9, 12, 0, 0, TimeSpan.Zero));
|
||||||
drv.Clock = clock;
|
drv.Clock = clock;
|
||||||
@@ -209,7 +209,7 @@ public sealed class OpcUaClientSlowDeleteTests
|
|||||||
RawTagEntry[] tags = [new(SpeedRef, JsonSerializer.Serialize(new { nodeId = "ns=0;s=Speed" }), WriteIdempotent: true)];
|
RawTagEntry[] tags = [new(SpeedRef, JsonSerializer.Serialize(new { nodeId = "ns=0;s=Speed" }), WriteIdempotent: true)];
|
||||||
var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions { RawTags = tags }, "opcua-764");
|
var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions { RawTags = tags }, "opcua-764");
|
||||||
var session = new Mock<ISession>(MockBehavior.Loose);
|
var session = new Mock<ISession>(MockBehavior.Loose);
|
||||||
session.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext(SdkTelemetry.Context));
|
session.SetupGet(s => s.MessageContext).Returns(ServiceMessageContext.Create(SdkTelemetry.Context));
|
||||||
session.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
|
session.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
|
||||||
var clock = new ManualClock(new DateTimeOffset(2026, 11, 10, 12, 0, 0, TimeSpan.Zero));
|
var clock = new ManualClock(new DateTimeOffset(2026, 11, 10, 12, 0, 0, TimeSpan.Zero));
|
||||||
drv.Clock = clock;
|
drv.Clock = clock;
|
||||||
|
|||||||
+6
-6
@@ -46,20 +46,20 @@ public sealed class OpcUaClientStaleSessionRaceTests
|
|||||||
// MessageContext. This getter is read in the driver's synchronous prologue (before the
|
// MessageContext. This getter is read in the driver's synchronous prologue (before the
|
||||||
// gate), so signalling here proves the pre-gate capture path has run.
|
// gate), so signalling here proves the pre-gate capture path has run.
|
||||||
mock.SetupGet(s => s.MessageContext)
|
mock.SetupGet(s => s.MessageContext)
|
||||||
.Returns(new ServiceMessageContext(ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.SdkTelemetry.Context))
|
.Returns(ServiceMessageContext.Create(ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.SdkTelemetry.Context))
|
||||||
.Callback(() => prologueReached?.TrySetResult());
|
.Callback(() => prologueReached?.TrySetResult());
|
||||||
mock.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
|
mock.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
|
||||||
// Acknowledge issues its wire call via CallAsync; stub it so AcknowledgeAsync can run to
|
// Acknowledge issues its wire call via CallAsync; stub it so AcknowledgeAsync can run to
|
||||||
// completion against whichever session it ends up binding to.
|
// completion against whichever session it ends up binding to.
|
||||||
mock.Setup(s => s.CallAsync(
|
mock.Setup(s => s.CallAsync(
|
||||||
It.IsAny<RequestHeader>(),
|
It.IsAny<RequestHeader>(),
|
||||||
It.IsAny<CallMethodRequestCollection>(),
|
It.IsAny<ArrayOf<CallMethodRequest>>(),
|
||||||
It.IsAny<CancellationToken>()))
|
It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new CallResponse
|
.ReturnsAsync(new CallResponse
|
||||||
{
|
{
|
||||||
ResponseHeader = new ResponseHeader(),
|
ResponseHeader = new ResponseHeader(),
|
||||||
Results = new CallMethodResultCollection(),
|
Results = new List<CallMethodResult>(),
|
||||||
DiagnosticInfos = new DiagnosticInfoCollection(),
|
DiagnosticInfos = new List<DiagnosticInfo>(),
|
||||||
});
|
});
|
||||||
return mock;
|
return mock;
|
||||||
}
|
}
|
||||||
@@ -67,10 +67,10 @@ public sealed class OpcUaClientStaleSessionRaceTests
|
|||||||
private static void VerifyAckWireHitNewSessionNotOld(Mock<ISession> newSession, Mock<ISession> oldSession)
|
private static void VerifyAckWireHitNewSessionNotOld(Mock<ISession> newSession, Mock<ISession> oldSession)
|
||||||
{
|
{
|
||||||
newSession.Verify(s => s.CallAsync(
|
newSession.Verify(s => s.CallAsync(
|
||||||
It.IsAny<RequestHeader>(), It.IsAny<CallMethodRequestCollection>(), It.IsAny<CancellationToken>()),
|
It.IsAny<RequestHeader>(), It.IsAny<ArrayOf<CallMethodRequest>>(), It.IsAny<CancellationToken>()),
|
||||||
Times.Once);
|
Times.Once);
|
||||||
oldSession.Verify(s => s.CallAsync(
|
oldSession.Verify(s => s.CallAsync(
|
||||||
It.IsAny<RequestHeader>(), It.IsAny<CallMethodRequestCollection>(), It.IsAny<CancellationToken>()),
|
It.IsAny<RequestHeader>(), It.IsAny<ArrayOf<CallMethodRequest>>(), It.IsAny<CancellationToken>()),
|
||||||
Times.Never);
|
Times.Never);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -30,7 +30,7 @@ public sealed class OpcUaClientSubscribeCleanupTests
|
|||||||
{
|
{
|
||||||
var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions { RawTags = Tags }, "opcua-o1");
|
var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions { RawTags = Tags }, "opcua-o1");
|
||||||
var session = new Mock<ISession>(MockBehavior.Loose);
|
var session = new Mock<ISession>(MockBehavior.Loose);
|
||||||
session.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext(SdkTelemetry.Context));
|
session.SetupGet(s => s.MessageContext).Returns(ServiceMessageContext.Create(SdkTelemetry.Context));
|
||||||
session.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
|
session.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
|
||||||
drv.SetSessionForTest(session.Object);
|
drv.SetSessionForTest(session.Object);
|
||||||
drv.SetNamespaceMapForTest(NamespaceMap.FromTable(new NamespaceTable(), Tags));
|
drv.SetNamespaceMapForTest(NamespaceMap.FromTable(new NamespaceTable(), Tags));
|
||||||
|
|||||||
+8
-8
@@ -28,7 +28,7 @@ public sealed class OpcUaClientWriteHealthTests
|
|||||||
{
|
{
|
||||||
var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions { RawTags = Tags }, "opcua-write-health");
|
var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions { RawTags = Tags }, "opcua-write-health");
|
||||||
var session = new Mock<ISession>(MockBehavior.Loose);
|
var session = new Mock<ISession>(MockBehavior.Loose);
|
||||||
session.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext(SdkTelemetry.Context));
|
session.SetupGet(s => s.MessageContext).Returns(ServiceMessageContext.Create(SdkTelemetry.Context));
|
||||||
session.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
|
session.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
|
||||||
drv.SetSessionForTest(session.Object);
|
drv.SetSessionForTest(session.Object);
|
||||||
drv.SetNamespaceMapForTest(NamespaceMap.FromTable(new NamespaceTable(), Tags));
|
drv.SetNamespaceMapForTest(NamespaceMap.FromTable(new NamespaceTable(), Tags));
|
||||||
@@ -44,7 +44,7 @@ public sealed class OpcUaClientWriteHealthTests
|
|||||||
{
|
{
|
||||||
var (drv, session) = NewConnectedDriver(new DriverHealth(DriverState.Degraded, Stale, "read failed"));
|
var (drv, session) = NewConnectedDriver(new DriverHealth(DriverState.Degraded, Stale, "read failed"));
|
||||||
using var _ = drv;
|
using var _ = drv;
|
||||||
session.Setup(s => s.WriteAsync(It.IsAny<RequestHeader>(), It.IsAny<WriteValueCollection>(), It.IsAny<CancellationToken>()))
|
session.Setup(s => s.WriteAsync(It.IsAny<RequestHeader>(), It.IsAny<ArrayOf<WriteValue>>(), It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new WriteResponse
|
.ReturnsAsync(new WriteResponse
|
||||||
{
|
{
|
||||||
ResponseHeader = new ResponseHeader(),
|
ResponseHeader = new ResponseHeader(),
|
||||||
@@ -54,7 +54,7 @@ public sealed class OpcUaClientWriteHealthTests
|
|||||||
|
|
||||||
var results = await drv.WriteAsync(Write(), TestContext.Current.CancellationToken);
|
var results = await drv.WriteAsync(Write(), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
results.Single().StatusCode.ShouldBe(StatusCodes.BadNotWritable, "the upstream refusal still passes through verbatim");
|
results.Single().StatusCode.ShouldBe(StatusCodes.BadNotWritable.Code, "the upstream refusal still passes through verbatim");
|
||||||
var h = drv.GetHealth();
|
var h = drv.GetHealth();
|
||||||
h.State.ShouldBe(DriverState.Healthy);
|
h.State.ShouldBe(DriverState.Healthy);
|
||||||
h.LastSuccessfulRead.ShouldNotBeNull().ShouldBeGreaterThan(Stale);
|
h.LastSuccessfulRead.ShouldNotBeNull().ShouldBeGreaterThan(Stale);
|
||||||
@@ -67,7 +67,7 @@ public sealed class OpcUaClientWriteHealthTests
|
|||||||
{
|
{
|
||||||
var (drv, session) = NewConnectedDriver(new DriverHealth(DriverState.Healthy, Stale, null));
|
var (drv, session) = NewConnectedDriver(new DriverHealth(DriverState.Healthy, Stale, null));
|
||||||
using var _ = drv;
|
using var _ = drv;
|
||||||
session.Setup(s => s.WriteAsync(It.IsAny<RequestHeader>(), It.IsAny<WriteValueCollection>(), It.IsAny<CancellationToken>()))
|
session.Setup(s => s.WriteAsync(It.IsAny<RequestHeader>(), It.IsAny<ArrayOf<WriteValue>>(), It.IsAny<CancellationToken>()))
|
||||||
.ThrowsAsync(new ServiceResultException(StatusCodes.BadConnectionClosed));
|
.ThrowsAsync(new ServiceResultException(StatusCodes.BadConnectionClosed));
|
||||||
|
|
||||||
var results = await drv.WriteAsync(Write(), TestContext.Current.CancellationToken);
|
var results = await drv.WriteAsync(Write(), TestContext.Current.CancellationToken);
|
||||||
@@ -89,8 +89,8 @@ public sealed class OpcUaClientWriteHealthTests
|
|||||||
var (drv, session) = NewConnectedDriver(healthy);
|
var (drv, session) = NewConnectedDriver(healthy);
|
||||||
using var _ = drv;
|
using var _ = drv;
|
||||||
using var cts = new CancellationTokenSource();
|
using var cts = new CancellationTokenSource();
|
||||||
session.Setup(s => s.WriteAsync(It.IsAny<RequestHeader>(), It.IsAny<WriteValueCollection>(), It.IsAny<CancellationToken>()))
|
session.Setup(s => s.WriteAsync(It.IsAny<RequestHeader>(), It.IsAny<ArrayOf<WriteValue>>(), It.IsAny<CancellationToken>()))
|
||||||
.Returns(async (RequestHeader _, WriteValueCollection _, CancellationToken ct) =>
|
.Returns(async (RequestHeader _, ArrayOf<WriteValue> _, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
cts.Cancel();
|
cts.Cancel();
|
||||||
await Task.Delay(Timeout.Infinite, ct);
|
await Task.Delay(Timeout.Infinite, ct);
|
||||||
@@ -128,7 +128,7 @@ public sealed class OpcUaClientWriteHealthTests
|
|||||||
{
|
{
|
||||||
var (drv, session) = NewConnectedDriver(new DriverHealth(DriverState.Healthy, Stale, null));
|
var (drv, session) = NewConnectedDriver(new DriverHealth(DriverState.Healthy, Stale, null));
|
||||||
using var _ = drv;
|
using var _ = drv;
|
||||||
session.Setup(s => s.WriteAsync(It.IsAny<RequestHeader>(), It.IsAny<WriteValueCollection>(), It.IsAny<CancellationToken>()))
|
session.Setup(s => s.WriteAsync(It.IsAny<RequestHeader>(), It.IsAny<ArrayOf<WriteValue>>(), It.IsAny<CancellationToken>()))
|
||||||
.ThrowsAsync(new OperationCanceledException("SDK request timeout"));
|
.ThrowsAsync(new OperationCanceledException("SDK request timeout"));
|
||||||
|
|
||||||
var results = await drv.WriteAsync(Write(), TestContext.Current.CancellationToken);
|
var results = await drv.WriteAsync(Write(), TestContext.Current.CancellationToken);
|
||||||
@@ -149,7 +149,7 @@ public sealed class OpcUaClientWriteHealthTests
|
|||||||
using var _ = drv;
|
using var _ = drv;
|
||||||
drv.RaiseKeepAliveForTest(StatusCodes.BadNoCommunication);
|
drv.RaiseKeepAliveForTest(StatusCodes.BadNoCommunication);
|
||||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded, "positive control: the outage is published");
|
drv.GetHealth().State.ShouldBe(DriverState.Degraded, "positive control: the outage is published");
|
||||||
session.Setup(s => s.WriteAsync(It.IsAny<RequestHeader>(), It.IsAny<WriteValueCollection>(), It.IsAny<CancellationToken>()))
|
session.Setup(s => s.WriteAsync(It.IsAny<RequestHeader>(), It.IsAny<ArrayOf<WriteValue>>(), It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new WriteResponse { ResponseHeader = new ResponseHeader(), Results = [StatusCodes.Good], DiagnosticInfos = [] });
|
.ReturnsAsync(new WriteResponse { ResponseHeader = new ResponseHeader(), Results = [StatusCodes.Good], DiagnosticInfos = [] });
|
||||||
|
|
||||||
await drv.WriteAsync(Write(), TestContext.Current.CancellationToken);
|
await drv.WriteAsync(Write(), TestContext.Current.CancellationToken);
|
||||||
|
|||||||
Reference in New Issue
Block a user