Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 47b66caa8d | |||
| c6ce30121c | |||
| 62c3ba6c37 | |||
| b583840fbc |
@@ -1,4 +1,3 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
|
||||
using System.Globalization;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Exceptions;
|
||||
@@ -211,7 +210,7 @@ public class HistoryReadCommand : CommandBase
|
||||
: StatusCode.IsBad(dv.StatusCode) ? "Bad"
|
||||
: "Uncertain";
|
||||
await console.Output.WriteLineAsync(
|
||||
$"{dv.SourceTimestamp.ToString("O"),-35} {ValueConverter.ToClrObject(dv.WrappedValue)?.ToString() ?? "(none)",-15} {status,-10} " +
|
||||
$"{dv.SourceTimestamp.ToString("O"),-35} {dv.Value?.ToString() ?? "(none)",-15} {status,-10} " +
|
||||
$"{e.UpdateType,-8} {e.UserName ?? "",-20} {e.ModificationTime:O}");
|
||||
}
|
||||
|
||||
@@ -245,7 +244,7 @@ public class HistoryReadCommand : CommandBase
|
||||
: StatusCode.IsBad(dv.StatusCode) ? "Bad"
|
||||
: "Uncertain";
|
||||
await console.Output.WriteLineAsync(
|
||||
$"{dv.SourceTimestamp.ToString("O"),-35} {ValueConverter.ToClrObject(dv.WrappedValue),-15} {status}");
|
||||
$"{dv.SourceTimestamp.ToString("O"),-35} {dv.Value,-15} {status}");
|
||||
}
|
||||
|
||||
await console.Output.WriteLineAsync();
|
||||
@@ -277,25 +276,23 @@ public class HistoryReadCommand : CommandBase
|
||||
internal static EventFilter BuildEventFilter(IReadOnlyList<string> fields, NodeId? ofType)
|
||||
{
|
||||
var filter = new EventFilter();
|
||||
var clauses = new List<SimpleAttributeOperand>();
|
||||
foreach (var field in fields)
|
||||
{
|
||||
var type = string.Equals(field, "ConditionName", StringComparison.Ordinal)
|
||||
? ObjectTypeIds.ConditionType
|
||||
: ObjectTypeIds.BaseEventType;
|
||||
clauses.Add(new SimpleAttributeOperand(type, new QualifiedName(field)) { AttributeId = Attributes.Value });
|
||||
filter.SelectClauses.Add(new SimpleAttributeOperand(type, new QualifiedName(field)) { AttributeId = Attributes.Value });
|
||||
}
|
||||
|
||||
filter.SelectClauses = clauses.ToArrayOf();
|
||||
if (ofType is not null)
|
||||
filter.WhereClause.Push(FilterOperator.OfType, Variant.From(ofType.Value));
|
||||
filter.WhereClause.Push(FilterOperator.OfType, new LiteralOperand(new Variant(ofType)));
|
||||
return filter;
|
||||
}
|
||||
|
||||
/// <summary>Formats one projected event field for the console: byte strings as hex, times as ISO 8601, null as <c>-</c>.</summary>
|
||||
/// <param name="field">The field value.</param>
|
||||
/// <returns>Its display text.</returns>
|
||||
private static string FormatEventField(Variant field) => ValueConverter.ToClrObject(field) switch
|
||||
private static string FormatEventField(Variant field) => field.Value switch
|
||||
{
|
||||
null => "-",
|
||||
byte[] bytes => Convert.ToHexString(bytes),
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
|
||||
using System.Globalization;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Exceptions;
|
||||
@@ -90,11 +89,11 @@ public class HistoryUpdateCommand : CommandBase
|
||||
throw new CommandException(
|
||||
$"--value and --time must be supplied in pairs ({values.Count} value(s), {times.Count} time(s)).");
|
||||
|
||||
var updates = new List<DataValue>();
|
||||
var updates = new DataValueCollection();
|
||||
for (var i = 0; i < values.Count; i++)
|
||||
{
|
||||
var ts = ParseTime(times[i], "--time");
|
||||
updates.Add(new DataValue(ValueConverter.ToVariant(ParseValue(values[i], Type)), StatusCodes.Good, ts, ts));
|
||||
updates.Add(new DataValue(new Variant(ParseValue(values[i], Type)), StatusCodes.Good, ts, ts));
|
||||
}
|
||||
|
||||
return new UpdateDataDetails
|
||||
@@ -106,7 +105,7 @@ public class HistoryUpdateCommand : CommandBase
|
||||
"replace" => PerformUpdateType.Replace,
|
||||
_ => PerformUpdateType.Update
|
||||
},
|
||||
UpdateValues = updates.ToArrayOf()
|
||||
UpdateValues = updates
|
||||
};
|
||||
}
|
||||
case "delete-raw":
|
||||
@@ -135,9 +134,9 @@ public class HistoryUpdateCommand : CommandBase
|
||||
throw new CommandException("--mode delete-at-time takes --time only; --value is not allowed.");
|
||||
if (times.Count == 0)
|
||||
throw new CommandException("--mode delete-at-time requires at least one --time.");
|
||||
var req = new List<DateTimeUtc>();
|
||||
var req = new DateTimeCollection();
|
||||
foreach (var t in times) req.Add(ParseTime(t, "--time"));
|
||||
return new DeleteAtTimeDetails { NodeId = nodeId, ReqTimes = req.ToArrayOf() };
|
||||
return new DeleteAtTimeDetails { NodeId = nodeId, ReqTimes = req };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -239,8 +238,8 @@ public class HistoryUpdateCommand : CommandBase
|
||||
/// <returns>The status name, or <c>0xXXXXXXXX</c>.</returns>
|
||||
private static string StatusName(StatusCode code)
|
||||
{
|
||||
var name = code.SymbolicId;
|
||||
if (string.IsNullOrEmpty(name)) name = new StatusCode(code.Code & 0xFFFF0000u).SymbolicId;
|
||||
var name = StatusCodes.GetBrowseName(code.Code);
|
||||
if (string.IsNullOrEmpty(name)) name = StatusCodes.GetBrowseName(code.Code & 0xFFFF0000u);
|
||||
return string.IsNullOrEmpty(name) ? $"0x{code.Code:X8}" : name;
|
||||
}
|
||||
|
||||
@@ -248,8 +247,8 @@ public class HistoryUpdateCommand : CommandBase
|
||||
{
|
||||
return details switch
|
||||
{
|
||||
UpdateDataDetails u => (u.UpdateValues.ToArray() ?? []).Select(v => v.SourceTimestamp.ToString("O")).ToList(),
|
||||
DeleteAtTimeDetails d => (d.ReqTimes.ToArray() ?? []).Select(t => t.ToString("O")).ToList(),
|
||||
UpdateDataDetails u => u.UpdateValues.Select(v => v.SourceTimestamp.ToString("O")).ToList(),
|
||||
DeleteAtTimeDetails d => d.ReqTimes.Select(t => t.ToString("O")).ToList(),
|
||||
DeleteRawModifiedDetails r => [$"{r.StartTime:O}..{r.EndTime:O}"],
|
||||
_ => []
|
||||
};
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Exceptions;
|
||||
using CliFx.Infrastructure;
|
||||
@@ -60,12 +59,12 @@ public class ReadCommand : CommandBase
|
||||
// not crash with a stack trace — session-level failures still propagate.
|
||||
await console.Output.WriteLineAsync($"Node: {NodeId}");
|
||||
await console.Output.WriteLineAsync($"Value: ");
|
||||
await console.Output.WriteLineAsync($"Status: {(StatusCode)ex.StatusCode} ({ex.StatusCode.SymbolicId})");
|
||||
await console.Output.WriteLineAsync($"Status: {(StatusCode)ex.StatusCode} ({StatusCodes.GetBrowseName(ex.StatusCode)})");
|
||||
return;
|
||||
}
|
||||
|
||||
await console.Output.WriteLineAsync($"Node: {NodeId}");
|
||||
await console.Output.WriteLineAsync($"Value: {ValueConverter.ToClrObject(value.WrappedValue)}");
|
||||
await console.Output.WriteLineAsync($"Value: {value.Value}");
|
||||
await console.Output.WriteLineAsync($"Status: {value.StatusCode}");
|
||||
await console.Output.WriteLineAsync($"Source Time: {value.SourceTimestamp:O}");
|
||||
await console.Output.WriteLineAsync($"Server Time: {value.ServerTimestamp:O}");
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Threading.Channels;
|
||||
using CliFx.Attributes;
|
||||
@@ -124,14 +123,14 @@ public class SubscribeCommand : CommandBase
|
||||
try
|
||||
{
|
||||
var key = e.NodeId.ToString();
|
||||
lastStatus[key] = (e.Value.StatusCode, DateTime.UtcNow, ValueConverter.ToClrObject(e.Value.WrappedValue));
|
||||
lastStatus[key] = (e.Value.StatusCode, DateTime.UtcNow, e.Value.Value);
|
||||
updateCount.AddOrUpdate(key, 1, (_, v) => v + 1);
|
||||
if (!StatusCode.IsGood(e.Value.StatusCode))
|
||||
everBad.TryAdd(key, 0);
|
||||
if (!Quiet)
|
||||
{
|
||||
var line =
|
||||
$"[{e.Value.SourceTimestamp:O}] {displayNameByNodeId.GetValueOrDefault(key, key)} = {ValueConverter.ToClrObject(e.Value.WrappedValue)} ({e.Value.StatusCode})";
|
||||
$"[{e.Value.SourceTimestamp:O}] {displayNameByNodeId.GetValueOrDefault(key, key)} = {e.Value.Value} ({e.Value.StatusCode})";
|
||||
outputChannel.Writer.TryWrite(line);
|
||||
}
|
||||
}
|
||||
@@ -272,7 +271,7 @@ public class SubscribeCommand : CommandBase
|
||||
var childPath = $"{parentPath}/{child.DisplayName}";
|
||||
if (child.NodeClass == "Variable")
|
||||
{
|
||||
into.Add((nodeId.Value, childPath));
|
||||
into.Add((nodeId, childPath));
|
||||
}
|
||||
if (child.HasChildren)
|
||||
{
|
||||
|
||||
@@ -54,7 +54,7 @@ public class WriteCommand : CommandBase
|
||||
|
||||
// Read current value to determine type for conversion
|
||||
var currentValue = await service.ReadValueAsync(nodeId, ct);
|
||||
var typedValue = ValueConverter.ConvertValue(Value, ValueConverter.ToClrObject(currentValue.WrappedValue));
|
||||
var typedValue = ValueConverter.ConvertValue(Value, currentValue.Value);
|
||||
|
||||
var statusCode = await service.WriteValueAsync(nodeId, typedValue, ct);
|
||||
|
||||
|
||||
@@ -52,6 +52,6 @@ public static class NodeIdParser
|
||||
var result = Parse(nodeIdString);
|
||||
if (result == null)
|
||||
throw new ArgumentException("Node ID is required but was not provided.");
|
||||
return result.Value;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
+12
-18
@@ -58,26 +58,20 @@ internal sealed class DefaultApplicationConfigurationFactory : IApplicationConfi
|
||||
}
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
await config.ValidateAsync(ApplicationType.Client, ct);
|
||||
await config.ValidateAsync(ApplicationType.Client, ct);
|
||||
|
||||
if (settings.SecurityMode != SecurityMode.None)
|
||||
{
|
||||
var app = new ApplicationInstance(ClientTelemetry.Context)
|
||||
{
|
||||
ApplicationName = "OtOpcUaClient",
|
||||
ApplicationType = ApplicationType.Client,
|
||||
ApplicationConfiguration = config
|
||||
};
|
||||
await app.CheckApplicationInstanceCertificatesAsync(false, lifeTimeInMonths: 2048, ct: ct);
|
||||
}
|
||||
}
|
||||
catch
|
||||
if (settings.AutoAcceptCertificates)
|
||||
config.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
|
||||
|
||||
if (settings.SecurityMode != SecurityMode.None)
|
||||
{
|
||||
try { (config.CertificateManager as IDisposable)?.Dispose(); }
|
||||
catch (Exception ex) { Logger.Warning(ex, "Disposing certificate manager after configuration failure"); }
|
||||
throw;
|
||||
var app = new ApplicationInstance(ClientTelemetry.Context)
|
||||
{
|
||||
ApplicationName = "OtOpcUaClient",
|
||||
ApplicationType = ApplicationType.Client,
|
||||
ApplicationConfiguration = config
|
||||
};
|
||||
await app.CheckApplicationInstanceCertificatesAsync(false, 2048);
|
||||
}
|
||||
|
||||
Logger.Debug("ApplicationConfiguration created for {EndpointUrl}", settings.EndpointUrl);
|
||||
|
||||
@@ -19,9 +19,9 @@ internal sealed class DefaultEndpointDiscovery : IEndpointDiscovery
|
||||
// obsolete synchronous CoreClientUtils.SelectEndpoint, which does the same filter + rewrite.)
|
||||
using var client = await DiscoveryClient.CreateAsync(
|
||||
config, new Uri(endpointUrl), DiagnosticsMasks.None, ct).ConfigureAwait(false);
|
||||
var allEndpoints = await client.GetEndpointsAsync(default, ct).ConfigureAwait(false);
|
||||
var allEndpoints = await client.GetEndpointsAsync(null, ct).ConfigureAwait(false);
|
||||
|
||||
return EndpointSelector.SelectBest(allEndpoints.ToArray() ?? [], endpointUrl, requestedMode);
|
||||
return EndpointSelector.SelectBest(allEndpoints, endpointUrl, requestedMode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ internal static class EndpointSelector
|
||||
// advertises its own host:port (docker-dev containers all say :4840); following that would
|
||||
// open the session on a different server and fail the secure-channel certificate check.
|
||||
// Only when the scheme matches: a different scheme (https vs opc.tcp) is a different transport.
|
||||
var serverUri = new Uri(best.EndpointUrl ?? throw new InvalidOperationException("Endpoint has no URL."));
|
||||
var serverUri = new Uri(best.EndpointUrl);
|
||||
var requestedUri = new Uri(endpointUrl);
|
||||
// A URL typed without a port (opc.tcp has no registered default, so Port is -1) keeps the advertised port.
|
||||
var port = requestedUri.Port > 0 ? requestedUri.Port : serverUri.Port;
|
||||
|
||||
@@ -12,25 +12,21 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
||||
{
|
||||
private static readonly ILogger Logger = Log.ForContext<DefaultSessionAdapter>();
|
||||
private readonly Session _session;
|
||||
private readonly IDisposable? _certificateManager;
|
||||
private int _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Wraps a live OPC UA session so the shared client can issue runtime operations through a testable adapter surface.
|
||||
/// </summary>
|
||||
/// <param name="session">The connected OPC UA session used for browsing, reads, writes, history, and subscriptions.</param>
|
||||
/// <param name="certificateManager">The per-connection certificate manager owned until session disposal.</param>
|
||||
public DefaultSessionAdapter(Session session, IDisposable? certificateManager = null)
|
||||
public DefaultSessionAdapter(Session session)
|
||||
{
|
||||
_session = session;
|
||||
_certificateManager = certificateManager;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool Connected => _session.Connected;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string SessionId => _session.SessionId.IsNull ? string.Empty : _session.SessionId.ToString();
|
||||
public string SessionId => _session.SessionId?.ToString() ?? string.Empty;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string SessionName => _session.SessionName ?? string.Empty;
|
||||
@@ -39,7 +35,7 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
||||
public string EndpointUrl => _session.Endpoint?.EndpointUrl ?? string.Empty;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ServerName => _session.Endpoint?.Server?.ApplicationName.Text ?? string.Empty;
|
||||
public string ServerName => _session.Endpoint?.Server?.ApplicationName?.Text ?? string.Empty;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string SecurityMode => _session.Endpoint?.SecurityMode.ToString() ?? string.Empty;
|
||||
@@ -76,13 +72,13 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
||||
Value = value
|
||||
};
|
||||
|
||||
ArrayOf<WriteValue> writeCollection = [writeValue];
|
||||
var writeCollection = new WriteValueCollection { writeValue };
|
||||
var response = await _session.WriteAsync(null, writeCollection, ct);
|
||||
// A malformed or service-level-faulted response can come back with an empty
|
||||
// Results collection alongside a service fault. Surface the service result
|
||||
// (or BadUnexpectedError) rather than letting Results[0] throw
|
||||
// IndexOutOfRangeException upstream.
|
||||
if (response.Results.Count == 0)
|
||||
if (response.Results == null || response.Results.Count == 0)
|
||||
{
|
||||
var serviceResult = response.ResponseHeader?.ServiceResult.Code ?? StatusCodes.BadUnexpectedError;
|
||||
throw new ServiceResultException(serviceResult,
|
||||
@@ -93,7 +89,7 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<(byte[]? ContinuationPoint, ArrayOf<ReferenceDescription> References)> BrowseAsync(
|
||||
public async Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseAsync(
|
||||
NodeId nodeId, uint nodeClassMask, CancellationToken ct)
|
||||
{
|
||||
var (_, continuationPoint, references) = await _session.BrowseAsync(
|
||||
@@ -104,25 +100,19 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
||||
BrowseDirection.Forward,
|
||||
ReferenceTypeIds.HierarchicalReferences,
|
||||
true,
|
||||
nodeClassMask, ct);
|
||||
nodeClassMask);
|
||||
|
||||
return (continuationPoint.IsNull ? null : continuationPoint.ToArray(), references);
|
||||
return (continuationPoint, references ?? []);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<(byte[]? ContinuationPoint, ArrayOf<ReferenceDescription> References)> BrowseNextAsync(
|
||||
public async Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseNextAsync(
|
||||
byte[] continuationPoint, CancellationToken ct)
|
||||
{
|
||||
// Pass the caller's token so a cancelled browse does not block on the continuation
|
||||
// round-trip.
|
||||
var (_, nextCp, nextRefs) = await _session.BrowseNextAsync(null, false, ByteString.From(continuationPoint), ct);
|
||||
return (nextCp.IsNull ? null : nextCp.ToArray(), nextRefs);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task ReleaseBrowseContinuationPointAsync(byte[] continuationPoint, CancellationToken ct)
|
||||
{
|
||||
await _session.BrowseNextAsync(null, true, ByteString.From(continuationPoint), ct).ConfigureAwait(false);
|
||||
var (_, nextCp, nextRefs) = await _session.BrowseNextAsync(null, false, continuationPoint, ct);
|
||||
return (nextCp, nextRefs ?? []);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -130,16 +120,16 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
||||
{
|
||||
var response = await _session.HistoryUpdateAsync(
|
||||
null,
|
||||
[new ExtensionObject(details)],
|
||||
new ExtensionObjectCollection { new ExtensionObject(details) },
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
var statuses = new List<StatusCode>();
|
||||
var ops = new List<IReadOnlyList<StatusCode>>();
|
||||
if (!response.Results.IsEmpty)
|
||||
if (response.Results != null)
|
||||
foreach (var r in response.Results)
|
||||
{
|
||||
statuses.Add(r.StatusCode);
|
||||
ops.Add(r.OperationResults.ToArray() ?? []);
|
||||
ops.Add(r.OperationResults?.ToArray() ?? Array.Empty<StatusCode>());
|
||||
}
|
||||
|
||||
return new Models.HistoryUpdateOutcome(response.ResponseHeader.ServiceResult, statuses, ops);
|
||||
@@ -162,10 +152,10 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
||||
// by the cap releases the server's cursor (HistoryReadPaging). This path used to pass
|
||||
// `releaseContinuationPoints: continuationPoint != null`, which RELEASED the second page instead of reading it,
|
||||
// silently truncating every raw read to its first server page.
|
||||
return await HistoryReadPaging.ReadAllAsync<DataValue>(
|
||||
return await HistoryReadPaging.ReadAllAsync(
|
||||
(cp, release, token) => ReadHistoryPageAsync(details, nodeId, cp, release, TimestampsToReturn.Source, token),
|
||||
result => result.HistoryData.TryGetValue(out HistoryData? historyData)
|
||||
? historyData.DataValues.ToArray() ?? []
|
||||
result => result.HistoryData is ExtensionObject { Body: HistoryData historyData }
|
||||
? historyData.DataValues
|
||||
: [],
|
||||
maxValues, nodeId, ct).ConfigureAwait(false);
|
||||
}
|
||||
@@ -203,21 +193,21 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
||||
|
||||
return await HistoryReadPaging.ReadAllAsync(
|
||||
(cp, release, token) => ReadHistoryPageAsync(details, nodeId, cp, release, TimestampsToReturn.Both, token),
|
||||
result => result.HistoryData.TryGetValue(out HistoryEvent? historyEvent)
|
||||
? (historyEvent.Events.ToArray() ?? []).Select(e => new Models.HistoryEventEntry(e.EventFields.ToArray() ?? []))
|
||||
result => result.HistoryData is ExtensionObject { Body: HistoryEvent historyEvent }
|
||||
? historyEvent.Events.Select(e => new Models.HistoryEventEntry(e.EventFields?.ToArray() ?? []))
|
||||
: [],
|
||||
maxEvents, nodeId, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static IEnumerable<Models.HistoryModifiedEntry> ToModifiedEntries(HistoryReadResult result)
|
||||
{
|
||||
if (!result.HistoryData.TryGetValue(out HistoryModifiedData? modified)) yield break;
|
||||
if (result.HistoryData is not ExtensionObject { Body: HistoryModifiedData modified }) yield break;
|
||||
for (var i = 0; i < modified.DataValues.Count; i++)
|
||||
{
|
||||
var info = i < modified.ModificationInfos.Count ? modified.ModificationInfos[i] : null;
|
||||
yield return new Models.HistoryModifiedEntry(
|
||||
modified.DataValues[i],
|
||||
info?.ModificationTime.ToDateTime() ?? DateTime.MinValue,
|
||||
info?.ModificationTime ?? DateTime.MinValue,
|
||||
info?.UpdateType ?? default,
|
||||
info?.UserName);
|
||||
}
|
||||
@@ -228,10 +218,10 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
||||
HistoryReadDetails details, NodeId nodeId, byte[]? continuationPoint, bool release,
|
||||
TimestampsToReturn timestamps, CancellationToken ct)
|
||||
{
|
||||
ArrayOf<HistoryReadValueId> nodesToRead =
|
||||
[
|
||||
new HistoryReadValueId { NodeId = nodeId, ContinuationPoint = ByteString.From(continuationPoint) }
|
||||
];
|
||||
var nodesToRead = new HistoryReadValueIdCollection
|
||||
{
|
||||
new HistoryReadValueId { NodeId = nodeId, ContinuationPoint = continuationPoint }
|
||||
};
|
||||
|
||||
// The async overload honours the token and does not block the caller's thread (the UI dispatcher for
|
||||
// client.ui consumers).
|
||||
@@ -254,7 +244,10 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
||||
AggregateType = [aggregateId]
|
||||
};
|
||||
|
||||
ArrayOf<HistoryReadValueId> nodesToRead = [new HistoryReadValueId { NodeId = nodeId }];
|
||||
var nodesToRead = new HistoryReadValueIdCollection
|
||||
{
|
||||
new HistoryReadValueId { NodeId = nodeId }
|
||||
};
|
||||
|
||||
// Use the async overload so the method honors the cancellation token and
|
||||
// does not block on a synchronous service round-trip.
|
||||
@@ -269,23 +262,13 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
||||
var results = response.Results;
|
||||
var allValues = new List<DataValue>();
|
||||
|
||||
if (results.Count > 0)
|
||||
if (results != null && results.Count > 0)
|
||||
{
|
||||
var result = results[0];
|
||||
try
|
||||
{
|
||||
HistoryReadStatus.ThrowIfRefused(result.StatusCode, nodeId);
|
||||
if (result.HistoryData.TryGetValue(out HistoryData? historyData))
|
||||
allValues.AddRange(historyData.DataValues);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Keep the existing one-shot processed read; it owns no outstanding server cursor on return.
|
||||
if (!result.ContinuationPoint.IsEmpty)
|
||||
await HistoryReadPaging.ReleaseAsync(
|
||||
(cp, release, token) => ReadHistoryPageAsync(details, nodeId, cp, release, TimestampsToReturn.Source, token),
|
||||
result.ContinuationPoint.ToArray(), nodeId).ConfigureAwait(false);
|
||||
}
|
||||
HistoryReadStatus.ThrowIfRefused(result.StatusCode, nodeId);
|
||||
if (result.HistoryData is ExtensionObject ext &&
|
||||
ext.Body is HistoryData historyData)
|
||||
allValues.AddRange(historyData.DataValues);
|
||||
}
|
||||
|
||||
return allValues;
|
||||
@@ -339,7 +322,6 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
|
||||
try
|
||||
{
|
||||
if (_session.Connected) DisposeBridge.Run(ct => _session.CloseAsync(ct));
|
||||
@@ -348,8 +330,7 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
||||
{
|
||||
}
|
||||
|
||||
try { _session.Dispose(); }
|
||||
finally { _certificateManager?.Dispose(); }
|
||||
_session.Dispose();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -358,20 +339,20 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
||||
{
|
||||
var result = await _session.CallAsync(
|
||||
null,
|
||||
new CallMethodRequest[]
|
||||
new CallMethodRequestCollection
|
||||
{
|
||||
new()
|
||||
{
|
||||
ObjectId = objectId,
|
||||
MethodId = methodId,
|
||||
InputArguments = inputArguments.Select(ValueConverter.ToVariant).ToArrayOf()
|
||||
InputArguments = new VariantCollection(inputArguments.Select(a => new Variant(a)))
|
||||
}
|
||||
},
|
||||
ct);
|
||||
|
||||
// An empty Results collection paired with a service fault must surface as
|
||||
// a ServiceResultException, not an IndexOutOfRangeException from Results[0].
|
||||
if (result.Results.Count == 0)
|
||||
if (result.Results == null || result.Results.Count == 0)
|
||||
{
|
||||
var serviceResult = result.ResponseHeader?.ServiceResult.Code ?? StatusCodes.BadUnexpectedError;
|
||||
throw new ServiceResultException(serviceResult,
|
||||
@@ -382,7 +363,6 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
||||
if (StatusCode.IsBad(callResult.StatusCode))
|
||||
throw new ServiceResultException(callResult.StatusCode);
|
||||
|
||||
return callResult.OutputArguments.IsNull ? null
|
||||
: (callResult.OutputArguments.ToArray() ?? []).Select(v => ValueConverter.ToClrObject(v)!).ToList();
|
||||
return callResult.OutputArguments?.Select(v => v.Value).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,13 +30,13 @@ internal sealed class DefaultSessionFactory : ISessionFactory
|
||||
sessionName,
|
||||
sessionTimeoutMs,
|
||||
identity,
|
||||
default,
|
||||
null,
|
||||
ct);
|
||||
var session = created as Session
|
||||
?? throw new InvalidOperationException(
|
||||
$"Session factory returned {created.GetType().Name}, expected {nameof(Session)}.");
|
||||
|
||||
Logger.Information("Session created: {SessionName} -> {EndpointUrl}", sessionName, endpoint.EndpointUrl);
|
||||
return new DefaultSessionAdapter(session, config.CertificateManager as IDisposable);
|
||||
return new DefaultSessionAdapter(session);
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,6 @@ internal sealed class DefaultSubscriptionAdapter : ISubscriptionAdapter
|
||||
private readonly Subscription _subscription;
|
||||
private readonly Func<Subscription, CancellationToken, Task>? _detach;
|
||||
private int _detached;
|
||||
private int _finishing;
|
||||
private readonly TaskCompletionSource _finished = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
/// <summary>
|
||||
/// Wraps a live OPC UA subscription so client code can manage monitored items through a testable abstraction.
|
||||
@@ -49,7 +47,7 @@ internal sealed class DefaultSubscriptionAdapter : ISubscriptionAdapter
|
||||
|
||||
item.Notification += (_, e) =>
|
||||
{
|
||||
if (Volatile.Read(ref _finishing) == 0 && e.NotificationValue is MonitoredItemNotification notification)
|
||||
if (e.NotificationValue is MonitoredItemNotification notification)
|
||||
onDataChange(nodeId.ToString(), notification.Value);
|
||||
};
|
||||
|
||||
@@ -92,7 +90,7 @@ internal sealed class DefaultSubscriptionAdapter : ISubscriptionAdapter
|
||||
|
||||
item.Notification += (_, e) =>
|
||||
{
|
||||
if (Volatile.Read(ref _finishing) == 0 && e.NotificationValue is EventFieldList eventFields) onEvent(eventFields);
|
||||
if (e.NotificationValue is EventFieldList eventFields) onEvent(eventFields);
|
||||
};
|
||||
|
||||
_subscription.AddItem(item);
|
||||
@@ -114,28 +112,19 @@ internal sealed class DefaultSubscriptionAdapter : ISubscriptionAdapter
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteAsync(CancellationToken ct)
|
||||
{
|
||||
if (Interlocked.Exchange(ref _finishing, 1) != 0)
|
||||
{
|
||||
await _finished.Task.WaitAsync(ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
try { await _subscription.DeleteAsync(true, ct).ConfigureAwait(false); }
|
||||
catch (Exception ex) { Logger.Warning(ex, "Error deleting subscription"); }
|
||||
|
||||
// A cancelled delete still has local resources to release. Cleanup gets its own bounded token.
|
||||
using var cleanup = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
try { await DetachAsync(cleanup.Token).WaitAsync(cleanup.Token).ConfigureAwait(false); }
|
||||
catch (Exception ex) { Logger.Warning(ex, "Error detaching subscription"); }
|
||||
finally
|
||||
{
|
||||
_monitoredItems.Clear();
|
||||
try { await Task.Run(_subscription.Dispose).WaitAsync(cleanup.Token).ConfigureAwait(false); }
|
||||
catch (Exception ex) { Logger.Warning(ex, "Error disposing subscription resources"); }
|
||||
}
|
||||
// Forward the caller's token so the delete can be cancelled.
|
||||
await _subscription.DeleteAsync(true, ct);
|
||||
}
|
||||
finally { _finished.TrySetResult(); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warning(ex, "Error deleting subscription");
|
||||
}
|
||||
|
||||
// Detach whether or not the server-side delete succeeded: the adapter is finished with it either way.
|
||||
await DetachAsync(ct);
|
||||
_monitoredItems.Clear();
|
||||
}
|
||||
|
||||
private async Task DetachAsync(CancellationToken ct)
|
||||
@@ -156,6 +145,21 @@ internal sealed class DefaultSubscriptionAdapter : ISubscriptionAdapter
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
DisposeBridge.Run(DeleteAsync);
|
||||
// One bounded bridge (5 s) for delete + detach, so a dispose never blocks longer than before (review m6).
|
||||
// DetachAsync swallows its own failures; a failed delete must not skip the detach.
|
||||
try
|
||||
{
|
||||
DisposeBridge.Run(async ct =>
|
||||
{
|
||||
try { await _subscription.DeleteAsync(true, ct); }
|
||||
catch (Exception ex) { Logger.Warning(ex, "Error deleting subscription"); }
|
||||
await DetachAsync(ct);
|
||||
});
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
_monitoredItems.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ internal interface ISessionAdapter : IDisposable
|
||||
/// <param name="nodeClassMask">The node classes that should be returned to the caller.</param>
|
||||
/// <param name="ct">The cancellation token that aborts the browse request.</param>
|
||||
/// <returns>A continuation point (or <c>null</c> when complete) and the references found.</returns>
|
||||
Task<(byte[]? ContinuationPoint, ArrayOf<ReferenceDescription> References)> BrowseAsync(
|
||||
Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseAsync(
|
||||
NodeId nodeId, uint nodeClassMask = 0, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
@@ -87,15 +87,9 @@ internal interface ISessionAdapter : IDisposable
|
||||
/// <param name="continuationPoint">The continuation token returned by a prior browse result page.</param>
|
||||
/// <param name="ct">The cancellation token that aborts the browse-next request.</param>
|
||||
/// <returns>The next continuation point (or <c>null</c> when exhausted) and the additional references.</returns>
|
||||
Task<(byte[]? ContinuationPoint, ArrayOf<ReferenceDescription> References)> BrowseNextAsync(
|
||||
Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseNextAsync(
|
||||
byte[] continuationPoint, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Releases a browse cursor that the caller stops following.</summary>
|
||||
/// <param name="continuationPoint">The cursor owned by this session.</param>
|
||||
/// <param name="ct">A bounded cleanup token independent of the cancelled browse.</param>
|
||||
/// <returns>The completion of the release request.</returns>
|
||||
Task ReleaseBrowseContinuationPointAsync(byte[] continuationPoint, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Reads raw historical data.
|
||||
/// </summary>
|
||||
|
||||
@@ -56,15 +56,15 @@ internal static class HistoryReadPaging
|
||||
HistoryReadStatus.ThrowIfRefused(result.StatusCode, nodeId);
|
||||
all.AddRange(extract(result));
|
||||
|
||||
continuationPoint = result.ContinuationPoint.IsNull ? null : result.ContinuationPoint.ToArray();
|
||||
continuationPoint = result.ContinuationPoint;
|
||||
if (continuationPoint is not { Length: > 0 }) return Trim(all, maxValues);
|
||||
if (all.Count >= maxValues) break;
|
||||
}
|
||||
}
|
||||
catch
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// A refused/failed/cancelled continuation still leaves the last cursor owned by this read.
|
||||
// Best-effort release must preserve the original operation failure.
|
||||
// Cancelled between pages (or during page N+1) with a cursor outstanding: release it rather than leave it
|
||||
// occupying one of the session's continuation-point slots, then surface the cancellation.
|
||||
if (continuationPoint is { Length: > 0 }) await ReleaseAsync(readPage, continuationPoint, nodeId);
|
||||
throw;
|
||||
}
|
||||
@@ -81,12 +81,12 @@ internal static class HistoryReadPaging
|
||||
return all;
|
||||
}
|
||||
|
||||
internal static async Task ReleaseAsync(PageReader readPage, byte[] continuationPoint, NodeId nodeId)
|
||||
private static async Task ReleaseAsync(PageReader readPage, byte[] continuationPoint, NodeId nodeId)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var cts = new CancellationTokenSource(ReleaseTimeout);
|
||||
await readPage(continuationPoint, true, cts.Token).WaitAsync(cts.Token).ConfigureAwait(false);
|
||||
await readPage(continuationPoint, true, cts.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -19,6 +19,6 @@ internal static class HistoryReadStatus
|
||||
{
|
||||
if (!StatusCode.IsBad(status) || status.Code == StatusCodes.BadNoData) return;
|
||||
throw new ServiceResultException(status.Code,
|
||||
$"HistoryRead of {nodeId} failed: {status.ToString()} (0x{status.Code:X8}).");
|
||||
$"HistoryRead of {nodeId} failed: {StatusCodes.GetBrowseName(status.Code)} (0x{status.Code:X8}).");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
using Opc.Ua;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
|
||||
|
||||
/// <summary>
|
||||
@@ -8,49 +6,6 @@ namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
|
||||
/// </summary>
|
||||
public static class ValueConverter
|
||||
{
|
||||
/// <summary>Converts a supported client payload to an SDK value, detaching caller-owned buffers.</summary>
|
||||
/// <param name="value">The existing CLR or SDK payload.</param>
|
||||
/// <returns>The corresponding OPC UA value.</returns>
|
||||
/// <exception cref="ServiceResultException">The payload shape is unsupported.</exception>
|
||||
public static Variant ToVariant(object? value)
|
||||
{
|
||||
// Historically a CLR byte[] supplied to a client write/method was a scalar ByteString.
|
||||
if (value is byte[] bytes && bytes.GetType() == typeof(byte[])) return Variant.From(ByteString.From(bytes.AsSpan()));
|
||||
if (value is ByteString byteString) return Variant.From(byteString.IsNull ? default : byteString.Copy());
|
||||
if (!VariantHelper.TryCastFrom(value, out Variant variant))
|
||||
throw new ServiceResultException(StatusCodes.BadTypeMismatch, "Unsupported client value type or shape.");
|
||||
if (variant.TypeInfo.IsMatrix)
|
||||
throw new ServiceResultException(StatusCodes.BadTypeMismatch, "Unsupported client matrix shape.");
|
||||
if (variant.TypeInfo.IsArray && variant.TryGetValue(out ArrayOf<ByteString> buffers))
|
||||
return Variant.From(buffers.IsNull ? default : buffers.ConvertAll(b => b.IsNull ? default : b.Copy()));
|
||||
// ArrayOf is read-only, but can wrap externally owned ReadOnlyMemory. Detach it too.
|
||||
if (value is not Array && variant.TypeInfo.IsArray && variant.AsBoxedObject(Variant.BoxingBehavior.Legacy) is Array array)
|
||||
{
|
||||
if (!VariantHelper.TryCastFrom(array, out variant))
|
||||
throw new ServiceResultException(StatusCodes.BadTypeMismatch, "Unsupported client array type.");
|
||||
}
|
||||
else if (variant.TypeInfo.IsScalar && variant.TryGetValue(out ByteString retainedBytes))
|
||||
variant = Variant.From(retainedBytes.IsNull ? default : retainedBytes.Copy());
|
||||
return variant;
|
||||
}
|
||||
|
||||
/// <summary>Returns the CLR representation expected by existing client object consumers.</summary>
|
||||
/// <param name="value">The SDK value to inspect.</param>
|
||||
/// <returns>A CLR scalar or array, preserving null versus empty values.</returns>
|
||||
public static object? ToClrObject(Variant value)
|
||||
{
|
||||
return value.AsBoxedObject(Variant.BoxingBehavior.Legacy) switch
|
||||
{
|
||||
ByteString bytes => bytes.IsNull ? null : bytes.ToArray(),
|
||||
DateTimeUtc timestamp => timestamp.ToDateTime(),
|
||||
Uuid guid => (Guid)guid,
|
||||
DateTimeUtc[] timestamps => timestamps.Select(t => t.ToDateTime()).ToArray(),
|
||||
Uuid[] guids => guids.Select(g => (Guid)g).ToArray(),
|
||||
ByteString[] buffers => buffers.Select(b => b.IsNull ? null : b.ToArray()).ToArray(),
|
||||
var payload => payload
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a raw string value into the runtime type expected by the target node.
|
||||
/// </summary>
|
||||
|
||||
@@ -221,13 +221,13 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
if (value is string rawString)
|
||||
{
|
||||
var currentDataValue = await _session!.ReadValueAsync(nodeId, ct);
|
||||
if (!StatusCode.IsGood(currentDataValue.StatusCode) || ValueConverter.ToClrObject(currentDataValue.WrappedValue) == null)
|
||||
if (!StatusCode.IsGood(currentDataValue.StatusCode) || currentDataValue.Value == null)
|
||||
throw new InvalidOperationException(
|
||||
$"Cannot infer target type for node {nodeId}: read returned status {currentDataValue.StatusCode} with no value. Provide a typed value instead of a string.");
|
||||
typedValue = ValueConverter.ConvertValue(rawString, ValueConverter.ToClrObject(currentDataValue.WrappedValue));
|
||||
typedValue = ValueConverter.ConvertValue(rawString, currentDataValue.Value);
|
||||
}
|
||||
|
||||
var dataValue = new DataValue(ValueConverter.ToVariant(typedValue));
|
||||
var dataValue = new DataValue(new Variant(typedValue));
|
||||
return await _session!.WriteValueAsync(nodeId, dataValue, ct);
|
||||
}
|
||||
|
||||
@@ -242,39 +242,22 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
var nodeClassMask = (uint)NodeClass.Object | (uint)NodeClass.Variable | (uint)NodeClass.Method;
|
||||
var results = new List<BrowseResult>();
|
||||
|
||||
// A cursor belongs to the session that issued it, including while failover swaps _session.
|
||||
var session = _session!;
|
||||
byte[]? continuationPoint = null;
|
||||
try
|
||||
{
|
||||
ArrayOf<ReferenceDescription> references;
|
||||
(continuationPoint, references) = await session.BrowseAsync(startNode, nodeClassMask, ct);
|
||||
while (references.Count > 0)
|
||||
{
|
||||
foreach (var reference in references)
|
||||
results.Add(new BrowseResult(
|
||||
reference.NodeId.ToString(),
|
||||
reference.DisplayName.Text ?? string.Empty,
|
||||
reference.NodeClass.ToString(),
|
||||
reference.NodeClass == NodeClass.Object));
|
||||
var (continuationPoint, references) = await _session!.BrowseAsync(startNode, nodeClassMask, ct);
|
||||
|
||||
if (continuationPoint is not { Length: > 0 } pending) break;
|
||||
ct.ThrowIfCancellationRequested();
|
||||
(continuationPoint, references) = await session.BrowseNextAsync(pending, ct);
|
||||
}
|
||||
}
|
||||
finally
|
||||
while (references.Count > 0)
|
||||
{
|
||||
if (continuationPoint is { Length: > 0 })
|
||||
{
|
||||
try
|
||||
{
|
||||
using var release = new CancellationTokenSource(HistoryReadPaging.ReleaseTimeout);
|
||||
await session.ReleaseBrowseContinuationPointAsync(continuationPoint, release.Token)
|
||||
.WaitAsync(release.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) { Logger.Warning(ex, "Releasing browse continuation point failed"); }
|
||||
}
|
||||
// Child detection is lazy: an Object MAY have children and is confirmed when the caller expands or
|
||||
// recurses into it. Probing each Object child with its own Browse made one level of N folders cost N + 1
|
||||
// round trips, and a recursive subscribe over a 50k-node tree ~50k extra Browse calls.
|
||||
foreach (var reference in references)
|
||||
results.Add(new BrowseResult(
|
||||
reference.NodeId.ToString(),
|
||||
reference.DisplayName?.Text ?? string.Empty,
|
||||
reference.NodeClass.ToString(),
|
||||
reference.NodeClass == NodeClass.Object));
|
||||
|
||||
if (continuationPoint is not { Length: > 0 } pending) break;
|
||||
(continuationPoint, references) = await _session.BrowseNextAsync(pending, ct);
|
||||
}
|
||||
|
||||
return results;
|
||||
@@ -310,7 +293,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
}
|
||||
|
||||
var handle = await subscription.AddDataChangeMonitoredItemAsync(
|
||||
nodeId, intervalMs, DataCallback(session, subscription), ct);
|
||||
nodeId, intervalMs, OnDataChangeNotification, ct);
|
||||
|
||||
lock (_subscriptionLock)
|
||||
{
|
||||
@@ -394,7 +377,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
|
||||
var filter = CreateAlarmEventFilter();
|
||||
await subscription.AddEventMonitoredItemAsync(
|
||||
monitorNode, intervalMs, filter, AlarmCallback(session, subscription), ct);
|
||||
monitorNode, intervalMs, filter, OnAlarmEventNotification, ct);
|
||||
|
||||
lock (_subscriptionLock)
|
||||
{
|
||||
@@ -700,15 +683,15 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
var redundancySupportValue =
|
||||
await _session!.ReadValueAsync(VariableIds.Server_ServerRedundancy_RedundancySupport, ct);
|
||||
RedundancySupport redundancySupport;
|
||||
if (StatusCode.IsGood(redundancySupportValue.StatusCode) && ValueConverter.ToClrObject(redundancySupportValue.WrappedValue) != null)
|
||||
redundancySupport = (RedundancySupport)Convert.ToInt32(ValueConverter.ToClrObject(redundancySupportValue.WrappedValue));
|
||||
if (StatusCode.IsGood(redundancySupportValue.StatusCode) && redundancySupportValue.Value != null)
|
||||
redundancySupport = (RedundancySupport)Convert.ToInt32(redundancySupportValue.Value);
|
||||
else
|
||||
redundancySupport = RedundancySupport.None;
|
||||
var redundancyMode = redundancySupport.ToString();
|
||||
|
||||
var serviceLevelValue = await _session.ReadValueAsync(VariableIds.Server_ServiceLevel, ct);
|
||||
var serviceLevel = StatusCode.IsGood(serviceLevelValue.StatusCode) && ValueConverter.ToClrObject(serviceLevelValue.WrappedValue) != null
|
||||
? Convert.ToByte(ValueConverter.ToClrObject(serviceLevelValue.WrappedValue))
|
||||
var serviceLevel = StatusCode.IsGood(serviceLevelValue.StatusCode) && serviceLevelValue.Value != null
|
||||
? Convert.ToByte(serviceLevelValue.Value)
|
||||
: (byte)0;
|
||||
|
||||
string[] serverUris = [];
|
||||
@@ -716,7 +699,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
{
|
||||
var serverUriArrayValue =
|
||||
await _session.ReadValueAsync(VariableIds.Server_ServerRedundancy_ServerUriArray, ct);
|
||||
if (ValueConverter.ToClrObject(serverUriArrayValue.WrappedValue) is string[] uris)
|
||||
if (serverUriArrayValue.Value is string[] uris)
|
||||
serverUris = uris;
|
||||
}
|
||||
catch
|
||||
@@ -728,7 +711,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
try
|
||||
{
|
||||
var serverArrayValue = await _session.ReadValueAsync(VariableIds.Server_ServerArray, ct);
|
||||
if (ValueConverter.ToClrObject(serverArrayValue.WrappedValue) is string[] serverArray && serverArray.Length > 0)
|
||||
if (serverArrayValue.Value is string[] serverArray && serverArray.Length > 0)
|
||||
applicationUri = serverArray[0];
|
||||
}
|
||||
catch
|
||||
@@ -783,26 +766,16 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
};
|
||||
|
||||
var config = await _configFactory.CreateAsync(effectiveSettings, ct);
|
||||
try
|
||||
{
|
||||
var requestedMode = SecurityModeMapper.ToMessageSecurityMode(settings.SecurityMode);
|
||||
var endpoint = await _endpointDiscovery.SelectEndpointAsync(config, endpointUrl, requestedMode, ct);
|
||||
var requestedMode = SecurityModeMapper.ToMessageSecurityMode(settings.SecurityMode);
|
||||
var endpoint = await _endpointDiscovery.SelectEndpointAsync(config, endpointUrl, requestedMode, ct);
|
||||
|
||||
var identity = settings.Username != null
|
||||
? new UserIdentity(settings.Username, Encoding.UTF8.GetBytes(settings.Password ?? ""))
|
||||
: new UserIdentity();
|
||||
var identity = settings.Username != null
|
||||
? new UserIdentity(settings.Username, Encoding.UTF8.GetBytes(settings.Password ?? ""))
|
||||
: new UserIdentity();
|
||||
|
||||
var sessionTimeoutMs = (uint)(settings.SessionTimeoutSeconds * 1000);
|
||||
return await _sessionFactory.CreateSessionAsync(config, endpoint, "OtOpcUaClient", sessionTimeoutMs, identity,
|
||||
ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// The session adapter owns it after successful creation; a failed attempt has no adapter owner.
|
||||
try { (config.CertificateManager as IDisposable)?.Dispose(); }
|
||||
catch (Exception ex) { Logger.Warning(ex, "Disposing certificate manager after connection failure"); }
|
||||
throw;
|
||||
}
|
||||
var sessionTimeoutMs = (uint)(settings.SessionTimeoutSeconds * 1000);
|
||||
return await _sessionFactory.CreateSessionAsync(config, endpoint, "OtOpcUaClient", sessionTimeoutMs, identity,
|
||||
ct);
|
||||
}
|
||||
|
||||
/// <summary>A bad keep-alive from the session of connection <paramref name="epoch"/>.</summary>
|
||||
@@ -989,7 +962,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
}
|
||||
|
||||
var handle = await dataSubscription.AddDataChangeMonitoredItemAsync(
|
||||
nodeId, intervalMs, DataCallback(session, dataSubscription), CancellationToken.None);
|
||||
nodeId, intervalMs, OnDataChangeNotification, CancellationToken.None);
|
||||
|
||||
lock (_subscriptionLock)
|
||||
{
|
||||
@@ -1012,7 +985,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
lock (_subscriptionLock) _alarmSubscription = replayedAlarms;
|
||||
var filter = CreateAlarmEventFilter();
|
||||
await replayedAlarms.AddEventMonitoredItemAsync(
|
||||
monitorNode, intervalMs, filter, AlarmCallback(session, replayedAlarms), CancellationToken.None);
|
||||
monitorNode, intervalMs, filter, OnAlarmEventNotification, CancellationToken.None);
|
||||
|
||||
lock (_subscriptionLock)
|
||||
{
|
||||
@@ -1026,25 +999,6 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsCurrentNotification(ISessionAdapter session, ISubscriptionAdapter subscription, bool alarm)
|
||||
{
|
||||
lock (_subscriptionLock)
|
||||
return !_disposed && _state == ConnectionState.Connected && ReferenceEquals(_session, session)
|
||||
&& ReferenceEquals(alarm ? _alarmSubscription : _dataSubscription, subscription);
|
||||
}
|
||||
|
||||
private Action<string, DataValue> DataCallback(ISessionAdapter session, ISubscriptionAdapter subscription) =>
|
||||
(nodeId, value) =>
|
||||
{
|
||||
if (IsCurrentNotification(session, subscription, false)) OnDataChangeNotification(nodeId, value);
|
||||
};
|
||||
|
||||
private Action<EventFieldList> AlarmCallback(ISessionAdapter session, ISubscriptionAdapter subscription) =>
|
||||
fields =>
|
||||
{
|
||||
if (IsCurrentNotification(session, subscription, true)) OnAlarmEventNotification(fields);
|
||||
};
|
||||
|
||||
private void OnDataChangeNotification(string nodeId, DataValue value)
|
||||
{
|
||||
DataChanged?.Invoke(this, new DataChangedEventArgs(nodeId, value));
|
||||
@@ -1053,28 +1007,28 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
private void OnAlarmEventNotification(EventFieldList eventFields)
|
||||
{
|
||||
var fields = eventFields.EventFields;
|
||||
if (fields.Count < 1)
|
||||
if (fields == null || fields.Count < 1)
|
||||
return;
|
||||
|
||||
var eventId = fields.Count > 0 ? ValueConverter.ToClrObject(fields[0]) as byte[] : null;
|
||||
var sourceName = fields.Count > 2 ? ValueConverter.ToClrObject(fields[2]) as string ?? string.Empty : string.Empty;
|
||||
var eventId = fields.Count > 0 ? fields[0].Value as byte[] : null;
|
||||
var sourceName = fields.Count > 2 ? fields[2].Value as string ?? string.Empty : string.Empty;
|
||||
var time = DateTime.MinValue;
|
||||
if (fields.Count > 3 && ValueConverter.ToClrObject(fields[3]) != null)
|
||||
if (fields.Count > 3 && fields[3].Value != null)
|
||||
{
|
||||
if (ValueConverter.ToClrObject(fields[3]) is DateTime dt)
|
||||
if (fields[3].Value is DateTime dt)
|
||||
time = dt;
|
||||
else if (DateTime.TryParse(ValueConverter.ToClrObject(fields[3])?.ToString(), out var parsed))
|
||||
else if (DateTime.TryParse(fields[3].Value.ToString(), out var parsed))
|
||||
time = parsed;
|
||||
}
|
||||
var message = fields.Count > 4 ? (fields[4].TryGetValue(out LocalizedText text) ? text.Text : null) ?? string.Empty : string.Empty;
|
||||
var severity = fields.Count > 5 ? Convert.ToUInt16(ValueConverter.ToClrObject(fields[5])) : (ushort)0;
|
||||
var conditionName = fields.Count > 6 ? ValueConverter.ToClrObject(fields[6]) as string ?? string.Empty : string.Empty;
|
||||
var retain = fields.Count > 7 && ParseBool(ValueConverter.ToClrObject(fields[7]));
|
||||
var conditionNodeId = fields.Count > 12 ? ValueConverter.ToClrObject(fields[12])?.ToString() : null;
|
||||
var message = fields.Count > 4 ? (fields[4].Value as LocalizedText)?.Text ?? string.Empty : string.Empty;
|
||||
var severity = fields.Count > 5 ? Convert.ToUInt16(fields[5].Value) : (ushort)0;
|
||||
var conditionName = fields.Count > 6 ? fields[6].Value as string ?? string.Empty : string.Empty;
|
||||
var retain = fields.Count > 7 && ParseBool(fields[7].Value);
|
||||
var conditionNodeId = fields.Count > 12 ? fields[12].Value?.ToString() : null;
|
||||
|
||||
// Try standard OPC UA ActiveState/AckedState fields first
|
||||
bool? ackedField = fields.Count > 8 && ValueConverter.ToClrObject(fields[8]) != null ? ParseBool(ValueConverter.ToClrObject(fields[8])) : null;
|
||||
bool? activeField = fields.Count > 9 && ValueConverter.ToClrObject(fields[9]) != null ? ParseBool(ValueConverter.ToClrObject(fields[9])) : null;
|
||||
bool? ackedField = fields.Count > 8 && fields[8].Value != null ? ParseBool(fields[8].Value) : null;
|
||||
bool? activeField = fields.Count > 9 && fields[9].Value != null ? ParseBool(fields[9].Value) : null;
|
||||
|
||||
var ackedState = ackedField ?? false;
|
||||
var activeState = activeField ?? false;
|
||||
@@ -1099,25 +1053,39 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
/// <returns>The populated <see cref="EventFilter"/>.</returns>
|
||||
internal static EventFilter CreateAlarmEventFilter()
|
||||
{
|
||||
var filter = new EventFilter
|
||||
{
|
||||
SelectClauses =
|
||||
[
|
||||
new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.BaseEventType, AttributeId = Attributes.Value, BrowsePath = [new QualifiedName("EventId")] },
|
||||
new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.BaseEventType, AttributeId = Attributes.Value, BrowsePath = [new QualifiedName("EventType")] },
|
||||
new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.BaseEventType, AttributeId = Attributes.Value, BrowsePath = [new QualifiedName("SourceName")] },
|
||||
new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.BaseEventType, AttributeId = Attributes.Value, BrowsePath = [new QualifiedName("Time")] },
|
||||
new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.BaseEventType, AttributeId = Attributes.Value, BrowsePath = [new QualifiedName("Message")] },
|
||||
new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.BaseEventType, AttributeId = Attributes.Value, BrowsePath = [new QualifiedName("Severity")] },
|
||||
new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.ConditionType, AttributeId = Attributes.Value, BrowsePath = [new QualifiedName("ConditionName")] },
|
||||
new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.ConditionType, AttributeId = Attributes.Value, BrowsePath = [new QualifiedName("Retain")] },
|
||||
new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.AcknowledgeableConditionType, AttributeId = Attributes.Value, BrowsePath = [new QualifiedName("AckedState"), new QualifiedName("Id")] },
|
||||
new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.AlarmConditionType, AttributeId = Attributes.Value, BrowsePath = [new QualifiedName("ActiveState"), new QualifiedName("Id")] },
|
||||
new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.ConditionType, AttributeId = Attributes.Value, BrowsePath = [new QualifiedName("EnabledState"), new QualifiedName("Id")] },
|
||||
new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.AlarmConditionType, AttributeId = Attributes.Value, BrowsePath = [new QualifiedName("SuppressedOrShelved")] },
|
||||
new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.BaseEventType, AttributeId = Attributes.Value, BrowsePath = [new QualifiedName("SourceNode")] },
|
||||
]
|
||||
};
|
||||
var filter = new EventFilter();
|
||||
// 0: EventId
|
||||
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.EventId);
|
||||
// 1: EventType
|
||||
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.EventType);
|
||||
// 2: SourceName
|
||||
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.SourceName);
|
||||
// 3: Time
|
||||
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.Time);
|
||||
// 4: Message
|
||||
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.Message);
|
||||
// 5: Severity
|
||||
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.Severity);
|
||||
// 6: ConditionName
|
||||
filter.AddSelectClause(ObjectTypeIds.ConditionType, BrowseNames.ConditionName);
|
||||
// 7: Retain
|
||||
filter.AddSelectClause(ObjectTypeIds.ConditionType, BrowseNames.Retain);
|
||||
// 8-10: the two-state /Id booleans. Each browse path is TWO QualifiedNames. Passing "AckedState/Id" as a
|
||||
// string to AddSelectClause does not split it — it selects one element NAMED "AckedState/Id", which no
|
||||
// event carries, so all three arrived null and every alarm read inactive + unacknowledged.
|
||||
// 8: AckedState/Id
|
||||
filter.SelectClauses.Add(new SimpleAttributeOperand(
|
||||
ObjectTypeIds.AcknowledgeableConditionType, [BrowseNames.AckedState, BrowseNames.Id]));
|
||||
// 9: ActiveState/Id
|
||||
filter.SelectClauses.Add(new SimpleAttributeOperand(
|
||||
ObjectTypeIds.AlarmConditionType, [BrowseNames.ActiveState, BrowseNames.Id]));
|
||||
// 10: EnabledState/Id
|
||||
filter.SelectClauses.Add(new SimpleAttributeOperand(
|
||||
ObjectTypeIds.ConditionType, [BrowseNames.EnabledState, BrowseNames.Id]));
|
||||
// 11: SuppressedOrShelved
|
||||
filter.AddSelectClause(ObjectTypeIds.AlarmConditionType, "SuppressedOrShelved");
|
||||
// 12: SourceNode (ConditionId for acknowledgment)
|
||||
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.SourceNode);
|
||||
return filter;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.MigrationAnalyzer" PrivateAssets="all" IncludeAssets="analyzers;build;buildtransitive" />
|
||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client"/>
|
||||
<PackageReference Include="Serilog"/>
|
||||
</ItemGroup>
|
||||
|
||||
@@ -16,7 +16,7 @@ internal static class StatusCodeFormatter
|
||||
var hex = $"0x{code:X8}";
|
||||
|
||||
// Try the SDK's browse name lookup first
|
||||
var name = statusCode.SymbolicId;
|
||||
var name = StatusCodes.GetBrowseName(code);
|
||||
if (!string.IsNullOrEmpty(name))
|
||||
return $"{hex} ({name})";
|
||||
|
||||
@@ -24,7 +24,7 @@ internal static class StatusCodeFormatter
|
||||
var mainCode = code & 0xFFFF0000;
|
||||
if (mainCode != code)
|
||||
{
|
||||
name = new StatusCode(mainCode).SymbolicId;
|
||||
name = StatusCodes.GetBrowseName(mainCode);
|
||||
if (!string.IsNullOrEmpty(name))
|
||||
return $"{hex} ({name})";
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ public partial class AlarmsViewModel : ObservableObject
|
||||
{
|
||||
try
|
||||
{
|
||||
NodeId? sourceNodeId = string.IsNullOrWhiteSpace(MonitoredNodeIdText)
|
||||
var sourceNodeId = string.IsNullOrWhiteSpace(MonitoredNodeIdText)
|
||||
? null
|
||||
: NodeId.Parse(MonitoredNodeIdText);
|
||||
|
||||
@@ -275,7 +275,7 @@ public partial class AlarmsViewModel : ObservableObject
|
||||
|
||||
try
|
||||
{
|
||||
NodeId? nodeId = string.IsNullOrWhiteSpace(sourceNodeId)
|
||||
var nodeId = string.IsNullOrWhiteSpace(sourceNodeId)
|
||||
? null
|
||||
: NodeId.Parse(sourceNodeId);
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
@@ -98,7 +97,7 @@ public partial class HistoryViewModel : ObservableObject
|
||||
{
|
||||
foreach (var dv in values)
|
||||
Results.Add(new HistoryValueViewModel(
|
||||
Helpers.ValueFormatter.Format(ValueConverter.ToClrObject(dv.WrappedValue)),
|
||||
Helpers.ValueFormatter.Format(dv.Value),
|
||||
Helpers.StatusCodeFormatter.Format(dv.StatusCode),
|
||||
dv.SourceTimestamp.ToString("O"),
|
||||
dv.ServerTimestamp.ToString("O")));
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Opc.Ua;
|
||||
@@ -77,7 +76,7 @@ public partial class ReadWriteViewModel : ObservableObject
|
||||
|
||||
_dispatcher.Post(() =>
|
||||
{
|
||||
CurrentValue = Helpers.ValueFormatter.Format(ValueConverter.ToClrObject(dataValue.WrappedValue));
|
||||
CurrentValue = Helpers.ValueFormatter.Format(dataValue.Value);
|
||||
CurrentStatus = Helpers.StatusCodeFormatter.Format(dataValue.StatusCode);
|
||||
SourceTimestamp = dataValue.SourceTimestamp.ToString("O");
|
||||
ServerTimestamp = dataValue.ServerTimestamp.ToString("O");
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Collections.Specialized;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
@@ -76,7 +75,7 @@ public partial class SubscriptionsViewModel : ObservableObject
|
||||
if (!_rowsByNodeId.TryGetValue(e.NodeId, out var rows)) return;
|
||||
foreach (var item in rows)
|
||||
{
|
||||
item.Value = Helpers.ValueFormatter.Format(ValueConverter.ToClrObject(e.Value.WrappedValue));
|
||||
item.Value = Helpers.ValueFormatter.Format(e.Value.Value);
|
||||
item.Status = Helpers.StatusCodeFormatter.Format(e.Value.StatusCode);
|
||||
item.Timestamp = e.Value.SourceTimestamp.ToString("O");
|
||||
}
|
||||
@@ -282,7 +281,7 @@ public partial class SubscriptionsViewModel : ObservableObject
|
||||
|
||||
// Read current value to determine target type
|
||||
var currentDataValue = await _service.ReadValueAsync(nodeId);
|
||||
var currentValue = ValueConverter.ToClrObject(currentDataValue.WrappedValue);
|
||||
var currentValue = currentDataValue.Value;
|
||||
|
||||
// Try parsing to the target type before writing
|
||||
try
|
||||
|
||||
+22
-19
@@ -17,7 +17,7 @@ internal sealed class OpcUaClientBrowseSession : IBrowseSession
|
||||
private readonly NodeId _rootNodeId;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private volatile bool _disposed;
|
||||
private readonly IDisposable? _ownedCredential;
|
||||
private readonly IAsyncDisposable? _ownedApplication;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// 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="ownedCredential">A credential (the user certificate) this session owns and disposes with itself.</param>
|
||||
internal OpcUaClientBrowseSession(ISession session, NamespaceMap nsMap, NodeId rootNodeId, IDisposable? ownedCredential = null)
|
||||
/// <param name="ownedApplication">The SDK application whose certificate manager this session owns.</param>
|
||||
internal OpcUaClientBrowseSession(ISession session, NamespaceMap nsMap, NodeId rootNodeId, IAsyncDisposable? ownedApplication = null)
|
||||
{
|
||||
_ownedCredential = ownedCredential;
|
||||
_ownedApplication = ownedApplication;
|
||||
_session = session;
|
||||
_nsMap = nsMap;
|
||||
_rootNodeId = rootNodeId;
|
||||
@@ -73,10 +73,10 @@ internal sealed class OpcUaClientBrowseSession : IBrowseSession
|
||||
Attributes.NodeClass, Attributes.DisplayName, Attributes.DataType, Attributes.ValueRank,
|
||||
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 });
|
||||
|
||||
DataValueCollection values;
|
||||
ArrayOf<DataValue> values;
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
@@ -97,15 +97,15 @@ internal sealed class OpcUaClientBrowseSession : IBrowseSession
|
||||
DataValue? At(int i) => i < values.Count && StatusCode.IsGood(values[i].StatusCode)
|
||||
? 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>();
|
||||
|
||||
var name = (At(1)?.Value as LocalizedText)?.Text ?? resolved.ToString() ?? nodeId;
|
||||
var dataType = At(2)?.Value is NodeId dt
|
||||
var name = (At(1)?.WrappedValue.AsBoxedObject() as LocalizedText?)?.Text ?? resolved.ToString() ?? nodeId;
|
||||
var dataType = At(2)?.WrappedValue.AsBoxedObject() is NodeId dt
|
||||
? UpstreamAttributeMapping.MapDataType(dt)
|
||||
: ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.String;
|
||||
var valueRank = At(3)?.Value is int vr ? vr : ValueRanks.Scalar;
|
||||
var access = At(4)?.Value is byte ab ? ab : (byte)0;
|
||||
var valueRank = At(3)?.WrappedValue.AsBoxedObject() is int vr ? vr : ValueRanks.Scalar;
|
||||
var access = At(4)?.WrappedValue.AsBoxedObject() is byte ab ? ab : (byte)0;
|
||||
|
||||
return
|
||||
[
|
||||
@@ -126,7 +126,7 @@ internal sealed class OpcUaClientBrowseSession : IBrowseSession
|
||||
await _gate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var descriptions = new BrowseDescriptionCollection
|
||||
var descriptions = new List<BrowseDescription>
|
||||
{
|
||||
new()
|
||||
{
|
||||
@@ -154,7 +154,7 @@ internal sealed class OpcUaClientBrowseSession : IBrowseSession
|
||||
}
|
||||
|
||||
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
|
||||
// cap aren't silently truncated.
|
||||
@@ -177,11 +177,12 @@ internal sealed class OpcUaClientBrowseSession : IBrowseSession
|
||||
// Best-effort — mirrors OpcUaClientDriver's pagination loop.
|
||||
try
|
||||
{
|
||||
using var release = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
await _session.BrowseNextAsync(
|
||||
requestHeader: null,
|
||||
releaseContinuationPoints: true,
|
||||
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 */ }
|
||||
throw;
|
||||
@@ -190,7 +191,7 @@ internal sealed class OpcUaClientBrowseSession : IBrowseSession
|
||||
if (next.Results.Count == 0) break;
|
||||
var nextResult = next.Results[0];
|
||||
if (nextResult.References is { Count: > 0 })
|
||||
refs.AddRange(nextResult.References);
|
||||
refs.AddRange(nextResult.References.ToList());
|
||||
cp = nextResult.ContinuationPoint;
|
||||
}
|
||||
|
||||
@@ -214,8 +215,8 @@ internal sealed class OpcUaClientBrowseSession : IBrowseSession
|
||||
{
|
||||
var childId = ExpandedNodeId.ToNodeId(rf.NodeId, _session.NamespaceUris);
|
||||
var isObject = rf.NodeClass == NodeClass.Object;
|
||||
var displayName = rf.DisplayName?.Text
|
||||
?? rf.BrowseName?.Name
|
||||
var displayName = rf.DisplayName.Text
|
||||
?? rf.BrowseName.Name
|
||||
?? childId.ToString()
|
||||
?? "(unnamed)";
|
||||
return new BrowseNode(
|
||||
@@ -246,7 +247,9 @@ internal sealed class OpcUaClientBrowseSession : IBrowseSession
|
||||
try { _gate.Dispose(); }
|
||||
catch { /* best-effort */ }
|
||||
|
||||
try { _ownedCredential?.Dispose(); }
|
||||
catch { /* best-effort */ }
|
||||
if (_ownedApplication is not null)
|
||||
{
|
||||
try { await _ownedApplication.DisposeAsync().ConfigureAwait(false); } catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+33
-38
@@ -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
|
||||
// same factory the runtime driver uses, BEFORE any network I/O, so a bad certificate path / password fails fast
|
||||
// with its own message.
|
||||
var (identity, ownedCertificate) = await BuildBrowseCredentialAsync(opts, _secretResolver, cancellationToken)
|
||||
var identity = await BuildBrowseUserIdentityAsync(opts, _secretResolver, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
ApplicationInstance? ownedApplication = null;
|
||||
try
|
||||
{
|
||||
if (opts.AutoAcceptCertificates)
|
||||
@@ -81,7 +82,9 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser
|
||||
"driver's PKI store instead.",
|
||||
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(
|
||||
Math.Clamp(opts.PerEndpointConnectTimeout.TotalSeconds, 5, 30));
|
||||
@@ -101,11 +104,12 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser
|
||||
sessionName: "OtOpcUa AdminUI Browse",
|
||||
(uint)opts.SessionTimeout.TotalMilliseconds,
|
||||
identity,
|
||||
preferredLocales: null,
|
||||
preferredLocales: default,
|
||||
connectCts.Token).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
connectCts.Token.ThrowIfCancellationRequested();
|
||||
var nsMap = NamespaceMap.FromSession(session);
|
||||
var rootNodeId = string.IsNullOrEmpty(opts.BrowseRoot)
|
||||
? ObjectIds.ObjectsFolder
|
||||
@@ -115,7 +119,9 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser
|
||||
"AdminUI OPC UA browse session opened against {Endpoint} (policy {Policy}, mode {Mode})",
|
||||
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
|
||||
{
|
||||
@@ -126,9 +132,8 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Review m9: the certificate (and its private-key handle) is owned by the browse session once handed
|
||||
// over; any failure before that releases it here instead of leaving it to the GC.
|
||||
ownedCertificate?.Dispose();
|
||||
// The SDK application owns its certificate manager until the browse session takes it.
|
||||
if (ownedApplication is not null) await ownedApplication.DisposeAsync().ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
@@ -139,7 +144,7 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser
|
||||
/// trust decisions don't leak into the deployed driver's cert store.
|
||||
/// </summary>
|
||||
/// <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
|
||||
// internals off any logging provider.
|
||||
@@ -153,7 +158,7 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"OtOpcUa", "adminui-browse-pki");
|
||||
|
||||
var config = new ApplicationConfiguration
|
||||
var config = new ApplicationConfiguration(BrowseTelemetry.Context)
|
||||
{
|
||||
ApplicationName = "OtOpcUa AdminUI Browse",
|
||||
ApplicationType = ApplicationType.Client,
|
||||
@@ -187,11 +192,19 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser
|
||||
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
|
||||
DisableHiResClock = true,
|
||||
};
|
||||
await config.ValidateAsync(ApplicationType.Client, ct).ConfigureAwait(false);
|
||||
app.ApplicationConfiguration = config;
|
||||
await app.CheckApplicationInstanceCertificatesAsync(silent: true, lifeTimeInMonths: null, ct)
|
||||
.ConfigureAwait(false);
|
||||
return config;
|
||||
try
|
||||
{
|
||||
await config.ValidateAsync(ApplicationType.Client, ct).ConfigureAwait(false);
|
||||
await app.CheckApplicationInstanceCertificatesAsync(silent: true, lifeTimeInMonths: null, ct)
|
||||
.ConfigureAwait(false);
|
||||
return app;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await app.DisposeAsync().ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -203,29 +216,11 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser
|
||||
/// <param name="ct">Cancellation token for the secret resolution.</param>
|
||||
/// <returns>The identity the browse session activates with.</returns>
|
||||
internal static async Task<UserIdentity> BuildBrowseUserIdentityAsync(
|
||||
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)
|
||||
OpcUaClientDriverOptions opts, ISecretResolver? secretResolver, CancellationToken ct)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var resolved = await OpcUaUserIdentityFactory.ResolveSecretRefsAsync(opts, secretResolver, ct).ConfigureAwait(false);
|
||||
if (resolved.AuthType != OpcUaAuthType.Certificate)
|
||||
{
|
||||
return (OpcUaUserIdentityFactory.Build(resolved), null);
|
||||
}
|
||||
|
||||
var certificate = OpcUaUserIdentityFactory.LoadCertificate(resolved);
|
||||
return (new UserIdentity(certificate), certificate);
|
||||
return OpcUaUserIdentityFactory.Build(resolved);
|
||||
}
|
||||
|
||||
/// <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(
|
||||
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 wantedMode = mode switch
|
||||
@@ -250,11 +245,11 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser
|
||||
OpcUaSecurityMode.SignAndEncrypt => MessageSecurityMode.SignAndEncrypt,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(mode)),
|
||||
};
|
||||
var match = all.FirstOrDefault(e =>
|
||||
var match = all.ToList().FirstOrDefault(e =>
|
||||
e.SecurityPolicyUri == wantedPolicy && e.SecurityMode == wantedMode);
|
||||
if (match is null)
|
||||
{
|
||||
var advertised = string.Join(", ", all.Select(e =>
|
||||
var advertised = string.Join(", ", all.ToList().Select(e =>
|
||||
$"{ShortName(e.SecurityPolicyUri)}/{e.SecurityMode}"));
|
||||
throw new InvalidOperationException(
|
||||
$"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>
|
||||
/// <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)";
|
||||
}
|
||||
|
||||
|
||||
-1
@@ -13,7 +13,6 @@
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session.csproj" />
|
||||
</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.Configuration" />
|
||||
<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>
|
||||
public string ToStableReference(NodeId nodeId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(nodeId);
|
||||
// Namespace 0 is fixed by the spec — the compact form is already stable.
|
||||
if (nodeId.NamespaceIndex == 0)
|
||||
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
|
||||
// resolve directly.
|
||||
nodeId = NodeId.Parse(currentSession.MessageContext, reference);
|
||||
return !NodeId.IsNull(nodeId);
|
||||
return !nodeId.IsNull;
|
||||
}
|
||||
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>
|
||||
private static string IdentifierPart(NodeId nodeId) => nodeId.IdType switch
|
||||
{
|
||||
IdType.Numeric => $"i={nodeId.Identifier}",
|
||||
IdType.String => $"s={nodeId.Identifier}",
|
||||
IdType.Guid => $"g={nodeId.Identifier}",
|
||||
IdType.Opaque => $"b={Convert.ToBase64String((byte[])nodeId.Identifier)}",
|
||||
_ => $"s={nodeId.Identifier}",
|
||||
IdType.Numeric => $"i={nodeId.IdentifierAsString}",
|
||||
IdType.String => $"s={nodeId.IdentifierAsString}",
|
||||
IdType.Guid => $"g={nodeId.IdentifierAsString}",
|
||||
IdType.Opaque => $"b={nodeId.IdentifierAsString}",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(nodeId)),
|
||||
};
|
||||
}
|
||||
|
||||
+32
-1
@@ -1,4 +1,5 @@
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Security.Certificates;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||
@@ -37,7 +38,37 @@ public static class OpcUaUserIdentityFactory
|
||||
/// </summary>
|
||||
/// <param name="options">Driver options containing certificate configuration.</param>
|
||||
/// <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>
|
||||
/// 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.
|
||||
-->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.MigrationAnalyzer" PrivateAssets="all" IncludeAssets="analyzers;build;buildtransitive" />
|
||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" />
|
||||
<!-- 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
|
||||
|
||||
@@ -274,12 +274,13 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
/// <summary>
|
||||
/// Cert-validation delegate wired when <see cref="OpcUaClientDriverOptions.AutoAcceptCertificates"/>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private CertificateValidationEventHandler? _certValidationHandler;
|
||||
/// <summary>The <see cref="CertificateValidator"/> that owns <see cref="_certValidationHandler"/>.</summary>
|
||||
private CertificateValidator? _certValidatorRef;
|
||||
private Func<Opc.Ua.Security.Certificates.Certificate, ServiceResult, bool>? _certValidationHandler;
|
||||
/// <summary>The <see cref="ICertificateManager"/> that owns <see cref="_certValidationHandler"/>.</summary>
|
||||
private ICertificateManager? _certValidatorRef;
|
||||
private ApplicationInstance? _ownedApplication;
|
||||
/// <summary>
|
||||
/// 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
|
||||
@@ -547,7 +548,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
// G-2b: resolve any secret:-prefixed Password / UserCertificatePassword through the
|
||||
// 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
|
||||
// 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.
|
||||
// Both credential consumers — the Username password below and the Certificate password
|
||||
// in BuildCertificateIdentity — flow through BuildUserIdentity(resolvedOptions), so a
|
||||
@@ -565,6 +566,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
string? connectedUrl = null;
|
||||
foreach (var url in candidates)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
try
|
||||
{
|
||||
session = OpenSessionForTest is { } open
|
||||
@@ -572,9 +574,11 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
: await OpenSessionOnEndpointAsync(
|
||||
appConfig, url, _options.SecurityPolicy, _options.SecurityMode,
|
||||
identity, cancellationToken).ConfigureAwait(false);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
connectedUrl = url;
|
||||
break;
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
attemptErrors.Add($"{url} -> {ex.GetType().Name}: {ex.Message}");
|
||||
@@ -622,6 +626,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
DetachCertValidation(_certValidatorRef, _certValidationHandler);
|
||||
_certValidationHandler = certValidation.Handler;
|
||||
_certValidatorRef = certValidation.Validator;
|
||||
_ownedApplication = certValidation.Application;
|
||||
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.
|
||||
if (session is not null && !installed)
|
||||
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 (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>
|
||||
private sealed class CertValidationHook
|
||||
{
|
||||
public CertificateValidationEventHandler? Handler { get; set; }
|
||||
public CertificateValidator? Validator { get; set; }
|
||||
public Func<Opc.Ua.Security.Certificates.Certificate, ServiceResult, bool>? Handler { 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;
|
||||
try { validator.CertificateValidation -= handler; } catch { /* best-effort */ }
|
||||
try { if (ReferenceEquals(validator.AcceptError, handler)) validator.AcceptError = null; } catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -735,11 +746,12 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
ApplicationType = ApplicationType.Client,
|
||||
};
|
||||
|
||||
certValidation.Application = app;
|
||||
var pkiRoot = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"OtOpcUa", "pki");
|
||||
|
||||
var config = new ApplicationConfiguration
|
||||
var config = new ApplicationConfiguration(_sdkTelemetry)
|
||||
{
|
||||
ApplicationName = _options.SessionName,
|
||||
ApplicationType = ApplicationType.Client,
|
||||
@@ -782,6 +794,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
DisableHiResClock = true,
|
||||
};
|
||||
|
||||
app.ApplicationConfiguration = config;
|
||||
await config.ValidateAsync(ApplicationType.Client, ct).ConfigureAwait(false);
|
||||
|
||||
// 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
|
||||
// exception: auto-accept is for dev self-signed certs, never for a cert its issuer
|
||||
// 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(
|
||||
"OpcUaClientDriver '{DriverInstanceId}': REVOKED server certificate rejected " +
|
||||
"(subject={Subject}, status=0x{Status:X8}) — AutoAcceptCertificates does not apply to revoked certs.",
|
||||
_driverInstanceId, e.Certificate?.Subject, e.Error.StatusCode.Code);
|
||||
e.Accept = false;
|
||||
return;
|
||||
_driverInstanceId, certificate.Subject, error.StatusCode.Code);
|
||||
return false;
|
||||
}
|
||||
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)
|
||||
// only while its initialise is the current lifecycle generation.
|
||||
certValidation.Handler = handler;
|
||||
certValidation.Validator = config.CertificateValidator;
|
||||
certValidation.Validator = config.CertificateManager;
|
||||
}
|
||||
|
||||
// Ensure an application certificate exists. The SDK auto-generates one if missing.
|
||||
app.ApplicationConfiguration = config;
|
||||
await app.CheckApplicationInstanceCertificatesAsync(silent: true, lifeTimeInMonths: null, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
@@ -888,31 +899,31 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
var wantedPolicyUri = MapSecurityPolicy(policy);
|
||||
var wantedMode = ToMessageSecurityMode(mode);
|
||||
|
||||
ApplicationDescriptionCollection servers;
|
||||
ArrayOf<ApplicationDescription> servers;
|
||||
using (var lds = await DiscoveryClient.CreateAsync(
|
||||
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>();
|
||||
foreach (var server in servers)
|
||||
foreach (var server in servers.ToList())
|
||||
{
|
||||
if (server.DiscoveryUrls is null) continue;
|
||||
foreach (var serverDiscoveryUrl in server.DiscoveryUrls)
|
||||
if (server.DiscoveryUrls.IsNull) continue;
|
||||
foreach (var serverDiscoveryUrl in server.DiscoveryUrls.ToList())
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(serverDiscoveryUrl)) continue;
|
||||
EndpointDescriptionCollection endpoints;
|
||||
ArrayOf<EndpointDescription> endpoints;
|
||||
try
|
||||
{
|
||||
using var ep = await DiscoveryClient.CreateAsync(
|
||||
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 { continue; }
|
||||
|
||||
found.AddRange(FilterEndpointUrls(endpoints, wantedPolicyUri, wantedMode)
|
||||
found.AddRange(FilterEndpointUrls(endpoints.ToList(), wantedPolicyUri, wantedMode)
|
||||
.Select(u => new DiscoveredEndpoint(server.ApplicationUri ?? string.Empty, u)));
|
||||
}
|
||||
}
|
||||
@@ -965,7 +976,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
endpoints
|
||||
.Where(e => e.SecurityPolicyUri == wantedPolicyUri && e.SecurityMode == wantedMode
|
||||
&& !string.IsNullOrWhiteSpace(e.EndpointUrl))
|
||||
.Select(e => e.EndpointUrl);
|
||||
.Select(e => e.EndpointUrl!);
|
||||
|
||||
private static MessageSecurityMode ToMessageSecurityMode(OpcUaSecurityMode mode) => mode switch
|
||||
{
|
||||
@@ -1052,7 +1063,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
_options.SessionName,
|
||||
(uint)_options.SessionTimeout.TotalMilliseconds,
|
||||
identity,
|
||||
null, // preferredLocales
|
||||
default, // preferredLocales
|
||||
cts.Token).ConfigureAwait(false);
|
||||
|
||||
session.KeepAliveInterval = (int)_options.KeepAliveInterval.TotalMilliseconds;
|
||||
@@ -1080,17 +1091,17 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
// all deprecated.
|
||||
using var client = await DiscoveryClient.CreateAsync(
|
||||
appConfig, new Uri(endpointUrl), Opc.Ua.DiagnosticsMasks.None, ct).ConfigureAwait(false);
|
||||
var all = await client.GetEndpointsAsync(null, ct).ConfigureAwait(false);
|
||||
var all = await client.GetEndpointsAsync(default, ct).ConfigureAwait(false);
|
||||
|
||||
var wantedPolicyUri = MapSecurityPolicy(policy);
|
||||
var wantedMode = ToMessageSecurityMode(mode);
|
||||
|
||||
var match = all.FirstOrDefault(e =>
|
||||
var match = all.ToList().FirstOrDefault(e =>
|
||||
e.SecurityPolicyUri == wantedPolicyUri && e.SecurityMode == wantedMode);
|
||||
|
||||
if (match is null)
|
||||
{
|
||||
var advertised = string.Join(", ", all
|
||||
var advertised = string.Join(", ", all.ToList()
|
||||
.Select(e => $"{ShortPolicyName(e.SecurityPolicyUri)}/{e.SecurityMode}"));
|
||||
throw new InvalidOperationException(
|
||||
$"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),
|
||||
};
|
||||
|
||||
private static string ShortPolicyName(string policyUri) =>
|
||||
private static string ShortPolicyName(string? policyUri) =>
|
||||
policyUri?.Substring(policyUri.LastIndexOf('#') + 1) ?? "(null)";
|
||||
|
||||
/// <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
|
||||
// checks inside _gate sees null immediately after release begins.
|
||||
ISession? sessionToClose;
|
||||
ApplicationInstance? applicationToDispose;
|
||||
lock (_probeLock)
|
||||
{
|
||||
if (Volatile.Read(ref _lifecycleGeneration) != generation) return;
|
||||
sessionToClose = Session;
|
||||
applicationToDispose = _ownedApplication;
|
||||
_ownedApplication = null;
|
||||
if (_keepAliveHandler is not null && sessionToClose is not null)
|
||||
{
|
||||
try { sessionToClose.KeepAlive -= _keepAliveHandler; } catch { }
|
||||
@@ -1210,6 +1224,10 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
}
|
||||
catch { /* best-effort */ }
|
||||
try { sessionToClose?.Dispose(); } catch { }
|
||||
if (applicationToDispose is not null)
|
||||
{
|
||||
try { await applicationToDispose.DisposeAsync().ConfigureAwait(false); } catch { }
|
||||
}
|
||||
Interlocked.Exchange(ref _sessionLost, 0);
|
||||
Interlocked.Exchange(ref _subscriptionLost, 0);
|
||||
Interlocked.Exchange(ref _publishingStoppedSinceTicks, 0);
|
||||
@@ -1347,7 +1365,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
_namespaceMap = 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
|
||||
// that installed its own handler meanwhile keeps it.
|
||||
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 —
|
||||
// gets BadNodeIdInvalid and is omitted from the wire request, saving a round-trip for a
|
||||
// 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
|
||||
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
|
||||
// §8's cascading-quality rule. Also preserve SourceTimestamp so downstream
|
||||
// clients can detect stale upstream data.
|
||||
results[r] = new DataValueSnapshot(
|
||||
Value: dv.Value,
|
||||
StatusCode: dv.StatusCode.Code,
|
||||
SourceTimestampUtc: dv.SourceTimestamp == DateTime.MinValue ? null : dv.SourceTimestamp,
|
||||
ServerTimestampUtc: dv.ServerTimestamp == DateTime.MinValue ? now : dv.ServerTimestamp);
|
||||
results[r] = ToSnapshot(dv, now);
|
||||
}
|
||||
RecordDeviceAnswered(generation, now);
|
||||
}
|
||||
@@ -1506,7 +1520,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
return results;
|
||||
}
|
||||
|
||||
var toSend = new WriteValueCollection();
|
||||
var toSend = new List<WriteValue>();
|
||||
var indexMap = new List<int>(writes.Count);
|
||||
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);
|
||||
continue;
|
||||
}
|
||||
if (!VariantHelper.TryCastFrom(writes[i].Value, out Variant value))
|
||||
{
|
||||
results[i] = new WriteResult(StatusCodes.BadTypeMismatch.Code);
|
||||
continue;
|
||||
}
|
||||
toSend.Add(new WriteValue
|
||||
{
|
||||
NodeId = nodeId,
|
||||
AttributeId = Attributes.Value,
|
||||
Value = new DataValue(new Variant(writes[i].Value)),
|
||||
Value = new DataValue(value),
|
||||
});
|
||||
indexMap.Add(i);
|
||||
}
|
||||
@@ -1674,7 +1693,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
if (discovered() >= _options.MaxDiscoveredNodes) return;
|
||||
if (!visited.Add(node)) return;
|
||||
|
||||
var browseDescriptions = new BrowseDescriptionCollection
|
||||
var browseDescriptions = new List<BrowseDescription>
|
||||
{
|
||||
new()
|
||||
{
|
||||
@@ -1688,7 +1707,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
}
|
||||
};
|
||||
|
||||
ReferenceDescriptionCollection refs;
|
||||
List<ReferenceDescription> refs;
|
||||
try
|
||||
{
|
||||
var resp = await session.BrowseAsync(
|
||||
@@ -1700,7 +1719,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
|
||||
if (resp.Results.Count == 0) return;
|
||||
var result = resp.Results[0];
|
||||
refs = result.References;
|
||||
refs = result.References.ToList();
|
||||
|
||||
// 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,
|
||||
@@ -1728,11 +1747,12 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
// the release fails silently and the cursor times out server-side anyway.
|
||||
try
|
||||
{
|
||||
using var release = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
await session.BrowseNextAsync(
|
||||
requestHeader: null,
|
||||
releaseContinuationPoints: true,
|
||||
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 */ }
|
||||
throw;
|
||||
@@ -1741,7 +1761,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
if (next.Results.Count == 0) break;
|
||||
var nextResult = next.Results[0];
|
||||
if (nextResult.References is { Count: > 0 })
|
||||
refs.AddRange(nextResult.References);
|
||||
refs.AddRange(nextResult.References.ToList());
|
||||
continuationPoint = nextResult.ContinuationPoint;
|
||||
}
|
||||
}
|
||||
@@ -1764,10 +1784,10 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
if (discovered() >= _options.MaxDiscoveredNodes) break;
|
||||
|
||||
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 displayName = rf.DisplayName?.Text ?? browseName;
|
||||
var browseName = rf.BrowseName.Name ?? childId.ToString();
|
||||
var displayName = rf.DisplayName.Text ?? browseName;
|
||||
|
||||
if (rf.NodeClass == NodeClass.Object)
|
||||
{
|
||||
@@ -1812,7 +1832,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
if (pending.Count == 0) return;
|
||||
|
||||
// 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)
|
||||
{
|
||||
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 });
|
||||
}
|
||||
|
||||
DataValueCollection values;
|
||||
ArrayOf<DataValue> values;
|
||||
try
|
||||
{
|
||||
var resp = await session.ReadAsync(
|
||||
@@ -1851,10 +1871,10 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
var accessDv = values[baseIdx + 2];
|
||||
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)
|
||||
: 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,
|
||||
// -1 = Scalar, 0 = OneOrMoreDimensions, 1 = OneDimension, >1 = N specific dimensions.
|
||||
// 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
|
||||
// the upstream variable carries a concrete dimensioned rank.
|
||||
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 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(
|
||||
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.
|
||||
if (args.NotificationValue is not MonitoredItemNotification mn) return;
|
||||
var dv = mn.Value;
|
||||
if (dv is null) return;
|
||||
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 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
|
||||
// without this a busy subscription-only driver's LastSuccessfulRead stayed at its connect time.
|
||||
RecordLivenessEvidence();
|
||||
var snapshot = new DataValueSnapshot(
|
||||
Value: dv.Value,
|
||||
StatusCode: dv.StatusCode.Code,
|
||||
SourceTimestampUtc: dv.SourceTimestamp == DateTime.MinValue ? null : dv.SourceTimestamp,
|
||||
ServerTimestampUtc: dv.ServerTimestamp == DateTime.MinValue ? DateTime.UtcNow : dv.ServerTimestamp);
|
||||
var snapshot = ToSnapshot(dv, DateTime.UtcNow);
|
||||
// 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).
|
||||
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
|
||||
// QualifiedName BrowsePaths.
|
||||
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,
|
||||
BrowsePath = [new QualifiedName(browseName)],
|
||||
@@ -2429,7 +2458,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
AddField("Time");
|
||||
// ConditionId on ConditionType nodes is the branch identifier for
|
||||
// acknowledgeable conditions. Not a BaseEventType field — reach it via the typed path.
|
||||
filter.SelectClauses.Add(new SimpleAttributeOperand
|
||||
selectClauses.Add(new SimpleAttributeOperand
|
||||
{
|
||||
TypeDefinitionId = ObjectTypeIds.ConditionType,
|
||||
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
|
||||
// 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,
|
||||
BrowsePath = [new QualifiedName(BrowseNames.ActiveState), new QualifiedName(BrowseNames.Id)],
|
||||
AttributeId = Attributes.Value,
|
||||
});
|
||||
filter.SelectClauses.Add(new SimpleAttributeOperand
|
||||
selectClauses.Add(new SimpleAttributeOperand
|
||||
{
|
||||
TypeDefinitionId = ObjectTypeIds.AcknowledgeableConditionType,
|
||||
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
|
||||
// EventId). Only the trunk (null BranchId) is the condition's current state.
|
||||
filter.SelectClauses.Add(new SimpleAttributeOperand
|
||||
selectClauses.Add(new SimpleAttributeOperand
|
||||
{
|
||||
TypeDefinitionId = ObjectTypeIds.ConditionType,
|
||||
BrowsePath = [new QualifiedName(BrowseNames.BranchId)],
|
||||
AttributeId = Attributes.Value,
|
||||
});
|
||||
|
||||
filter.SelectClauses = selectClauses;
|
||||
|
||||
// Set by CreateAlarmEventItemAsync so a failure after the handler is attached can detach it.
|
||||
MonitoredItem? eventItem = 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
|
||||
// condition node with EventId + Comment arguments. CallAsync accepts a batch —
|
||||
// one CallMethodRequest per ack.
|
||||
var callRequests = new CallMethodRequestCollection();
|
||||
var callRequests = new List<CallMethodRequest>();
|
||||
foreach (var ack in acknowledgements)
|
||||
{
|
||||
// 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,
|
||||
MethodId = MethodIds.AcknowledgeableConditionType_Acknowledge,
|
||||
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)),
|
||||
],
|
||||
});
|
||||
@@ -2711,7 +2742,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
// without this it would feed RouteAlarmEvent alongside the real one.
|
||||
if (!_alarmSubscriptions.ContainsKey(handle.Id)) return;
|
||||
RecordLivenessEvidence();
|
||||
RouteAlarmEvent(efl.EventFields, sourceFilter, handle);
|
||||
RouteAlarmEvent(efl.EventFields.ToList(), sourceFilter, handle);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -2805,7 +2836,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
/// <param name="fields">The event's fields.</param>
|
||||
/// <returns>Whether the event is a RefreshRequired marker.</returns>
|
||||
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;
|
||||
|
||||
/// <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;
|
||||
if (fields.Count <= AlarmFieldConditionId) return null;
|
||||
// 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
|
||||
// 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;
|
||||
if (fields.Count > AlarmFieldAckedState
|
||||
&& fields[AlarmFieldActiveState].Value is bool active
|
||||
&& fields[AlarmFieldAckedState].Value is bool acked)
|
||||
&& UpstreamValueMapping.ToClr(fields[AlarmFieldActiveState]) is bool active
|
||||
&& UpstreamValueMapping.ToClr(fields[AlarmFieldAckedState]) is bool acked)
|
||||
state = (active, acked);
|
||||
|
||||
// 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
|
||||
// several conditions on one source apart — and then the source node.
|
||||
var sourceField = fields[AlarmFieldSourceNode].Value;
|
||||
var conditionField = fields[AlarmFieldConditionId].Value;
|
||||
var sourceField = UpstreamValueMapping.ToClr(fields[AlarmFieldSourceNode]);
|
||||
var conditionField = UpstreamValueMapping.ToClr(fields[AlarmFieldConditionId]);
|
||||
string reported, upstreamCondition;
|
||||
if (sourceIndex.Count == 0)
|
||||
{
|
||||
@@ -3040,23 +3071,23 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
}
|
||||
else
|
||||
{
|
||||
upstreamCondition = conditionField is NodeId cn && !NodeId.IsNull(cn) ? keyOf(cn) : string.Empty;
|
||||
var sourceKey = sourceField is NodeId sn && !NodeId.IsNull(sn) ? keyOf(sn) : string.Empty;
|
||||
upstreamCondition = conditionField is NodeId cn && !cn.IsNull ? keyOf(cn) : string.Empty;
|
||||
var sourceKey = sourceField is NodeId sn && !sn.IsNull ? keyOf(sn) : string.Empty;
|
||||
if (!(upstreamCondition.Length > 0 && sourceIndex.TryGetValue(upstreamCondition, out reported!))
|
||||
&& !(sourceKey.Length > 0 && sourceIndex.TryGetValue(sourceKey, out reported!)))
|
||||
return null;
|
||||
}
|
||||
|
||||
var eventType = fields[AlarmFieldEventType].Value?.ToString() ?? "BaseEventType";
|
||||
var message = (fields[AlarmFieldMessage].Value as LocalizedText)?.Text ?? string.Empty;
|
||||
var severity = fields[AlarmFieldSeverity].Value is ushort sev ? sev : (ushort)0;
|
||||
var time = fields[AlarmFieldTime].Value is DateTime t ? t : DateTime.UtcNow;
|
||||
var eventType = UpstreamValueMapping.ToClr(fields[AlarmFieldEventType])?.ToString() ?? "BaseEventType";
|
||||
var message = (UpstreamValueMapping.ToClr(fields[AlarmFieldMessage]) as LocalizedText?)?.Text ?? string.Empty;
|
||||
var severity = UpstreamValueMapping.ToClr(fields[AlarmFieldSeverity]) is ushort sev ? sev : (ushort)0;
|
||||
var time = UpstreamValueMapping.ToClr(fields[AlarmFieldTime]) is DateTime t ? t : DateTime.UtcNow;
|
||||
|
||||
if (sourceIndex.Count != 0 && upstreamCondition.Length > 0)
|
||||
rememberCondition(reported, new AlarmAckTarget(
|
||||
upstreamCondition,
|
||||
// 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(
|
||||
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 */ }
|
||||
}
|
||||
|
||||
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,
|
||||
EventNotificationList ev => ev.Events?.Count ?? 0,
|
||||
DataChangeNotification dc => dc.MonitoredItems.Count,
|
||||
EventNotificationList ev => ev.Events.Count,
|
||||
StatusChangeNotification => 1,
|
||||
_ => 1,
|
||||
};
|
||||
@@ -3268,7 +3299,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
PublishCommsLoss(now);
|
||||
}
|
||||
if (definitive)
|
||||
RaiseConnectionLost($"upstream session keep-alive reported {StatusCodes.GetBrowseName(keepAliveCode.Code)}");
|
||||
RaiseConnectionLost($"upstream session keep-alive reported {keepAliveCode.SymbolicId}");
|
||||
}
|
||||
|
||||
/// <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
|
||||
/// 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
|
||||
/// single-threaded context (OPC UA stack thread). The session will be cleaned up by
|
||||
/// the SDK's own finalizer on GC (no sync-over-async).
|
||||
/// single-threaded context (OPC UA stack thread). Local session and certificate-manager
|
||||
/// resources are disposed synchronously.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
@@ -3715,15 +3746,20 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
try { Session.KeepAlive -= _keepAliveHandler; } catch { }
|
||||
}
|
||||
_keepAliveHandler = null;
|
||||
var session = Session;
|
||||
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
|
||||
// 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.
|
||||
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;
|
||||
_certValidatorRef = null;
|
||||
}
|
||||
@@ -3764,7 +3800,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
// Detach the cert-validation handler.
|
||||
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;
|
||||
_certValidatorRef = null;
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ public sealed class OpcUaClientDriverProbe : IDriverProbe
|
||||
var appConfig = BuildMinimalAppConfig();
|
||||
using var client = await DiscoveryClient.CreateAsync(
|
||||
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();
|
||||
|
||||
if (endpoints is { Count: > 0 })
|
||||
@@ -130,7 +130,7 @@ public sealed class OpcUaClientDriverProbe : IDriverProbe
|
||||
/// is plain-text and does not require a client certificate.
|
||||
/// </summary>
|
||||
private static ApplicationConfiguration BuildMinimalAppConfig() =>
|
||||
new()
|
||||
new(SdkTelemetry.Context)
|
||||
{
|
||||
ApplicationName = "OtOpcUa.Probe",
|
||||
ApplicationType = ApplicationType.Client,
|
||||
|
||||
-1
@@ -21,7 +21,6 @@
|
||||
</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.Configuration"/>
|
||||
<PackageReference Include="ZB.MOM.WW.Secrets.Abstractions"/>
|
||||
|
||||
@@ -54,7 +54,7 @@ public class AlarmsCommandTests
|
||||
|
||||
fakeService.SubscribeAlarmsCalls.Count.ShouldBe(1);
|
||||
fakeService.SubscribeAlarmsCalls[0].SourceNodeId.ShouldNotBeNull();
|
||||
fakeService.SubscribeAlarmsCalls[0].SourceNodeId!.Value.IdentifierAsString.ShouldBe("AlarmSource");
|
||||
fakeService.SubscribeAlarmsCalls[0].SourceNodeId!.Identifier.ShouldBe("AlarmSource");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Execute with refresh requests condition refresh.</summary>
|
||||
|
||||
@@ -56,7 +56,7 @@ public class BrowseCommandTests
|
||||
|
||||
fakeService.BrowseNodeIds.Count.ShouldBe(1);
|
||||
fakeService.BrowseNodeIds[0].ShouldNotBeNull();
|
||||
fakeService.BrowseNodeIds[0]!.Value.IdentifierAsString.ShouldBe("StartNode");
|
||||
fakeService.BrowseNodeIds[0]!.Identifier.ShouldBe("StartNode");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Execute browses from null node when not specified.</summary>
|
||||
|
||||
@@ -61,7 +61,7 @@ public class HistoryReadCommandTests
|
||||
|
||||
fakeService.HistoryReadRawCalls.Count.ShouldBe(1);
|
||||
fakeService.HistoryReadRawCalls[0].MaxValues.ShouldBe(500);
|
||||
fakeService.HistoryReadRawCalls[0].NodeId.IdentifierAsString.ShouldBe("HistNode");
|
||||
fakeService.HistoryReadRawCalls[0].NodeId.Identifier.ShouldBe("HistNode");
|
||||
}
|
||||
|
||||
/// <summary>Verifies AggregateRead execution calls HistoryReadAggregate.</summary>
|
||||
@@ -211,11 +211,11 @@ public class HistoryReadCommandTests
|
||||
|
||||
var call = fakeService.HistoryReadEventsCalls.ShouldHaveSingleItem();
|
||||
call.MaxEvents.ShouldBe(25);
|
||||
call.Filter.SelectClauses.ToArray()!.Select(c => c.BrowsePath.ToArray()!.ShouldHaveSingleItem().Name)
|
||||
call.Filter.SelectClauses.Select(c => c.BrowsePath.ShouldHaveSingleItem().Name)
|
||||
.ShouldBe(["EventId", "EventType", "Time", "SourceName", "Severity", "Message", "ConditionName", "SourceNode"]);
|
||||
call.Filter.SelectClauses.ToArray()!.Single(c => c.BrowsePath[0].Name == "ConditionName").TypeDefinitionId
|
||||
call.Filter.SelectClauses.Single(c => c.BrowsePath[0].Name == "ConditionName").TypeDefinitionId
|
||||
.ShouldBe(ObjectTypeIds.ConditionType);
|
||||
call.Filter.WhereClause.Elements.ToArray()!.ShouldBeEmpty("no --of-type, no where-clause");
|
||||
call.Filter.WhereClause.Elements.ShouldBeEmpty("no --of-type, no where-clause");
|
||||
fakeService.HistoryReadRawCalls.ShouldBeEmpty();
|
||||
|
||||
var output = TestConsoleHelper.GetOutput(console);
|
||||
@@ -243,10 +243,10 @@ public class HistoryReadCommandTests
|
||||
await command.ExecuteAsync(console);
|
||||
|
||||
var filter = fakeService.HistoryReadEventsCalls.ShouldHaveSingleItem().Filter;
|
||||
filter.SelectClauses.ToArray()!.Select(c => c.BrowsePath[0].Name).ShouldBe(["EventType", "Severity"]);
|
||||
var element = filter.WhereClause.Elements.ToArray()!.ShouldHaveSingleItem();
|
||||
filter.SelectClauses.Select(c => c.BrowsePath[0].Name).ShouldBe(["EventType", "Severity"]);
|
||||
var element = filter.WhereClause.Elements.ShouldHaveSingleItem();
|
||||
element.FilterOperator.ShouldBe(FilterOperator.OfType);
|
||||
(element.FilterOperands[0].TryGetValue(out LiteralOperand? literal) ? literal.Value : Variant.Null)
|
||||
((LiteralOperand)ExtensionObject.ToEncodeable(element.FilterOperands[0])).Value.Value
|
||||
.ShouldBe(ObjectTypeIds.LimitAlarmType);
|
||||
TestConsoleHelper.GetOutput(console).ShouldContain("0 events returned.");
|
||||
}
|
||||
|
||||
@@ -33,9 +33,9 @@ public class HistoryUpdateCommandTests
|
||||
.BuildDetails().ShouldBeOfType<UpdateDataDetails>();
|
||||
d.PerformInsertReplace.ShouldBe(PerformUpdateType.Insert);
|
||||
d.UpdateValues.Count.ShouldBe(2);
|
||||
d.UpdateValues[1].WrappedValue.ShouldBe(Variant.From(2.5));
|
||||
d.UpdateValues[1].Value.ShouldBe(2.5);
|
||||
d.UpdateValues[0].SourceTimestamp.ShouldBe(new DateTime(2026, 1, 15, 8, 0, 0, DateTimeKind.Utc));
|
||||
d.NodeId.IdentifierAsString.ShouldBe("HistNode");
|
||||
d.NodeId.Identifier.ShouldBe("HistNode");
|
||||
}
|
||||
|
||||
/// <summary>Replace and update map to their update types.</summary>
|
||||
@@ -60,7 +60,7 @@ public class HistoryUpdateCommandTests
|
||||
{
|
||||
var d = Make(values: [value], times: ["2026-01-15T08:00:00Z"], type: type)
|
||||
.BuildDetails().ShouldBeOfType<UpdateDataDetails>();
|
||||
ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers.ValueConverter.ToClrObject(d.UpdateValues[0].WrappedValue).ShouldBeOfType(expected);
|
||||
d.UpdateValues[0].Value.ShouldBeOfType(expected);
|
||||
}
|
||||
|
||||
/// <summary>delete-raw builds DeleteRawModifiedDetails with IsDeleteModified=false.</summary>
|
||||
@@ -211,7 +211,7 @@ public class HistoryUpdateCommandTests
|
||||
public async Task Execute_UnnamedStatus_PrintsHex()
|
||||
{
|
||||
const uint unnamed = 0x8FFF0000u;
|
||||
new StatusCode(unnamed).SymbolicId.ShouldBeNullOrEmpty(); // precondition: genuinely unnamed
|
||||
StatusCodes.GetBrowseName(unnamed).ShouldBeNullOrEmpty(); // precondition: genuinely unnamed
|
||||
var svc = new FakeOpcUaClientService
|
||||
{
|
||||
HistoryUpdateResult = new HistoryUpdateOutcome(StatusCodes.Good, [StatusCodes.Good],
|
||||
|
||||
@@ -34,8 +34,8 @@ public class NodeIdParserTests
|
||||
{
|
||||
var result = NodeIdParser.Parse("ns=2;s=MyNode");
|
||||
result.ShouldNotBeNull();
|
||||
result.Value.NamespaceIndex.ShouldBe((ushort)2);
|
||||
result.Value.IdentifierAsString.ShouldBe("MyNode");
|
||||
result.NamespaceIndex.ShouldBe((ushort)2);
|
||||
result.Identifier.ShouldBe("MyNode");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Parse correctly parses numeric NodeId format (i=85).</summary>
|
||||
@@ -44,7 +44,7 @@ public class NodeIdParserTests
|
||||
{
|
||||
var result = NodeIdParser.Parse("i=85");
|
||||
result.ShouldNotBeNull();
|
||||
result.Value.IdType.ShouldBe(IdType.Numeric);
|
||||
result.IdType.ShouldBe(IdType.Numeric);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Parse treats bare numeric input as namespace 0 numeric NodeId.</summary>
|
||||
@@ -53,9 +53,8 @@ public class NodeIdParserTests
|
||||
{
|
||||
var result = NodeIdParser.Parse("85");
|
||||
result.ShouldNotBeNull();
|
||||
result.Value.NamespaceIndex.ShouldBe((ushort)0);
|
||||
result.Value.TryGetValue(out uint identifier).ShouldBeTrue();
|
||||
identifier.ShouldBe(85u);
|
||||
result.NamespaceIndex.ShouldBe((ushort)0);
|
||||
result.Identifier.ShouldBe((uint)85);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Parse trims whitespace padding from input.</summary>
|
||||
@@ -64,7 +63,7 @@ public class NodeIdParserTests
|
||||
{
|
||||
var result = NodeIdParser.Parse(" ns=2;s=MyNode ");
|
||||
result.ShouldNotBeNull();
|
||||
result.Value.IdentifierAsString.ShouldBe("MyNode");
|
||||
result.Identifier.ShouldBe("MyNode");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Parse throws FormatException for invalid input.</summary>
|
||||
@@ -93,36 +92,7 @@ public class NodeIdParserTests
|
||||
public void ParseRequired_ValidInput_ReturnsNodeId()
|
||||
{
|
||||
var result = NodeIdParser.ParseRequired("ns=2;s=TestNode");
|
||||
result.IsNull.ShouldBeFalse();
|
||||
result.IdentifierAsString.ShouldBe("TestNode");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_OpaqueIdentifier_PreservesBytes()
|
||||
{
|
||||
var node = NodeIdParser.ParseRequired("ns=3;b=AQID");
|
||||
node.IdType.ShouldBe(IdType.Opaque);
|
||||
node.NamespaceIndex.ShouldBe((ushort)3);
|
||||
node.TryGetValue(out ByteString bytes).ShouldBeTrue();
|
||||
bytes.ToArray().ShouldBe(new byte[] { 1, 2, 3 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_StringIdentifierContainingEncodedText_StaysLiteral()
|
||||
{
|
||||
var node = NodeIdParser.ParseRequired("ns=4;s=ns=2;b=AQID");
|
||||
node.IdType.ShouldBe(IdType.String);
|
||||
node.NamespaceIndex.ShouldBe((ushort)4);
|
||||
node.TryGetValue(out string? identifier).ShouldBeTrue();
|
||||
identifier.ShouldBe("ns=2;b=AQID");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("ns=bad;i=1")]
|
||||
[InlineData("ns=2;b=invalid!")]
|
||||
[InlineData("ns=2;i=invalid")]
|
||||
public void Parse_InvalidEncodedIdentifier_IsRejected(string input)
|
||||
{
|
||||
Should.Throw<FormatException>(() => NodeIdParser.ParseRequired(input));
|
||||
result.ShouldNotBeNull();
|
||||
result.Identifier.ShouldBe("TestNode");
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,7 @@ public class ReadCommandTests
|
||||
await command.ExecuteAsync(console);
|
||||
|
||||
fakeService.ReadNodeIds.Count.ShouldBe(1);
|
||||
fakeService.ReadNodeIds[0].IdentifierAsString.ShouldBe("MyVariable");
|
||||
fakeService.ReadNodeIds[0].Identifier.ShouldBe("MyVariable");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that execute disconnects in finally.</summary>
|
||||
|
||||
@@ -222,8 +222,8 @@ public class SubscribeCommandSummaryTests
|
||||
|
||||
// Both Variables (depth 1 + depth 2) should have been subscribed.
|
||||
fakeService.SubscribeCalls.Count.ShouldBeGreaterThanOrEqualTo(2);
|
||||
fakeService.SubscribeCalls.ShouldContain(c => c.NodeId.IdentifierAsString.ToString() == "Tag1");
|
||||
fakeService.SubscribeCalls.ShouldContain(c => c.NodeId.IdentifierAsString.ToString() == "Tag2");
|
||||
fakeService.SubscribeCalls.ShouldContain(c => c.NodeId.Identifier.ToString() == "Tag1");
|
||||
fakeService.SubscribeCalls.ShouldContain(c => c.NodeId.Identifier.ToString() == "Tag2");
|
||||
|
||||
var output = TestConsoleHelper.GetOutput(console);
|
||||
output.ShouldContain("Browsing subtree of ns=2;s=Root");
|
||||
|
||||
@@ -33,7 +33,7 @@ public class SubscribeCommandTests
|
||||
|
||||
fakeService.SubscribeCalls.Count.ShouldBe(1);
|
||||
fakeService.SubscribeCalls[0].IntervalMs.ShouldBe(500);
|
||||
fakeService.SubscribeCalls[0].NodeId.IdentifierAsString.ShouldBe("TestVar");
|
||||
fakeService.SubscribeCalls[0].NodeId.Identifier.ShouldBe("TestVar");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ExecuteAsync unsubscribes when cancellation is requested.</summary>
|
||||
|
||||
-42
@@ -11,48 +11,6 @@ namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.Adapters;
|
||||
/// </summary>
|
||||
public class DefaultSubscriptionAdapterTests
|
||||
{
|
||||
private sealed class CountingSubscription() : Subscription(ClientTelemetry.Context)
|
||||
{
|
||||
public int DisposeCount;
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing) Interlocked.Increment(ref DisposeCount);
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAndDispose_ReleaseSdkResourcesExactlyOnce()
|
||||
{
|
||||
var subscription = new CountingSubscription();
|
||||
var detached = 0;
|
||||
var adapter = new DefaultSubscriptionAdapter(subscription, (_, _) =>
|
||||
{
|
||||
Interlocked.Increment(ref detached);
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
await adapter.DeleteAsync(TestContext.Current.CancellationToken);
|
||||
adapter.Dispose();
|
||||
adapter.Dispose();
|
||||
detached.ShouldBe(1);
|
||||
subscription.DisposeCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConcurrentTeardown_HasOneSdkResourceOwner()
|
||||
{
|
||||
var subscription = new CountingSubscription();
|
||||
var detached = 0;
|
||||
var adapter = new DefaultSubscriptionAdapter(subscription, (_, _) =>
|
||||
{
|
||||
Interlocked.Increment(ref detached);
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
await Task.WhenAll(Task.Run(adapter.Dispose, TestContext.Current.CancellationToken), adapter.DeleteAsync(TestContext.Current.CancellationToken));
|
||||
detached.ShouldBe(1);
|
||||
subscription.DisposeCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>DeleteAsync detaches the wrapped subscription exactly once, even when Dispose follows.</summary>
|
||||
[Fact]
|
||||
public async Task DeleteAsync_detaches_the_subscription_from_its_session_once()
|
||||
|
||||
+6
-6
@@ -117,7 +117,7 @@ public class EndpointSelectorTests
|
||||
var best = EndpointSelector.SelectBest(endpoints, "opc.tcp://external-host:4840",
|
||||
MessageSecurityMode.Sign);
|
||||
|
||||
new Uri(best.EndpointUrl.ShouldNotBeNull()).Host.ShouldBe("external-host");
|
||||
new Uri(best.EndpointUrl).Host.ShouldBe("external-host");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -169,7 +169,7 @@ public class EndpointSelectorTests
|
||||
|
||||
var best = EndpointSelector.SelectBest(endpoints, "opc.tcp://localhost:4841", MessageSecurityMode.None);
|
||||
|
||||
var uri = new Uri(best.EndpointUrl.ShouldNotBeNull());
|
||||
var uri = new Uri(best.EndpointUrl);
|
||||
uri.Host.ShouldBe("localhost");
|
||||
uri.Port.ShouldBe(4841);
|
||||
uri.AbsolutePath.ShouldBe("/OtOpcUa");
|
||||
@@ -183,8 +183,8 @@ public class EndpointSelectorTests
|
||||
|
||||
var best = EndpointSelector.SelectBest(endpoints, "opc.tcp://localhost:4841", MessageSecurityMode.None);
|
||||
|
||||
new Uri(best.EndpointUrl.ShouldNotBeNull()).Host.ShouldBe("internal-host");
|
||||
new Uri(best.EndpointUrl.ShouldNotBeNull()).Port.ShouldBe(4843);
|
||||
new Uri(best.EndpointUrl).Host.ShouldBe("internal-host");
|
||||
new Uri(best.EndpointUrl).Port.ShouldBe(4843);
|
||||
}
|
||||
|
||||
/// <summary>Identical host, port and scheme: nothing to rewrite.</summary>
|
||||
@@ -206,7 +206,7 @@ public class EndpointSelectorTests
|
||||
|
||||
var best = EndpointSelector.SelectBest(endpoints, "opc.tcp://localhost", MessageSecurityMode.None);
|
||||
|
||||
var uri = new Uri(best.EndpointUrl.ShouldNotBeNull());
|
||||
var uri = new Uri(best.EndpointUrl);
|
||||
uri.Host.ShouldBe("localhost");
|
||||
uri.Port.ShouldBe(4840);
|
||||
}
|
||||
@@ -219,7 +219,7 @@ public class EndpointSelectorTests
|
||||
|
||||
var best = EndpointSelector.SelectBest(endpoints, "opc.tcp://[::1]:4841", MessageSecurityMode.None);
|
||||
|
||||
var uri = new Uri(best.EndpointUrl.ShouldNotBeNull());
|
||||
var uri = new Uri(best.EndpointUrl);
|
||||
uri.Host.ShouldBe("[::1]");
|
||||
uri.Port.ShouldBe(4841);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ public sealed class AlarmEventFilterTests
|
||||
{
|
||||
var filter = OpcUaClientService.CreateAlarmEventFilter();
|
||||
|
||||
filter.SelectClauses.ToArray()!.SelectMany(c => c.BrowsePath.ToArray()!).ShouldAllBe(q => !(q.Name ?? "").Contains('/'));
|
||||
filter.SelectClauses.SelectMany(c => c.BrowsePath).ShouldAllBe(q => !q.Name.Contains('/'));
|
||||
}
|
||||
|
||||
/// <summary>The two-state ids are selected by their two-element browse paths, at the indices the parser reads.</summary>
|
||||
@@ -27,7 +27,7 @@ public sealed class AlarmEventFilterTests
|
||||
{
|
||||
var clauses = OpcUaClientService.CreateAlarmEventFilter().SelectClauses;
|
||||
|
||||
string Path(int i) => string.Join("/", clauses[i].BrowsePath.ToArray()!.Select(q => q.Name));
|
||||
string Path(int i) => string.Join("/", clauses[i].BrowsePath.Select(q => q.Name));
|
||||
Path(8).ShouldBe("AckedState/Id");
|
||||
clauses[8].BrowsePath.Count.ShouldBe(2);
|
||||
Path(9).ShouldBe("ActiveState/Id");
|
||||
|
||||
@@ -164,7 +164,7 @@ public sealed class FailoverEpochTests : IDisposable
|
||||
var sub = new FakeSubscriptionAdapter();
|
||||
_factory.EnqueueSession(new FakeSessionAdapter { NextSubscription = sub });
|
||||
await _service.ConnectAsync(Settings(), TestContext.Current.CancellationToken);
|
||||
var node = Opc.Ua.NodeId.Parse("ns=2;s=X");
|
||||
var node = new Opc.Ua.NodeId("ns=2;s=X");
|
||||
await _service.SubscribeAsync(node, ct: TestContext.Current.CancellationToken);
|
||||
|
||||
sub.ThrowOnRemove = true;
|
||||
|
||||
@@ -35,7 +35,7 @@ internal sealed class FakeEndpointDiscovery : IEndpointDiscovery
|
||||
: SecurityPolicies.Basic256Sha256,
|
||||
Server = new ApplicationDescription
|
||||
{
|
||||
ApplicationName = new LocalizedText("FakeServer"),
|
||||
ApplicationName = "FakeServer",
|
||||
ApplicationUri = "urn:localhost:FakeServer"
|
||||
}
|
||||
});
|
||||
|
||||
@@ -48,15 +48,11 @@ internal sealed class FakeSessionAdapter : ISessionAdapter
|
||||
public bool ThrowOnBrowse { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the browse references returned by browse operations.</summary>
|
||||
public ArrayOf<ReferenceDescription> BrowseResponse { get; set; } = [];
|
||||
public ReferenceDescriptionCollection BrowseResponse { get; set; } = [];
|
||||
/// <summary>Gets or sets the continuation point for browse operations.</summary>
|
||||
public byte[]? BrowseContinuationPoint { get; set; }
|
||||
public Func<CancellationToken, Task>? BeforeBrowseNext { get; set; }
|
||||
public List<(byte[] Point, bool Cancelled)> BrowseReleases { get; } = [];
|
||||
public bool ThrowOnBrowseRelease { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the browse references returned by browse-next operations.</summary>
|
||||
public ArrayOf<ReferenceDescription> BrowseNextResponse { get; set; } = [];
|
||||
public ReferenceDescriptionCollection BrowseNextResponse { get; set; } = [];
|
||||
/// <summary>Gets or sets the continuation point for browse-next operations.</summary>
|
||||
public byte[]? BrowseNextContinuationPoint { get; set; }
|
||||
|
||||
@@ -133,7 +129,7 @@ internal sealed class FakeSessionAdapter : ISessionAdapter
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<(byte[]? ContinuationPoint, ArrayOf<ReferenceDescription> References)> BrowseAsync(
|
||||
public Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseAsync(
|
||||
NodeId nodeId, uint nodeClassMask, CancellationToken ct)
|
||||
{
|
||||
BrowseCount++;
|
||||
@@ -143,19 +139,11 @@ internal sealed class FakeSessionAdapter : ISessionAdapter
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<(byte[]? ContinuationPoint, ArrayOf<ReferenceDescription> References)> BrowseNextAsync(
|
||||
public Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseNextAsync(
|
||||
byte[] continuationPoint, CancellationToken ct)
|
||||
{
|
||||
BrowseNextCount++;
|
||||
if (BeforeBrowseNext != null) await BeforeBrowseNext(ct);
|
||||
return (BrowseNextContinuationPoint, BrowseNextResponse);
|
||||
}
|
||||
|
||||
public Task ReleaseBrowseContinuationPointAsync(byte[] continuationPoint, CancellationToken ct)
|
||||
{
|
||||
BrowseReleases.Add((continuationPoint, ct.IsCancellationRequested));
|
||||
if (ThrowOnBrowseRelease) throw new ServiceResultException(StatusCodes.BadSessionClosed);
|
||||
return Task.CompletedTask;
|
||||
return Task.FromResult((BrowseNextContinuationPoint, BrowseNextResponse));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -40,7 +40,7 @@ internal sealed class FakeSessionFactory : ISessionFactory
|
||||
uint sessionTimeoutMs, UserIdentity identity, CancellationToken ct)
|
||||
{
|
||||
CreateCallCount++;
|
||||
LastEndpointUrl = endpoint.EndpointUrl ?? string.Empty;
|
||||
LastEndpointUrl = endpoint.EndpointUrl;
|
||||
|
||||
// One-shot: the gate holds only the call that observed it, so a later call (e.g. a user's Connect while a
|
||||
// failover is held) runs freely.
|
||||
@@ -60,14 +60,14 @@ internal sealed class FakeSessionFactory : ISessionFactory
|
||||
else
|
||||
session = new FakeSessionAdapter
|
||||
{
|
||||
EndpointUrl = endpoint.EndpointUrl ?? string.Empty,
|
||||
ServerName = endpoint.Server?.ApplicationName.Text ?? "FakeServer",
|
||||
EndpointUrl = endpoint.EndpointUrl,
|
||||
ServerName = endpoint.Server?.ApplicationName?.Text ?? "FakeServer",
|
||||
SecurityMode = endpoint.SecurityMode.ToString(),
|
||||
SecurityPolicyUri = endpoint.SecurityPolicyUri ?? string.Empty
|
||||
};
|
||||
|
||||
// Ensure endpoint URL matches
|
||||
session.EndpointUrl = endpoint.EndpointUrl ?? string.Empty;
|
||||
session.EndpointUrl = endpoint.EndpointUrl;
|
||||
_createdSessions.Add(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
@@ -129,16 +129,6 @@ internal sealed class FakeSubscriptionAdapter : ISubscriptionAdapter
|
||||
lock (_itemsLock) _items.Clear();
|
||||
}
|
||||
|
||||
public Action<string, DataValue> CaptureDataCallback(uint handle)
|
||||
{
|
||||
lock (_itemsLock) return _items[handle].DataCallback!;
|
||||
}
|
||||
|
||||
public Action<EventFieldList> CaptureEventCallback(uint handle)
|
||||
{
|
||||
lock (_itemsLock) return _items[handle].EventCallback!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simulates a data change notification for testing.
|
||||
/// </summary>
|
||||
|
||||
+5
-4
@@ -26,7 +26,8 @@ public class AggregateTypeMapperTests
|
||||
public void ToNodeId_ReturnsNonNullForAllValues(AggregateType aggregate)
|
||||
{
|
||||
var nodeId = AggregateTypeMapper.ToNodeId(aggregate);
|
||||
nodeId.IsNull.ShouldBeFalse();
|
||||
nodeId.ShouldNotBeNull();
|
||||
nodeId.IsNullNodeId.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Average aggregate type maps to the correct OPC UA node ID.</summary>
|
||||
@@ -108,9 +109,9 @@ public class AggregateTypeMapperTests
|
||||
[MemberData(nameof(AllAggregates))]
|
||||
public void ToNodeId_maps_to_the_same_named_AggregateFunction(AggregateType aggregate)
|
||||
{
|
||||
var property = typeof(ObjectIds).GetProperty("AggregateFunction_" + aggregate);
|
||||
property.ShouldNotBeNull();
|
||||
AggregateTypeMapper.ToNodeId(aggregate).ShouldBe((NodeId)property.GetValue(null)!);
|
||||
var field = typeof(ObjectIds).GetField("AggregateFunction_" + aggregate);
|
||||
field.ShouldNotBeNull();
|
||||
AggregateTypeMapper.ToNodeId(aggregate).ShouldBe((NodeId)field.GetValue(null)!);
|
||||
}
|
||||
|
||||
/// <summary>Every client aggregate.</summary>
|
||||
|
||||
+10
-26
@@ -29,18 +29,19 @@ public class HistoryReadPagingTests
|
||||
Calls.Add(new Call(cp, release));
|
||||
if (release) return Task.FromResult<HistoryReadResult?>(new HistoryReadResult { StatusCode = StatusCodes.Good });
|
||||
var (count, next) = _pages.Dequeue();
|
||||
var data = new HistoryData { DataValues = Enumerable.Range(0, count).Select(i => new DataValue(Variant.From(i))).ToArrayOf() };
|
||||
var data = new HistoryData();
|
||||
for (var i = 0; i < count; i++) data.DataValues.Add(new DataValue(new Variant(i)));
|
||||
return Task.FromResult<HistoryReadResult?>(new HistoryReadResult
|
||||
{
|
||||
StatusCode = StatusCodes.Good,
|
||||
ContinuationPoint = ByteString.From(next),
|
||||
ContinuationPoint = next,
|
||||
HistoryData = new ExtensionObject(data)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<DataValue> Values(HistoryReadResult r) =>
|
||||
r.HistoryData.TryGetValue(out HistoryData? d) ? d.DataValues.ToArray() ?? [] : [];
|
||||
r.HistoryData?.Body is HistoryData d ? d.DataValues : [];
|
||||
|
||||
/// <summary>
|
||||
/// A server page limit below the caller's cap hands back a continuation point: the follow-up must READ the next
|
||||
@@ -135,7 +136,8 @@ public class HistoryReadPagingTests
|
||||
calls.Add((cp, release, ct.IsCancellationRequested));
|
||||
if (release) return Task.FromResult<HistoryReadResult?>(new HistoryReadResult { StatusCode = StatusCodes.Good });
|
||||
if (cp != null) { cts.Cancel(); ct.ThrowIfCancellationRequested(); }
|
||||
var data = new HistoryData { DataValues = [new DataValue(Variant.From(1))] };
|
||||
var data = new HistoryData();
|
||||
data.DataValues.Add(new DataValue(new Variant(1)));
|
||||
return Task.FromResult<HistoryReadResult?>(new HistoryReadResult
|
||||
{
|
||||
StatusCode = StatusCodes.Good, ContinuationPoint = [5], HistoryData = new ExtensionObject(data)
|
||||
@@ -163,7 +165,8 @@ public class HistoryReadPagingTests
|
||||
releaseSawCancelled = ct.IsCancellationRequested;
|
||||
return Task.FromResult<HistoryReadResult?>(new HistoryReadResult { StatusCode = StatusCodes.Good });
|
||||
}
|
||||
var data = new HistoryData { DataValues = [new DataValue(Variant.From(1))] };
|
||||
var data = new HistoryData();
|
||||
data.DataValues.Add(new DataValue(new Variant(1)));
|
||||
cts.Cancel(); // the caller gives up right as the capped page arrives
|
||||
return Task.FromResult<HistoryReadResult?>(new HistoryReadResult
|
||||
{
|
||||
@@ -186,7 +189,8 @@ public class HistoryReadPagingTests
|
||||
{
|
||||
calls++;
|
||||
if (release) throw new ServiceResultException(StatusCodes.BadSessionClosed);
|
||||
var data = new HistoryData { DataValues = [new DataValue(Variant.From(1))] };
|
||||
var data = new HistoryData();
|
||||
data.DataValues.Add(new DataValue(new Variant(1)));
|
||||
return Task.FromResult<HistoryReadResult?>(new HistoryReadResult
|
||||
{
|
||||
StatusCode = StatusCodes.Good, ContinuationPoint = [9], HistoryData = new ExtensionObject(data)
|
||||
@@ -212,24 +216,4 @@ public class HistoryReadPagingTests
|
||||
await Should.ThrowAsync<ServiceResultException>(() =>
|
||||
HistoryReadPaging.ReadAllAsync(Read, Values, maxValues: 10, Node, CancellationToken.None));
|
||||
}
|
||||
[Fact]
|
||||
public async Task RefusedContinuationPage_ReleasesCursorAndPreservesStatus()
|
||||
{
|
||||
var releases = new List<byte[]>();
|
||||
Task<HistoryReadResult?> Read(byte[]? cp, bool release, CancellationToken ct)
|
||||
{
|
||||
if (release)
|
||||
{
|
||||
releases.Add(cp!);
|
||||
throw new ServiceResultException(StatusCodes.BadSessionClosed);
|
||||
}
|
||||
return Task.FromResult<HistoryReadResult?>(cp == null
|
||||
? new HistoryReadResult { StatusCode = StatusCodes.Good, ContinuationPoint = [7] }
|
||||
: new HistoryReadResult { StatusCode = StatusCodes.BadUserAccessDenied });
|
||||
}
|
||||
var exception = await Should.ThrowAsync<ServiceResultException>(() =>
|
||||
HistoryReadPaging.ReadAllAsync(Read, Values, 10, Node, TestContext.Current.CancellationToken));
|
||||
exception.StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
releases.ShouldHaveSingleItem().ShouldBe(new byte[] { 7 });
|
||||
}
|
||||
}
|
||||
|
||||
+14
-11
@@ -7,29 +7,32 @@ namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.Helpers;
|
||||
|
||||
public class HistoryReadStatusTests
|
||||
{
|
||||
public static IEnumerable<object[]> Refusals() => new[] { StatusCodes.BadHistoryOperationUnsupported,
|
||||
StatusCodes.BadUserAccessDenied, StatusCodes.BadNodeIdUnknown, StatusCodes.BadTimeout }.Select(s => new object[] { s });
|
||||
public static IEnumerable<object[]> NonRefusals() => new[] { StatusCodes.Good, StatusCodes.GoodNoData,
|
||||
StatusCodes.GoodMoreData, StatusCodes.UncertainNoCommunicationLastUsableValue, StatusCodes.BadNoData }.Select(s => new object[] { s });
|
||||
/// <summary>A refused read must surface, carrying the status code, rather than read as an empty result.</summary>
|
||||
/// <param name="code">The Bad per-node status code.</param>
|
||||
[Theory]
|
||||
[MemberData(nameof(Refusals))]
|
||||
public void Bad_status_throws_ServiceResultException_with_the_code(StatusCode code)
|
||||
[InlineData(StatusCodes.BadHistoryOperationUnsupported)]
|
||||
[InlineData(StatusCodes.BadUserAccessDenied)]
|
||||
[InlineData(StatusCodes.BadNodeIdUnknown)]
|
||||
[InlineData(StatusCodes.BadTimeout)]
|
||||
public void Bad_status_throws_ServiceResultException_with_the_code(uint code)
|
||||
{
|
||||
var ex = Should.Throw<ServiceResultException>(
|
||||
() => HistoryReadStatus.ThrowIfRefused(code, new NodeId("n", 2)));
|
||||
() => HistoryReadStatus.ThrowIfRefused(new StatusCode(code), new NodeId("n", 2)));
|
||||
|
||||
ex.StatusCode.ShouldBe(code.Code);
|
||||
ex.StatusCode.ShouldBe(code);
|
||||
ex.Message.ShouldContain("ns=2;s=n");
|
||||
}
|
||||
|
||||
/// <summary>Good, GoodNoData, Uncertain and BadNoData are all non-refusals (the window may simply be empty).</summary>
|
||||
/// <param name="code">The non-refusal status code.</param>
|
||||
[Theory]
|
||||
[MemberData(nameof(NonRefusals))]
|
||||
public void Non_refusal_status_does_not_throw(StatusCode code)
|
||||
[InlineData(StatusCodes.Good)]
|
||||
[InlineData(StatusCodes.GoodNoData)]
|
||||
[InlineData(StatusCodes.GoodMoreData)]
|
||||
[InlineData(StatusCodes.UncertainNoCommunicationLastUsableValue)]
|
||||
[InlineData(StatusCodes.BadNoData)]
|
||||
public void Non_refusal_status_does_not_throw(uint code)
|
||||
{
|
||||
Should.NotThrow(() => HistoryReadStatus.ThrowIfRefused(code, new NodeId("n", 2)));
|
||||
Should.NotThrow(() => HistoryReadStatus.ThrowIfRefused(new StatusCode(code), new NodeId("n", 2)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.Helpers;
|
||||
|
||||
public class VariantBoundaryTests
|
||||
{
|
||||
public static IEnumerable<object?[]> Payloads()
|
||||
{
|
||||
foreach (var value in new object?[] { null, true, (sbyte)-1, (byte)1, (short)-2, (ushort)2,
|
||||
-3, 3u, -4L, 4UL, 1.25f, 2.5, "", "text", Guid.Parse("de305d54-75b4-431b-adb2-eb6b9e546014"),
|
||||
new DateTime(2026, 11, 10, 0, 0, 0, DateTimeKind.Utc), Array.Empty<byte>(), new byte[] { 1, 2 },
|
||||
Array.Empty<int>(), new[] { 1 }, new[] { 1, 2 }, Array.Empty<string>(), new[] { "", "text" } })
|
||||
yield return [value];
|
||||
foreach (var value in new object[] { true, (sbyte)-1, (byte)1, (short)-2, (ushort)2,
|
||||
-3, 3u, -4L, 4UL, 1.25f, 2.5, "text", Guid.Empty,
|
||||
new DateTime(2026, 11, 10, 0, 0, 0, DateTimeKind.Utc) })
|
||||
{
|
||||
foreach (var count in new[] { 0, 1, 3 })
|
||||
{
|
||||
var array = Array.CreateInstance(value.GetType(), count);
|
||||
for (var i = 0; i < count; i++) array.SetValue(value, i);
|
||||
yield return [array];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Payloads))]
|
||||
public void RoundTrip_PreservesClrTypeAndValue(object? expected)
|
||||
{
|
||||
var actual = ValueConverter.ToClrObject(ValueConverter.ToVariant(expected));
|
||||
if (expected == null) actual.ShouldBeNull();
|
||||
else
|
||||
{
|
||||
actual.ShouldNotBeNull();
|
||||
actual.GetType().ShouldBe(expected.GetType());
|
||||
actual.ShouldBe(expected);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetainedArrays_AreDetachedFromCaller()
|
||||
{
|
||||
var bytes = new byte[] { 1, 2 };
|
||||
var numbers = new[] { 3, 4 };
|
||||
var byteValue = ValueConverter.ToVariant(bytes);
|
||||
var numericValue = ValueConverter.ToVariant(numbers);
|
||||
bytes[0] = 99;
|
||||
numbers[0] = 99;
|
||||
ValueConverter.ToClrObject(byteValue).ShouldBe(new byte[] { 1, 2 });
|
||||
ValueConverter.ToClrObject(numericValue).ShouldBe(new[] { 3, 4 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ByteString_NullAndEmptyRemainDistinct()
|
||||
{
|
||||
ValueConverter.ToClrObject(Variant.From(default(ByteString))).ShouldBeNull();
|
||||
ValueConverter.ToClrObject(Variant.From(ByteString.Empty)).ShouldBe(Array.Empty<byte>());
|
||||
ValueConverter.ToClrObject(Variant.Null).ShouldBeNull();
|
||||
ValueConverter.ToClrObject(Variant.From(0)).ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NullWrapperInputs_RemainNullAfterRetention()
|
||||
{
|
||||
ValueConverter.ToClrObject(ValueConverter.ToVariant(default(ByteString))).ShouldBeNull();
|
||||
ValueConverter.ToClrObject(ValueConverter.ToVariant(default(ArrayOf<int>))).ShouldBeNull();
|
||||
ValueConverter.ToVariant(default(ArrayOf<byte>)).TypeInfo.ValueRank.ShouldBe(ValueRanks.OneDimension);
|
||||
ValueConverter.ToClrObject(ValueConverter.ToVariant(Variant.From(default(ByteString)))).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadOnlyWrappers_AreDetachedFromMutableBackingBuffers()
|
||||
{
|
||||
var bytes = new byte[] { 1, 2 };
|
||||
var numbers = new[] { 3, 4 };
|
||||
var retainedBytes = ValueConverter.ToVariant(new ByteString(bytes));
|
||||
var retainedNumbers = ValueConverter.ToVariant(new ArrayOf<int>(numbers));
|
||||
bytes[0] = 99;
|
||||
numbers[0] = 99;
|
||||
ValueConverter.ToClrObject(retainedBytes).ShouldBe(new byte[] { 1, 2 });
|
||||
ValueConverter.ToClrObject(retainedNumbers).ShouldBe(new[] { 3, 4 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetainedByteStringArray_CopiesElementBuffersAndPreservesNullEmpty()
|
||||
{
|
||||
var bytes = new byte[] { 1, 2 };
|
||||
var value = ValueConverter.ToVariant(new ByteString[] { default, ByteString.Empty, new(bytes) });
|
||||
bytes[0] = 99;
|
||||
var actual = ValueConverter.ToClrObject(value).ShouldBeOfType<byte[][]>();
|
||||
actual[0].ShouldBeNull();
|
||||
actual[1].ShouldBeEmpty();
|
||||
actual[2].ShouldBe(new byte[] { 1, 2 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ByteArrayAndByteString_KeepWireRank()
|
||||
{
|
||||
ValueConverter.ToVariant(new byte[] { 1 }).TypeInfo.BuiltInType.ShouldBe(BuiltInType.ByteString);
|
||||
ValueConverter.ToVariant(new ArrayOf<byte>(new byte[] { 1 })).TypeInfo.BuiltInType.ShouldBe(BuiltInType.Byte);
|
||||
ValueConverter.ToVariant(new ArrayOf<byte>(new byte[] { 1 })).TypeInfo.ValueRank.ShouldBe(ValueRanks.OneDimension);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NodeIds_KeepOpaqueBytesAndLiteralStringIdentifiers()
|
||||
{
|
||||
var opaque = NodeId.Parse("ns=2;b=AQID");
|
||||
ValueConverter.ToClrObject(ValueConverter.ToVariant(opaque)).ShouldBe(opaque);
|
||||
var literal = new NodeId("ns=2;s=literal", 4);
|
||||
var result = ValueConverter.ToClrObject(ValueConverter.ToVariant(literal)).ShouldBeOfType<NodeId>();
|
||||
result.NamespaceIndex.ShouldBe((ushort)4);
|
||||
result.TryGetValue(out string? identifier).ShouldBeTrue();
|
||||
identifier.ShouldBe("ns=2;s=literal");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnsupportedShape_IsRejected()
|
||||
{
|
||||
Should.Throw<ServiceResultException>(() => ValueConverter.ToVariant(new int[2, 2]));
|
||||
Should.Throw<ServiceResultException>(() => ValueConverter.ToVariant(new object()));
|
||||
}
|
||||
}
|
||||
@@ -76,7 +76,7 @@ public class ModelConstructionTests
|
||||
|
||||
args.NodeId.ShouldBe("ns=2;s=Temp");
|
||||
args.Value.ShouldBe(value);
|
||||
args.Value.WrappedValue.ShouldBe(Variant.From(42));
|
||||
args.Value.Value.ShouldBe(42);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ConnectionStateChangedEventArgs constructs correctly with all properties.</summary>
|
||||
|
||||
@@ -197,9 +197,9 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
_sessionFactory.EnqueueSession(session);
|
||||
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
||||
|
||||
var result = await _service.ReadValueAsync(NodeId.Parse("ns=2;s=MyNode"), TestContext.Current.CancellationToken);
|
||||
var result = await _service.ReadValueAsync(new NodeId("ns=2;s=MyNode"), TestContext.Current.CancellationToken);
|
||||
|
||||
result.WrappedValue.ShouldBe(Variant.From(42));
|
||||
result.Value.ShouldBe(42);
|
||||
session.ReadCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
@@ -210,7 +210,7 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
public async Task ReadValueAsync_WhenDisconnected_Throws()
|
||||
{
|
||||
await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||
_service.ReadValueAsync(NodeId.Parse("ns=2;s=MyNode")));
|
||||
_service.ReadValueAsync(new NodeId("ns=2;s=MyNode")));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -224,7 +224,7 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
||||
|
||||
await Should.ThrowAsync<ServiceResultException>(() =>
|
||||
_service.ReadValueAsync(NodeId.Parse("ns=2;s=MyNode")));
|
||||
_service.ReadValueAsync(new NodeId("ns=2;s=MyNode")));
|
||||
}
|
||||
|
||||
// --- Write tests ---
|
||||
@@ -243,7 +243,7 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
_sessionFactory.EnqueueSession(session);
|
||||
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
||||
|
||||
var result = await _service.WriteValueAsync(NodeId.Parse("ns=2;s=MyNode"), 42, TestContext.Current.CancellationToken);
|
||||
var result = await _service.WriteValueAsync(new NodeId("ns=2;s=MyNode"), 42, TestContext.Current.CancellationToken);
|
||||
|
||||
result.ShouldBe(StatusCodes.Good);
|
||||
session.WriteCount.ShouldBe(1);
|
||||
@@ -262,7 +262,7 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
_sessionFactory.EnqueueSession(session);
|
||||
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
||||
|
||||
await _service.WriteValueAsync(NodeId.Parse("ns=2;s=MyNode"), "42", TestContext.Current.CancellationToken);
|
||||
await _service.WriteValueAsync(new NodeId("ns=2;s=MyNode"), "42", TestContext.Current.CancellationToken);
|
||||
|
||||
session.WriteCount.ShouldBe(1);
|
||||
session.ReadCount.ShouldBe(1); // Read for type inference
|
||||
@@ -278,7 +278,7 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
_sessionFactory.EnqueueSession(session);
|
||||
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
||||
|
||||
await _service.WriteValueAsync(NodeId.Parse("ns=2;s=MyNode"), 42, TestContext.Current.CancellationToken);
|
||||
await _service.WriteValueAsync(new NodeId("ns=2;s=MyNode"), 42, TestContext.Current.CancellationToken);
|
||||
|
||||
session.WriteCount.ShouldBe(1);
|
||||
session.ReadCount.ShouldBe(0); // No read for non-string values
|
||||
@@ -291,7 +291,7 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
public async Task WriteValueAsync_WhenDisconnected_Throws()
|
||||
{
|
||||
await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||
_service.WriteValueAsync(NodeId.Parse("ns=2;s=MyNode"), 42));
|
||||
_service.WriteValueAsync(new NodeId("ns=2;s=MyNode"), 42));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -303,13 +303,13 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
{
|
||||
var session = new FakeSessionAdapter
|
||||
{
|
||||
ReadResponse = DataValue.FromStatusCode(StatusCodes.BadNodeIdUnknown) // Bad status, null Value
|
||||
ReadResponse = new DataValue(StatusCodes.BadNodeIdUnknown) // Bad status, null Value
|
||||
};
|
||||
_sessionFactory.EnqueueSession(session);
|
||||
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
||||
|
||||
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||
_service.WriteValueAsync(NodeId.Parse("ns=2;s=MyNode"), "42"));
|
||||
_service.WriteValueAsync(new NodeId("ns=2;s=MyNode"), "42"));
|
||||
|
||||
ex.Message.ShouldContain("Cannot infer target type");
|
||||
}
|
||||
@@ -323,90 +323,17 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
{
|
||||
var session = new FakeSessionAdapter
|
||||
{
|
||||
ReadResponse = DataValue.FromStatusCode(StatusCodes.BadNotReadable)
|
||||
ReadResponse = new DataValue(StatusCodes.BadNotReadable)
|
||||
};
|
||||
_sessionFactory.EnqueueSession(session);
|
||||
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
||||
|
||||
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||
_service.WriteValueAsync(NodeId.Parse("ns=2;s=MyNode"), "42"));
|
||||
_service.WriteValueAsync(new NodeId("ns=2;s=MyNode"), "42"));
|
||||
|
||||
ex.Message.ShouldContain("ns=2;s=MyNode");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task BrowseCancellation_ReleasesOwnedCursor_WithoutMaskingCancellation(bool releaseFails)
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
var session = new FakeSessionAdapter
|
||||
{
|
||||
BrowseResponse = [new ReferenceDescription { NodeId = new ExpandedNodeId("child", 2) }],
|
||||
BrowseContinuationPoint = [7],
|
||||
ThrowOnBrowseRelease = releaseFails,
|
||||
BeforeBrowseNext = token => { cts.Cancel(); token.ThrowIfCancellationRequested(); return Task.CompletedTask; }
|
||||
};
|
||||
_sessionFactory.EnqueueSession(session);
|
||||
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
||||
await Should.ThrowAsync<OperationCanceledException>(() => _service.BrowseAsync(ct: cts.Token));
|
||||
var release = session.BrowseReleases.ShouldHaveSingleItem();
|
||||
release.Point.ShouldBe(new byte[] { 7 });
|
||||
release.Cancelled.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BrowseEmptyPage_ReleasesRemainingCursor()
|
||||
{
|
||||
var session = new FakeSessionAdapter { BrowseContinuationPoint = [8] };
|
||||
_sessionFactory.EnqueueSession(session);
|
||||
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
||||
(await _service.BrowseAsync(ct: TestContext.Current.CancellationToken)).ShouldBeEmpty();
|
||||
session.BrowseReleases.ShouldHaveSingleItem().Point.ShouldBe(new byte[] { 8 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadValue_PreservesSdkStatusTimestampsAndPicoseconds()
|
||||
{
|
||||
var source = new DateTime(2026, 11, 10, 12, 0, 0, DateTimeKind.Utc);
|
||||
var value = new DataValue(Variant.From(12.5), StatusCodes.UncertainLastUsableValue,
|
||||
source, source.AddSeconds(1), 12, 34);
|
||||
var session = new FakeSessionAdapter { ReadResponse = value };
|
||||
_sessionFactory.EnqueueSession(session);
|
||||
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
||||
var actual = await _service.ReadValueAsync(new NodeId("tag", 2), TestContext.Current.CancellationToken);
|
||||
actual.ShouldBe(value);
|
||||
actual.StatusCode.ShouldBe(StatusCodes.UncertainLastUsableValue);
|
||||
actual.SourcePicoseconds.ShouldBe((ushort)12);
|
||||
actual.ServerPicoseconds.ShouldBe((ushort)34);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AlarmMapping_PreservesTimeEmptyTextAndRetainedEventId()
|
||||
{
|
||||
var session = new FakeSessionAdapter();
|
||||
_sessionFactory.EnqueueSession(session);
|
||||
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
||||
await _service.SubscribeAlarmsAsync(ct: TestContext.Current.CancellationToken);
|
||||
var timestamp = new DateTime(2026, 11, 10, 12, 0, 0, DateTimeKind.Utc);
|
||||
var bytes = new byte[] { 1, 2, 3 };
|
||||
AlarmEventArgs? received = null;
|
||||
_service.AlarmEvent += (_, e) => received = e;
|
||||
var subscription = session.CreatedSubscriptions.Single();
|
||||
subscription.SimulateEvent(subscription.ActiveHandles.Single(), new EventFieldList
|
||||
{
|
||||
EventFields = [Variant.From(new ByteString(bytes)), Variant.From(ObjectTypeIds.AlarmConditionType),
|
||||
Variant.From("source"), Variant.From((DateTimeUtc)timestamp), Variant.From(LocalizedText.Null),
|
||||
Variant.From((ushort)321), Variant.From("condition"), Variant.From(true)]
|
||||
});
|
||||
bytes[0] = 99;
|
||||
received.ShouldNotBeNull();
|
||||
received.Time.ShouldBe(timestamp);
|
||||
received.Message.ShouldBe("");
|
||||
received.EventId.ShouldBe(new byte[] { 1, 2, 3 });
|
||||
received.Severity.ShouldBe((ushort)321);
|
||||
}
|
||||
|
||||
// --- Browse tests ---
|
||||
|
||||
/// <summary>
|
||||
@@ -421,7 +348,7 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
[
|
||||
new ReferenceDescription
|
||||
{
|
||||
NodeId = ExpandedNodeId.Parse("ns=2;s=Child1"),
|
||||
NodeId = new ExpandedNodeId("ns=2;s=Child1"),
|
||||
DisplayName = new LocalizedText("Child1"),
|
||||
NodeClass = NodeClass.Variable
|
||||
}
|
||||
@@ -470,19 +397,19 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
[
|
||||
new ReferenceDescription
|
||||
{
|
||||
NodeId = ExpandedNodeId.Parse("ns=2;s=Folder1"),
|
||||
NodeId = new ExpandedNodeId("ns=2;s=Folder1"),
|
||||
DisplayName = new LocalizedText("Folder1"),
|
||||
NodeClass = NodeClass.Object
|
||||
},
|
||||
new ReferenceDescription
|
||||
{
|
||||
NodeId = ExpandedNodeId.Parse("ns=2;s=Folder2"),
|
||||
NodeId = new ExpandedNodeId("ns=2;s=Folder2"),
|
||||
DisplayName = new LocalizedText("Folder2"),
|
||||
NodeClass = NodeClass.Object
|
||||
},
|
||||
new ReferenceDescription
|
||||
{
|
||||
NodeId = ExpandedNodeId.Parse("ns=2;s=Tag"),
|
||||
NodeId = new ExpandedNodeId("ns=2;s=Tag"),
|
||||
DisplayName = new LocalizedText("Tag"),
|
||||
NodeClass = NodeClass.Variable
|
||||
}
|
||||
@@ -510,7 +437,7 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
[
|
||||
new ReferenceDescription
|
||||
{
|
||||
NodeId = ExpandedNodeId.Parse("ns=2;s=A"),
|
||||
NodeId = new ExpandedNodeId("ns=2;s=A"),
|
||||
DisplayName = new LocalizedText("A"),
|
||||
NodeClass = NodeClass.Variable
|
||||
}
|
||||
@@ -520,7 +447,7 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
[
|
||||
new ReferenceDescription
|
||||
{
|
||||
NodeId = ExpandedNodeId.Parse("ns=2;s=B"),
|
||||
NodeId = new ExpandedNodeId("ns=2;s=B"),
|
||||
DisplayName = new LocalizedText("B"),
|
||||
NodeClass = NodeClass.Variable
|
||||
}
|
||||
@@ -557,7 +484,7 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
_sessionFactory.EnqueueSession(session);
|
||||
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
||||
|
||||
await _service.SubscribeAsync(NodeId.Parse("ns=2;s=MyNode"), 500, TestContext.Current.CancellationToken);
|
||||
await _service.SubscribeAsync(new NodeId("ns=2;s=MyNode"), 500, TestContext.Current.CancellationToken);
|
||||
|
||||
session.CreatedSubscriptions.Count.ShouldBe(1);
|
||||
session.CreatedSubscriptions[0].AddDataChangeCount.ShouldBe(1);
|
||||
@@ -573,8 +500,8 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
_sessionFactory.EnqueueSession(session);
|
||||
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
||||
|
||||
await _service.SubscribeAsync(NodeId.Parse("ns=2;s=MyNode"), ct: TestContext.Current.CancellationToken);
|
||||
await _service.SubscribeAsync(NodeId.Parse("ns=2;s=MyNode"), ct: TestContext.Current.CancellationToken); // duplicate
|
||||
await _service.SubscribeAsync(new NodeId("ns=2;s=MyNode"), ct: TestContext.Current.CancellationToken);
|
||||
await _service.SubscribeAsync(new NodeId("ns=2;s=MyNode"), ct: TestContext.Current.CancellationToken); // duplicate
|
||||
|
||||
session.CreatedSubscriptions[0].AddDataChangeCount.ShouldBe(1);
|
||||
}
|
||||
@@ -593,7 +520,7 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
DataChangedEventArgs? received = null;
|
||||
_service.DataChanged += (_, e) => received = e;
|
||||
|
||||
await _service.SubscribeAsync(NodeId.Parse("ns=2;s=MyNode"), 500, TestContext.Current.CancellationToken);
|
||||
await _service.SubscribeAsync(new NodeId("ns=2;s=MyNode"), 500, TestContext.Current.CancellationToken);
|
||||
|
||||
// Simulate data change
|
||||
var handle = fakeSub.ActiveHandles.First();
|
||||
@@ -601,7 +528,7 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
|
||||
received.ShouldNotBeNull();
|
||||
received!.NodeId.ShouldBe("ns=2;s=MyNode");
|
||||
received.Value.WrappedValue.ShouldBe(Variant.From(99));
|
||||
received.Value.Value.ShouldBe(99);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -615,8 +542,8 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
_sessionFactory.EnqueueSession(session);
|
||||
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
||||
|
||||
await _service.SubscribeAsync(NodeId.Parse("ns=2;s=MyNode"), ct: TestContext.Current.CancellationToken);
|
||||
await _service.UnsubscribeAsync(NodeId.Parse("ns=2;s=MyNode"), TestContext.Current.CancellationToken);
|
||||
await _service.SubscribeAsync(new NodeId("ns=2;s=MyNode"), ct: TestContext.Current.CancellationToken);
|
||||
await _service.UnsubscribeAsync(new NodeId("ns=2;s=MyNode"), TestContext.Current.CancellationToken);
|
||||
|
||||
fakeSub.RemoveCount.ShouldBe(1);
|
||||
}
|
||||
@@ -631,7 +558,7 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
_sessionFactory.EnqueueSession(session);
|
||||
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
||||
|
||||
await _service.UnsubscribeAsync(NodeId.Parse("ns=2;s=NotSubscribed"), TestContext.Current.CancellationToken);
|
||||
await _service.UnsubscribeAsync(new NodeId("ns=2;s=NotSubscribed"), TestContext.Current.CancellationToken);
|
||||
// Should not throw
|
||||
}
|
||||
|
||||
@@ -642,7 +569,7 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
public async Task SubscribeAsync_WhenDisconnected_Throws()
|
||||
{
|
||||
await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||
_service.SubscribeAsync(NodeId.Parse("ns=2;s=MyNode")));
|
||||
_service.SubscribeAsync(new NodeId("ns=2;s=MyNode")));
|
||||
}
|
||||
|
||||
// --- Alarm subscription tests ---
|
||||
@@ -815,7 +742,7 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
_sessionFactory.EnqueueSession(session);
|
||||
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
||||
|
||||
var results = await _service.HistoryReadRawAsync(NodeId.Parse("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, ct: TestContext.Current.CancellationToken);
|
||||
var results = await _service.HistoryReadRawAsync(new NodeId("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, ct: TestContext.Current.CancellationToken);
|
||||
|
||||
results.Count.ShouldBe(2);
|
||||
session.HistoryReadRawCount.ShouldBe(1);
|
||||
@@ -828,7 +755,7 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
public async Task HistoryReadRawAsync_WhenDisconnected_Throws()
|
||||
{
|
||||
await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||
_service.HistoryReadRawAsync(NodeId.Parse("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow));
|
||||
_service.HistoryReadRawAsync(new NodeId("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -842,7 +769,7 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
||||
|
||||
await Should.ThrowAsync<ServiceResultException>(() =>
|
||||
_service.HistoryReadRawAsync(NodeId.Parse("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow));
|
||||
_service.HistoryReadRawAsync(new NodeId("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -859,7 +786,7 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
_sessionFactory.EnqueueSession(session);
|
||||
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
||||
|
||||
var results = await _service.HistoryReadAggregateAsync(NodeId.Parse("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, AggregateType.Average, ct: TestContext.Current.CancellationToken);
|
||||
var results = await _service.HistoryReadAggregateAsync(new NodeId("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, AggregateType.Average, ct: TestContext.Current.CancellationToken);
|
||||
|
||||
results.Count.ShouldBe(1);
|
||||
session.HistoryReadAggregateCount.ShouldBe(1);
|
||||
@@ -873,7 +800,7 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
{
|
||||
await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||
_service.HistoryReadAggregateAsync(
|
||||
NodeId.Parse("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow,
|
||||
new NodeId("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow,
|
||||
AggregateType.Average));
|
||||
}
|
||||
|
||||
@@ -889,7 +816,7 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
|
||||
await Should.ThrowAsync<ServiceResultException>(() =>
|
||||
_service.HistoryReadAggregateAsync(
|
||||
NodeId.Parse("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow,
|
||||
new NodeId("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow,
|
||||
AggregateType.Average));
|
||||
}
|
||||
|
||||
@@ -913,7 +840,7 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
return new DataValue(new Variant(["urn:server1", "urn:server2"]), StatusCodes.Good);
|
||||
if (nodeId == VariableIds.Server_ServerArray)
|
||||
return new DataValue(new Variant(["urn:server1"]), StatusCodes.Good);
|
||||
return DataValue.FromStatusCode(StatusCodes.BadNodeIdUnknown);
|
||||
return new DataValue(StatusCodes.BadNodeIdUnknown);
|
||||
}
|
||||
};
|
||||
_sessionFactory.EnqueueSession(session);
|
||||
@@ -1006,9 +933,9 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
ReadResponseFunc = nodeId =>
|
||||
{
|
||||
if (nodeId == VariableIds.Server_ServerRedundancy_RedundancySupport)
|
||||
return DataValue.FromStatusCode(StatusCodes.BadNotReadable); // bad status, null Value
|
||||
return new DataValue(StatusCodes.BadNotReadable); // bad status, null Value
|
||||
if (nodeId == VariableIds.Server_ServiceLevel)
|
||||
return DataValue.FromStatusCode(StatusCodes.BadNotReadable);
|
||||
return new DataValue(StatusCodes.BadNotReadable);
|
||||
throw new ServiceResultException(StatusCodes.BadNodeIdUnknown);
|
||||
}
|
||||
};
|
||||
@@ -1119,7 +1046,7 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
var result = await _service.AcknowledgeAlarmAsync("ns=2;s=Cond", new byte[] { 1, 2 }, "acked", TestContext.Current.CancellationToken);
|
||||
|
||||
StatusCode.IsBad(result).ShouldBeTrue();
|
||||
result.Code.ShouldBe(StatusCodes.BadConditionAlreadyEnabled.Code);
|
||||
result.Code.ShouldBe(StatusCodes.BadConditionAlreadyEnabled);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1178,7 +1105,7 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
var result = await _service.ConfirmAlarmAsync("ns=2;s=Cond", new byte[] { 1, 2 }, "confirmed", TestContext.Current.CancellationToken);
|
||||
|
||||
StatusCode.IsBad(result).ShouldBeTrue();
|
||||
result.Code.ShouldBe(StatusCodes.BadConditionAlreadyEnabled.Code);
|
||||
result.Code.ShouldBe(StatusCodes.BadConditionAlreadyEnabled);
|
||||
}
|
||||
|
||||
// --- ShelveAlarm tests ---
|
||||
@@ -1259,7 +1186,7 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
var result = await _service.ShelveAlarmAsync("ns=2;s=Cond", ShelveKind.OneShot, ct: TestContext.Current.CancellationToken);
|
||||
|
||||
StatusCode.IsBad(result).ShouldBeTrue();
|
||||
result.Code.ShouldBe(StatusCodes.BadConditionAlreadyShelved.Code);
|
||||
result.Code.ShouldBe(StatusCodes.BadConditionAlreadyShelved);
|
||||
}
|
||||
|
||||
// --- Enable / Disable tests (H4 client path) ---
|
||||
@@ -1322,7 +1249,7 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
var result = await _service.EnableAsync("ns=2;s=Cond", TestContext.Current.CancellationToken);
|
||||
|
||||
StatusCode.IsBad(result).ShouldBeTrue();
|
||||
result.Code.ShouldBe(StatusCodes.BadConditionAlreadyEnabled.Code);
|
||||
result.Code.ShouldBe(StatusCodes.BadConditionAlreadyEnabled);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1343,7 +1270,7 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
var result = await _service.DisableAsync("ns=2;s=Cond", TestContext.Current.CancellationToken);
|
||||
|
||||
StatusCode.IsBad(result).ShouldBeTrue();
|
||||
result.Code.ShouldBe(StatusCodes.BadConditionAlreadyDisabled.Code);
|
||||
result.Code.ShouldBe(StatusCodes.BadConditionAlreadyDisabled);
|
||||
}
|
||||
|
||||
// --- Missing standard alarm fields (#598: the Galaxy-era supplemental reads are gone) ---
|
||||
@@ -1557,7 +1484,7 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
|
||||
var gate = new TaskCompletionSource();
|
||||
session1.CreateSubscriptionGate = gate;
|
||||
var subscribe = _service.SubscribeAsync(NodeId.Parse("ns=2;s=Racer"), 1000, TestContext.Current.CancellationToken);
|
||||
var subscribe = _service.SubscribeAsync(new NodeId("ns=2;s=Racer"), 1000, TestContext.Current.CancellationToken);
|
||||
await session1.CreateSubscriptionEntered.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
|
||||
|
||||
session1.SimulateKeepAlive(false);
|
||||
@@ -1572,7 +1499,7 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
replayed.AddDataChangeCount.ShouldBe(1);
|
||||
|
||||
// And it is the live subscription: unsubscribing removes it from session2's subscription, not session1's.
|
||||
await _service.UnsubscribeAsync(NodeId.Parse("ns=2;s=Racer"), TestContext.Current.CancellationToken);
|
||||
await _service.UnsubscribeAsync(new NodeId("ns=2;s=Racer"), TestContext.Current.CancellationToken);
|
||||
replayed.RemoveCount.ShouldBe(1);
|
||||
replayed.ActiveHandles.ShouldBeEmpty();
|
||||
}
|
||||
@@ -1621,7 +1548,7 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
var tasks = new List<Task>();
|
||||
for (var i = 0; i < 50; i++)
|
||||
{
|
||||
var nodeId = NodeId.Parse($"ns=2;s=Node{i}");
|
||||
var nodeId = new NodeId($"ns=2;s=Node{i}");
|
||||
tasks.Add(Task.Run(async () =>
|
||||
{
|
||||
await _service.SubscribeAsync(nodeId);
|
||||
@@ -1633,55 +1560,6 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
await Should.NotThrowAsync(() => Task.WhenAll(tasks));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueuedCallbacks_AfterDisposal_AreIgnored()
|
||||
{
|
||||
var session = new FakeSessionAdapter();
|
||||
_sessionFactory.EnqueueSession(session);
|
||||
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
||||
await _service.SubscribeAsync(new NodeId("tag", 2), ct: TestContext.Current.CancellationToken);
|
||||
await _service.SubscribeAlarmsAsync(ct: TestContext.Current.CancellationToken);
|
||||
var data = session.CreatedSubscriptions[0];
|
||||
var alarm = session.CreatedSubscriptions[1];
|
||||
var queuedData = data.CaptureDataCallback(data.ActiveHandles.Single());
|
||||
var queuedAlarm = alarm.CaptureEventCallback(alarm.ActiveHandles.Single());
|
||||
var dataCount = 0;
|
||||
var alarmCount = 0;
|
||||
_service.DataChanged += (_, _) => dataCount++;
|
||||
_service.AlarmEvent += (_, _) => alarmCount++;
|
||||
queuedData("ns=2;s=tag", new DataValue(Variant.From(1)));
|
||||
queuedAlarm(new EventFieldList { EventFields = [Variant.From(ByteString.Empty)] });
|
||||
dataCount.ShouldBe(1);
|
||||
alarmCount.ShouldBe(1);
|
||||
_service.Dispose();
|
||||
queuedData("ns=2;s=tag", new DataValue(Variant.From(2)));
|
||||
queuedAlarm(new EventFieldList { EventFields = [Variant.From(ByteString.Empty)] });
|
||||
dataCount.ShouldBe(1);
|
||||
alarmCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueuedCallbacks_FromPreviousSession_AreIgnoredAfterFailover()
|
||||
{
|
||||
var first = new FakeSessionAdapter();
|
||||
var second = new FakeSessionAdapter();
|
||||
_sessionFactory.EnqueueSession(first);
|
||||
_sessionFactory.EnqueueSession(second);
|
||||
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
||||
await _service.SubscribeAsync(new NodeId("tag", 2), ct: TestContext.Current.CancellationToken);
|
||||
var firstSubscription = first.CreatedSubscriptions.Single();
|
||||
var queued = firstSubscription.CaptureDataCallback(firstSubscription.ActiveHandles.Single());
|
||||
var received = new List<DataValue>();
|
||||
_service.DataChanged += (_, e) => received.Add(e.Value);
|
||||
first.SimulateKeepAlive(false);
|
||||
await _service.LastFailover!.WaitAsync(TestContext.Current.CancellationToken);
|
||||
queued("ns=2;s=tag", new DataValue(Variant.From(1)));
|
||||
received.ShouldBeEmpty();
|
||||
var current = second.CreatedSubscriptions.Single();
|
||||
current.SimulateDataChange(current.ActiveHandles.Single(), new DataValue(Variant.From(2)));
|
||||
received.ShouldHaveSingleItem().WrappedValue.ShouldBe(Variant.From(2));
|
||||
}
|
||||
|
||||
// --- Dispose tests ---
|
||||
|
||||
/// <summary>
|
||||
@@ -1720,7 +1598,7 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
await Should.ThrowAsync<ObjectDisposedException>(() =>
|
||||
_service.ConnectAsync(ValidSettings()));
|
||||
await Should.ThrowAsync<ObjectDisposedException>(() =>
|
||||
_service.ReadValueAsync(NodeId.Parse("ns=2;s=X")));
|
||||
_service.ReadValueAsync(new NodeId("ns=2;s=X")));
|
||||
}
|
||||
|
||||
// --- Factory tests ---
|
||||
@@ -1765,7 +1643,7 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
||||
|
||||
// Subscribe data (uses dataFakeSub from session.NextSubscription).
|
||||
await _service.SubscribeAsync(NodeId.Parse("ns=2;s=Node1"), ct: TestContext.Current.CancellationToken);
|
||||
await _service.SubscribeAsync(new NodeId("ns=2;s=Node1"), ct: TestContext.Current.CancellationToken);
|
||||
|
||||
// Now set NextSubscription on the session so SubscribeAlarmsAsync gets the alarm fake.
|
||||
session.NextSubscription = alarmFakeSub;
|
||||
|
||||
@@ -191,7 +191,7 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
|
||||
LastBrowseParentNodeId = parentNodeId;
|
||||
if (BrowseException != null) throw BrowseException;
|
||||
|
||||
if (parentNodeId != null && BrowseResultsByParent.TryGetValue(parentNodeId.Value.ToString(), out var perParent))
|
||||
if (parentNodeId != null && BrowseResultsByParent.TryGetValue(parentNodeId.ToString(), out var perParent))
|
||||
return Task.FromResult(perParent);
|
||||
|
||||
return Task.FromResult(BrowseResults);
|
||||
|
||||
@@ -23,26 +23,6 @@ public class ReadWriteViewModelTests
|
||||
_vm = new ReadWriteViewModel(_service, dispatcher);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadCommand_FormatsSdkArraysByteStringsAndEmptyText()
|
||||
{
|
||||
_vm.IsConnected = true;
|
||||
_vm.SelectedNodeId = "ns=2;s=SomeNode";
|
||||
foreach (var (value, expected) in new (Variant Value, string Expected)[]
|
||||
{
|
||||
(Variant.From(new int[] { 1, 2 }.ToArrayOf()), "[1,2]"),
|
||||
(Variant.From(ArrayOf<int>.Empty), "[]"),
|
||||
(Variant.From(ByteString.From(new byte[] { 3, 4 })), "[3,4]"),
|
||||
(Variant.From(LocalizedText.Null), ""),
|
||||
(Variant.Null, "(null)")
|
||||
})
|
||||
{
|
||||
_service.ReadResult = new DataValue(value, StatusCodes.Good);
|
||||
await _vm.ReadCommand.ExecuteAsync(null);
|
||||
_vm.CurrentValue.ShouldBe(expected);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the read command cannot execute when disconnected.</summary>
|
||||
[Fact]
|
||||
public void ReadCommand_CannotExecute_WhenDisconnected()
|
||||
|
||||
+10
-10
@@ -20,23 +20,23 @@ public sealed class OpcUaClientBrowseSessionAttributesTests
|
||||
private static Mock<ISession> SessionReturning(NodeClass nodeClass, NodeId dataType, int valueRank, byte userAccess)
|
||||
{
|
||||
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.Setup(s => s.ReadAsync(
|
||||
It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
|
||||
It.IsAny<ReadValueIdCollection>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((RequestHeader _, double _, TimestampsToReturn _, ReadValueIdCollection nodes, CancellationToken _) =>
|
||||
It.IsAny<ArrayOf<ReadValueId>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((RequestHeader _, double _, TimestampsToReturn _, ArrayOf<ReadValueId> nodes, CancellationToken _) =>
|
||||
new ReadResponse
|
||||
{
|
||||
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.DisplayName => new DataValue(new Variant(new LocalizedText("Speed"))),
|
||||
Attributes.DataType => new DataValue(new Variant(dataType)),
|
||||
Attributes.ValueRank => new DataValue(new Variant(valueRank)),
|
||||
Attributes.UserAccessLevel => new DataValue(new Variant(userAccess)),
|
||||
_ => new DataValue(StatusCodes.BadAttributeIdInvalid),
|
||||
_ => DataValue.FromStatusCode(StatusCodes.BadAttributeIdInvalid),
|
||||
})],
|
||||
DiagnosticInfos = [],
|
||||
});
|
||||
@@ -101,19 +101,19 @@ public sealed class OpcUaClientBrowseSessionAttributesTests
|
||||
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 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]
|
||||
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 credential = new TrackingCredential();
|
||||
var credential = new TrackingApplication();
|
||||
var session = new OpcUaClientBrowseSession(
|
||||
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)
|
||||
{
|
||||
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());
|
||||
|
||||
var firstRef = new ReferenceDescription
|
||||
@@ -32,44 +32,44 @@ public sealed class OpcUaClientBrowseSessionPaginationTests
|
||||
};
|
||||
mock.Setup(s => s.BrowseAsync(
|
||||
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
|
||||
{
|
||||
ResponseHeader = new ResponseHeader(),
|
||||
Results = new BrowseResultCollection
|
||||
Results = new List<BrowseResult>
|
||||
{
|
||||
new BrowseResult
|
||||
{
|
||||
ContinuationPoint = FakeContinuationPoint,
|
||||
References = new ReferenceDescriptionCollection { firstRef },
|
||||
ContinuationPoint = FakeContinuationPoint.ToByteString(),
|
||||
References = new List<ReferenceDescription> { firstRef },
|
||||
},
|
||||
},
|
||||
DiagnosticInfos = new DiagnosticInfoCollection(),
|
||||
DiagnosticInfos = new List<DiagnosticInfo>(),
|
||||
});
|
||||
|
||||
var emptyPage = new BrowseNextResponse
|
||||
{
|
||||
ResponseHeader = new ResponseHeader(),
|
||||
Results = new BrowseResultCollection
|
||||
Results = new List<BrowseResult>
|
||||
{
|
||||
new BrowseResult
|
||||
{
|
||||
ContinuationPoint = Array.Empty<byte>(),
|
||||
References = new ReferenceDescriptionCollection(),
|
||||
ContinuationPoint = ByteString.Empty,
|
||||
References = new List<ReferenceDescription>(),
|
||||
},
|
||||
},
|
||||
DiagnosticInfos = new DiagnosticInfoCollection(),
|
||||
DiagnosticInfos = new List<DiagnosticInfo>(),
|
||||
};
|
||||
|
||||
var pageSetup = mock.Setup(s => s.BrowseNextAsync(
|
||||
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"));
|
||||
else pageSetup.ReturnsAsync(emptyPage);
|
||||
|
||||
mock.Setup(s => s.BrowseNextAsync(
|
||||
It.IsAny<RequestHeader>(), It.Is<bool>(r => r),
|
||||
It.IsAny<ByteStringCollection>(), It.IsAny<CancellationToken>()))
|
||||
It.IsAny<ArrayOf<ByteString>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(emptyPage);
|
||||
return mock;
|
||||
}
|
||||
@@ -89,7 +89,7 @@ public sealed class OpcUaClientBrowseSessionPaginationTests
|
||||
|
||||
mock.Verify(s => s.BrowseNextAsync(
|
||||
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>
|
||||
@@ -104,9 +104,9 @@ public sealed class OpcUaClientBrowseSessionPaginationTests
|
||||
nodes.Count.ShouldBe(1);
|
||||
mock.Verify(s => s.BrowseNextAsync(
|
||||
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(
|
||||
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.
|
||||
var mockSession = new Mock<ISession>(MockBehavior.Loose);
|
||||
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 sut = new OpcUaClientBrowseSession(mockSession.Object, nsMap, ObjectIds.ObjectsFolder);
|
||||
|
||||
|
||||
+28
-8
@@ -21,7 +21,7 @@ public sealed class OpcUaClientAcknowledgeFailureTests
|
||||
private static Mock<ISession> NewSession()
|
||||
{
|
||||
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());
|
||||
return mock;
|
||||
}
|
||||
@@ -34,7 +34,7 @@ public sealed class OpcUaClientAcknowledgeFailureTests
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-o8");
|
||||
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));
|
||||
drv.SetSessionForTest(session.Object);
|
||||
|
||||
@@ -50,7 +50,7 @@ public sealed class OpcUaClientAcknowledgeFailureTests
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-o8-ok");
|
||||
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
|
||||
{
|
||||
ResponseHeader = new ResponseHeader(),
|
||||
@@ -61,10 +61,30 @@ public sealed class OpcUaClientAcknowledgeFailureTests
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
[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>
|
||||
[Fact]
|
||||
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);
|
||||
|
||||
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);
|
||||
lock (logger.Entries)
|
||||
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;
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-n1");
|
||||
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
|
||||
{
|
||||
ResponseHeader = new ResponseHeader(),
|
||||
@@ -154,7 +174,7 @@ public sealed class OpcUaClientAcknowledgeFailureTests
|
||||
await invoker.ExecuteAsync(DriverCapability.AlarmAcknowledge, "host",
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -166,7 +186,7 @@ public sealed class OpcUaClientAcknowledgeFailureTests
|
||||
var stamp = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
drv.SetHealthForTest(new DriverHealth(DriverState.Healthy, stamp, null));
|
||||
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));
|
||||
drv.SetSessionForTest(session.Object);
|
||||
|
||||
|
||||
@@ -76,9 +76,9 @@ public sealed class OpcUaClientAlarmTests
|
||||
|
||||
// ---- #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(NodeId.Parse(sourceNode)), // SourceNode
|
||||
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,
|
||||
NodeId? branchId = null, NodeId? eventType = null)
|
||||
{
|
||||
var f = EventFields("ns=3;s=Alarms.Trip1", "ns=3;s=Alarms.Trip1.Cond");
|
||||
f[0] = new Variant(eventId ?? new byte[] { 1 });
|
||||
if (eventType is not null) f[1] = new Variant(eventType);
|
||||
f[0] = Variant.From(new ByteString((eventId ?? new byte[] { 1 }).AsMemory()));
|
||||
if (eventType is not null) f[1] = Variant.From(eventType.Value);
|
||||
f[3] = new Variant(new LocalizedText(message));
|
||||
f[4] = new Variant(severity);
|
||||
f.Add(new Variant(active));
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -382,7 +382,7 @@ public sealed class OpcUaClientAlarmTests
|
||||
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");
|
||||
f[1] = new Variant(type);
|
||||
|
||||
@@ -65,4 +65,49 @@ public sealed class OpcUaClientCertAuthTests
|
||||
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()
|
||||
{
|
||||
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());
|
||||
return mock;
|
||||
}
|
||||
@@ -77,18 +77,18 @@ public sealed class OpcUaClientConnectionLossSignalTests
|
||||
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>
|
||||
private static void SetupRead(Mock<ISession> session, Func<DateTime> at, params object[] values) =>
|
||||
session.Setup(s => s.ReadAsync(
|
||||
It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
|
||||
It.IsAny<ReadValueIdCollection>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((RequestHeader _, double _, TimestampsToReturn _, ReadValueIdCollection nodes, CancellationToken _) =>
|
||||
It.IsAny<ArrayOf<ReadValueId>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((RequestHeader _, double _, TimestampsToReturn _, ArrayOf<ReadValueId> nodes, CancellationToken _) =>
|
||||
new ReadResponse
|
||||
{
|
||||
ResponseHeader = new ResponseHeader(),
|
||||
Results = [.. nodes.Select((_, i) => Good(values[i], at()))],
|
||||
Results = [.. nodes.ToList().Select((_, i) => Good(values[i], at()))],
|
||||
DiagnosticInfos = [],
|
||||
});
|
||||
|
||||
@@ -96,18 +96,18 @@ public sealed class OpcUaClientConnectionLossSignalTests
|
||||
|
||||
public static TheoryData<uint> DefinitiveStatuses =>
|
||||
[
|
||||
StatusCodes.BadSessionIdInvalid,
|
||||
StatusCodes.BadSessionClosed,
|
||||
StatusCodes.BadSessionNotActivated,
|
||||
StatusCodes.BadServerHalted,
|
||||
StatusCodes.BadSessionIdInvalid.Code,
|
||||
StatusCodes.BadSessionClosed.Code,
|
||||
StatusCodes.BadSessionNotActivated.Code,
|
||||
StatusCodes.BadServerHalted.Code,
|
||||
];
|
||||
|
||||
public static TheoryData<uint> AmbiguousStatuses =>
|
||||
[
|
||||
StatusCodes.BadNoCommunication,
|
||||
StatusCodes.BadNotConnected,
|
||||
StatusCodes.BadRequestTimeout,
|
||||
StatusCodes.BadTimeout,
|
||||
StatusCodes.BadNoCommunication.Code,
|
||||
StatusCodes.BadNotConnected.Code,
|
||||
StatusCodes.BadRequestTimeout.Code,
|
||||
StatusCodes.BadTimeout.Code,
|
||||
];
|
||||
|
||||
[Theory]
|
||||
@@ -127,7 +127,7 @@ public sealed class OpcUaClientConnectionLossSignalTests
|
||||
drv.RaiseKeepAliveForTest(StatusCodes.BadSessionIdInvalid);
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -404,7 +404,7 @@ public sealed class OpcUaClientConnectionLossSignalTests
|
||||
var reads = 0;
|
||||
session.Setup(s => s.ReadAsync(
|
||||
It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
|
||||
It.IsAny<ReadValueIdCollection>(), It.IsAny<CancellationToken>()))
|
||||
It.IsAny<ArrayOf<ReadValueId>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(() =>
|
||||
{
|
||||
var trailing = Interlocked.Increment(ref reads) > 1;
|
||||
@@ -447,7 +447,7 @@ public sealed class OpcUaClientConnectionLossSignalTests
|
||||
var reads = 0;
|
||||
session.Setup(s => s.ReadAsync(
|
||||
It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
|
||||
It.IsAny<ReadValueIdCollection>(), It.IsAny<CancellationToken>()))
|
||||
It.IsAny<ArrayOf<ReadValueId>>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(async () =>
|
||||
{
|
||||
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(
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -506,7 +506,7 @@ public sealed class OpcUaClientConnectionLossSignalTests
|
||||
var reads = 0;
|
||||
session.Setup(s => s.ReadAsync(
|
||||
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
|
||||
{
|
||||
ResponseHeader = new ResponseHeader(),
|
||||
@@ -559,7 +559,7 @@ public sealed class OpcUaClientConnectionLossSignalTests
|
||||
var handle = drv.AddSubscriptionForTest([SpeedRef]);
|
||||
var t0 = clock.Now.UtcDateTime;
|
||||
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));
|
||||
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;
|
||||
session.Setup(s => s.ReadAsync(
|
||||
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
|
||||
{
|
||||
ResponseHeader = new ResponseHeader(),
|
||||
@@ -677,12 +677,12 @@ public sealed class OpcUaClientConnectionLossSignalTests
|
||||
drv.PostRecoveryReconcileDelay = () => TimeSpan.Zero;
|
||||
var handle = drv.AddSubscriptionForTest([SpeedRef]);
|
||||
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));
|
||||
var reads = 0;
|
||||
session.Setup(s => s.ReadAsync(
|
||||
It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
|
||||
It.IsAny<ReadValueIdCollection>(), It.IsAny<CancellationToken>()))
|
||||
It.IsAny<ArrayOf<ReadValueId>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(() =>
|
||||
{
|
||||
DataValue result;
|
||||
|
||||
+21
-21
@@ -37,7 +37,7 @@ public sealed class OpcUaClientContinuationPointReleaseTests
|
||||
private static Mock<ISession> BuildCancellingSessionMock()
|
||||
{
|
||||
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());
|
||||
|
||||
// BrowseAsync — return one Object child plus a non-empty continuation point so
|
||||
@@ -52,20 +52,20 @@ public sealed class OpcUaClientContinuationPointReleaseTests
|
||||
};
|
||||
var browseResult = new BrowseResult
|
||||
{
|
||||
ContinuationPoint = FakeContinuationPoint,
|
||||
References = new ReferenceDescriptionCollection { firstRef },
|
||||
ContinuationPoint = FakeContinuationPoint.ToByteString(),
|
||||
References = new List<ReferenceDescription> { firstRef },
|
||||
};
|
||||
var browseResp = new BrowseResponse
|
||||
{
|
||||
ResponseHeader = new ResponseHeader(),
|
||||
Results = new BrowseResultCollection { browseResult },
|
||||
DiagnosticInfos = new DiagnosticInfoCollection(),
|
||||
Results = new List<BrowseResult> { browseResult },
|
||||
DiagnosticInfos = new List<DiagnosticInfo>(),
|
||||
};
|
||||
mock.Setup(s => s.BrowseAsync(
|
||||
It.IsAny<RequestHeader>(),
|
||||
It.IsAny<ViewDescription>(),
|
||||
It.IsAny<uint>(),
|
||||
It.IsAny<BrowseDescriptionCollection>(),
|
||||
It.IsAny<ArrayOf<BrowseDescription>>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(browseResp);
|
||||
|
||||
@@ -73,7 +73,7 @@ public sealed class OpcUaClientContinuationPointReleaseTests
|
||||
mock.Setup(s => s.BrowseNextAsync(
|
||||
It.IsAny<RequestHeader>(),
|
||||
It.Is<bool>(release => !release),
|
||||
It.IsAny<ByteStringCollection>(),
|
||||
It.IsAny<ArrayOf<ByteString>>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.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.
|
||||
var emptyReleaseResult = new BrowseResult
|
||||
{
|
||||
ContinuationPoint = Array.Empty<byte>(),
|
||||
References = new ReferenceDescriptionCollection(),
|
||||
ContinuationPoint = ByteString.Empty,
|
||||
References = new List<ReferenceDescription>(),
|
||||
};
|
||||
var releaseResp = new BrowseNextResponse
|
||||
{
|
||||
ResponseHeader = new ResponseHeader(),
|
||||
Results = new BrowseResultCollection { emptyReleaseResult },
|
||||
DiagnosticInfos = new DiagnosticInfoCollection(),
|
||||
Results = new List<BrowseResult> { emptyReleaseResult },
|
||||
DiagnosticInfos = new List<DiagnosticInfo>(),
|
||||
};
|
||||
mock.Setup(s => s.BrowseNextAsync(
|
||||
It.IsAny<RequestHeader>(),
|
||||
It.Is<bool>(release => release),
|
||||
It.IsAny<ByteStringCollection>(),
|
||||
It.IsAny<ArrayOf<ByteString>>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(releaseResp);
|
||||
|
||||
@@ -127,7 +127,7 @@ public sealed class OpcUaClientContinuationPointReleaseTests
|
||||
sessionMock.Verify(s => s.BrowseNextAsync(
|
||||
It.IsAny<RequestHeader>(),
|
||||
It.Is<bool>(release => release),
|
||||
It.IsAny<ByteStringCollection>(),
|
||||
It.IsAny<ArrayOf<ByteString>>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once,
|
||||
"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");
|
||||
|
||||
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());
|
||||
|
||||
// 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
|
||||
{
|
||||
ContinuationPoint = FakeContinuationPoint,
|
||||
References = new ReferenceDescriptionCollection { firstRef },
|
||||
ContinuationPoint = FakeContinuationPoint.ToByteString(),
|
||||
References = new List<ReferenceDescription> { firstRef },
|
||||
};
|
||||
mock.Setup(s => s.BrowseAsync(
|
||||
It.IsAny<RequestHeader>(),
|
||||
It.IsAny<ViewDescription>(),
|
||||
It.IsAny<uint>(),
|
||||
It.IsAny<BrowseDescriptionCollection>(),
|
||||
It.IsAny<ArrayOf<BrowseDescription>>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new BrowseResponse
|
||||
{
|
||||
ResponseHeader = new ResponseHeader(),
|
||||
Results = new BrowseResultCollection { browseResult },
|
||||
DiagnosticInfos = new DiagnosticInfoCollection(),
|
||||
Results = new List<BrowseResult> { browseResult },
|
||||
DiagnosticInfos = new List<DiagnosticInfo>(),
|
||||
});
|
||||
|
||||
// BrowseNextAsync — throw a non-cancellation transport error.
|
||||
mock.Setup(s => s.BrowseNextAsync(
|
||||
It.IsAny<RequestHeader>(),
|
||||
It.Is<bool>(release => !release),
|
||||
It.IsAny<ByteStringCollection>(),
|
||||
It.IsAny<ArrayOf<ByteString>>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new ServiceResultException(StatusCodes.BadCommunicationError));
|
||||
|
||||
@@ -195,7 +195,7 @@ public sealed class OpcUaClientContinuationPointReleaseTests
|
||||
mock.Verify(s => s.BrowseNextAsync(
|
||||
It.IsAny<RequestHeader>(),
|
||||
It.Is<bool>(release => release),
|
||||
It.IsAny<ByteStringCollection>(),
|
||||
It.IsAny<ArrayOf<ByteString>>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Never,
|
||||
"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 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.Setup(s => s.ReadAsync(
|
||||
It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
|
||||
It.IsAny<ReadValueIdCollection>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((RequestHeader _, double _, TimestampsToReturn _, ReadValueIdCollection nodes, CancellationToken _) =>
|
||||
It.IsAny<ArrayOf<ReadValueId>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((RequestHeader _, double _, TimestampsToReturn _, ArrayOf<ReadValueId> nodes, CancellationToken _) =>
|
||||
new ReadResponse
|
||||
{
|
||||
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 = [],
|
||||
});
|
||||
drv.SetSessionForTest(session.Object);
|
||||
@@ -60,15 +60,15 @@ public sealed class OpcUaClientGateSplitTests
|
||||
using var _ = drv;
|
||||
var writeOnWire = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var releaseWrite = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
session.Setup(s => s.WriteAsync(It.IsAny<RequestHeader>(), It.IsAny<WriteValueCollection>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(async (RequestHeader _, WriteValueCollection nodes, CancellationToken _) =>
|
||||
session.Setup(s => s.WriteAsync(It.IsAny<RequestHeader>(), It.IsAny<ArrayOf<WriteValue>>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(async (RequestHeader _, ArrayOf<WriteValue> nodes, CancellationToken _) =>
|
||||
{
|
||||
writeOnWire.TrySetResult();
|
||||
await releaseWrite.Task;
|
||||
return new WriteResponse
|
||||
{
|
||||
ResponseHeader = new ResponseHeader(),
|
||||
Results = [.. nodes.Select(_ => (StatusCode)StatusCodes.Good)],
|
||||
Results = [.. nodes.ToList().Select(_ => (StatusCode)StatusCodes.Good)],
|
||||
DiagnosticInfos = [],
|
||||
};
|
||||
});
|
||||
@@ -111,8 +111,8 @@ public sealed class OpcUaClientGateSplitTests
|
||||
var releaseBrowse = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
session.Setup(s => s.BrowseAsync(
|
||||
It.IsAny<RequestHeader>(), It.IsAny<ViewDescription>(), It.IsAny<uint>(),
|
||||
It.IsAny<BrowseDescriptionCollection>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(async (RequestHeader _, ViewDescription _, uint _, BrowseDescriptionCollection _, CancellationToken _) =>
|
||||
It.IsAny<ArrayOf<BrowseDescription>>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(async (RequestHeader _, ViewDescription _, uint _, ArrayOf<BrowseDescription> _, CancellationToken _) =>
|
||||
{
|
||||
browseOnWire.TrySetResult();
|
||||
await releaseBrowse.Task;
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ public sealed class OpcUaClientIdlePublishErrorTests
|
||||
{
|
||||
var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions { RawTags = Tags }, instance);
|
||||
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());
|
||||
drv.SetSessionForTest(session.Object);
|
||||
drv.SetNamespaceMapForTest(NamespaceMap.FromTable(new NamespaceTable(), Tags));
|
||||
|
||||
+25
-5
@@ -29,15 +29,15 @@ public sealed class OpcUaClientLifecycleGenerationTests
|
||||
private static Mock<ISession> NewSession()
|
||||
{
|
||||
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());
|
||||
return mock;
|
||||
}
|
||||
|
||||
private static ReadResponse GoodRead(ReadValueIdCollection nodes) => new()
|
||||
private static ReadResponse GoodRead(ArrayOf<ReadValueId> nodes) => new()
|
||||
{
|
||||
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 = [],
|
||||
};
|
||||
|
||||
@@ -118,6 +118,26 @@ public sealed class OpcUaClientLifecycleGenerationTests
|
||||
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>
|
||||
[Fact]
|
||||
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>();
|
||||
session.Setup(s => s.ReadAsync(
|
||||
It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
|
||||
It.IsAny<ReadValueIdCollection>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(async (RequestHeader _, double _, TimestampsToReturn _, ReadValueIdCollection nodes, CancellationToken _) =>
|
||||
It.IsAny<ArrayOf<ReadValueId>>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(async (RequestHeader _, double _, TimestampsToReturn _, ArrayOf<ReadValueId> nodes, CancellationToken _) =>
|
||||
{
|
||||
var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
respond.Enqueue(gate);
|
||||
|
||||
@@ -74,6 +74,35 @@ public sealed class OpcUaClientNamespaceTests
|
||||
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 ----
|
||||
|
||||
/// <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);
|
||||
mock.SetupGet(s => s.MessageContext)
|
||||
.Returns(new ServiceMessageContext(SdkTelemetry.Context));
|
||||
.Returns(ServiceMessageContext.Create(SdkTelemetry.Context));
|
||||
mock.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
|
||||
return mock;
|
||||
}
|
||||
@@ -60,17 +60,17 @@ public sealed class OpcUaClientSessionLossTests
|
||||
}
|
||||
|
||||
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) =>
|
||||
session.Setup(s => s.ReadAsync(
|
||||
It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
|
||||
It.IsAny<ReadValueIdCollection>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((RequestHeader _, double _, TimestampsToReturn _, ReadValueIdCollection nodes, CancellationToken _) =>
|
||||
It.IsAny<ArrayOf<ReadValueId>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((RequestHeader _, double _, TimestampsToReturn _, ArrayOf<ReadValueId> nodes, CancellationToken _) =>
|
||||
new ReadResponse
|
||||
{
|
||||
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 = [],
|
||||
});
|
||||
|
||||
@@ -500,11 +500,11 @@ public sealed class OpcUaClientSessionLossTests
|
||||
private static void SetupReadAt(Mock<ISession> session, object value, DateTime sourceTime) =>
|
||||
session.Setup(s => s.ReadAsync(
|
||||
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
|
||||
{
|
||||
ResponseHeader = new ResponseHeader(),
|
||||
Results = [new DataValue(new Variant(value), StatusCodes.Good, sourceTime, sourceTime)],
|
||||
Results = [new DataValue(VariantHelper.CastFrom(value), StatusCodes.Good, sourceTime, sourceTime)],
|
||||
DiagnosticInfos = [],
|
||||
});
|
||||
|
||||
@@ -702,7 +702,7 @@ public sealed class OpcUaClientSessionLossTests
|
||||
var readAt = t0.AddSeconds(10);
|
||||
session.Setup(s => s.ReadAsync(
|
||||
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
|
||||
{
|
||||
ResponseHeader = new ResponseHeader(),
|
||||
|
||||
+2
-2
@@ -42,7 +42,7 @@ public sealed class OpcUaClientStaleNoSubscriptionTests
|
||||
{
|
||||
var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions { RawTags = Tags }, "opcua-763");
|
||||
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());
|
||||
var clock = new ManualClock(new DateTimeOffset(2026, 11, 9, 12, 0, 0, TimeSpan.Zero));
|
||||
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)];
|
||||
var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions { RawTags = tags }, "opcua-764");
|
||||
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());
|
||||
var clock = new ManualClock(new DateTimeOffset(2026, 11, 10, 12, 0, 0, TimeSpan.Zero));
|
||||
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
|
||||
// gate), so signalling here proves the pre-gate capture path has run.
|
||||
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());
|
||||
mock.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
|
||||
// Acknowledge issues its wire call via CallAsync; stub it so AcknowledgeAsync can run to
|
||||
// completion against whichever session it ends up binding to.
|
||||
mock.Setup(s => s.CallAsync(
|
||||
It.IsAny<RequestHeader>(),
|
||||
It.IsAny<CallMethodRequestCollection>(),
|
||||
It.IsAny<ArrayOf<CallMethodRequest>>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new CallResponse
|
||||
{
|
||||
ResponseHeader = new ResponseHeader(),
|
||||
Results = new CallMethodResultCollection(),
|
||||
DiagnosticInfos = new DiagnosticInfoCollection(),
|
||||
Results = new List<CallMethodResult>(),
|
||||
DiagnosticInfos = new List<DiagnosticInfo>(),
|
||||
});
|
||||
return mock;
|
||||
}
|
||||
@@ -67,10 +67,10 @@ public sealed class OpcUaClientStaleSessionRaceTests
|
||||
private static void VerifyAckWireHitNewSessionNotOld(Mock<ISession> newSession, Mock<ISession> oldSession)
|
||||
{
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ public sealed class OpcUaClientSubscribeCleanupTests
|
||||
{
|
||||
var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions { RawTags = Tags }, "opcua-o1");
|
||||
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());
|
||||
drv.SetSessionForTest(session.Object);
|
||||
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 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());
|
||||
drv.SetSessionForTest(session.Object);
|
||||
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"));
|
||||
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
|
||||
{
|
||||
ResponseHeader = new ResponseHeader(),
|
||||
@@ -54,7 +54,7 @@ public sealed class OpcUaClientWriteHealthTests
|
||||
|
||||
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();
|
||||
h.State.ShouldBe(DriverState.Healthy);
|
||||
h.LastSuccessfulRead.ShouldNotBeNull().ShouldBeGreaterThan(Stale);
|
||||
@@ -67,7 +67,7 @@ public sealed class OpcUaClientWriteHealthTests
|
||||
{
|
||||
var (drv, session) = NewConnectedDriver(new DriverHealth(DriverState.Healthy, Stale, null));
|
||||
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));
|
||||
|
||||
var results = await drv.WriteAsync(Write(), TestContext.Current.CancellationToken);
|
||||
@@ -89,8 +89,8 @@ public sealed class OpcUaClientWriteHealthTests
|
||||
var (drv, session) = NewConnectedDriver(healthy);
|
||||
using var _ = drv;
|
||||
using var cts = new CancellationTokenSource();
|
||||
session.Setup(s => s.WriteAsync(It.IsAny<RequestHeader>(), It.IsAny<WriteValueCollection>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(async (RequestHeader _, WriteValueCollection _, CancellationToken ct) =>
|
||||
session.Setup(s => s.WriteAsync(It.IsAny<RequestHeader>(), It.IsAny<ArrayOf<WriteValue>>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(async (RequestHeader _, ArrayOf<WriteValue> _, CancellationToken ct) =>
|
||||
{
|
||||
cts.Cancel();
|
||||
await Task.Delay(Timeout.Infinite, ct);
|
||||
@@ -128,7 +128,7 @@ public sealed class OpcUaClientWriteHealthTests
|
||||
{
|
||||
var (drv, session) = NewConnectedDriver(new DriverHealth(DriverState.Healthy, Stale, null));
|
||||
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"));
|
||||
|
||||
var results = await drv.WriteAsync(Write(), TestContext.Current.CancellationToken);
|
||||
@@ -149,7 +149,7 @@ public sealed class OpcUaClientWriteHealthTests
|
||||
using var _ = drv;
|
||||
drv.RaiseKeepAliveForTest(StatusCodes.BadNoCommunication);
|
||||
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 = [] });
|
||||
|
||||
await drv.WriteAsync(Write(), TestContext.Current.CancellationToken);
|
||||
|
||||
Reference in New Issue
Block a user