Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ca06e1b87 | |||
| 0ecea3c6be |
@@ -1,3 +1,4 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using CliFx.Attributes;
|
using CliFx.Attributes;
|
||||||
using CliFx.Exceptions;
|
using CliFx.Exceptions;
|
||||||
@@ -210,7 +211,7 @@ public class HistoryReadCommand : CommandBase
|
|||||||
: StatusCode.IsBad(dv.StatusCode) ? "Bad"
|
: StatusCode.IsBad(dv.StatusCode) ? "Bad"
|
||||||
: "Uncertain";
|
: "Uncertain";
|
||||||
await console.Output.WriteLineAsync(
|
await console.Output.WriteLineAsync(
|
||||||
$"{dv.SourceTimestamp.ToString("O"),-35} {dv.Value?.ToString() ?? "(none)",-15} {status,-10} " +
|
$"{dv.SourceTimestamp.ToString("O"),-35} {ValueConverter.ToClrObject(dv.WrappedValue)?.ToString() ?? "(none)",-15} {status,-10} " +
|
||||||
$"{e.UpdateType,-8} {e.UserName ?? "",-20} {e.ModificationTime:O}");
|
$"{e.UpdateType,-8} {e.UserName ?? "",-20} {e.ModificationTime:O}");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,7 +245,7 @@ public class HistoryReadCommand : CommandBase
|
|||||||
: StatusCode.IsBad(dv.StatusCode) ? "Bad"
|
: StatusCode.IsBad(dv.StatusCode) ? "Bad"
|
||||||
: "Uncertain";
|
: "Uncertain";
|
||||||
await console.Output.WriteLineAsync(
|
await console.Output.WriteLineAsync(
|
||||||
$"{dv.SourceTimestamp.ToString("O"),-35} {dv.Value,-15} {status}");
|
$"{dv.SourceTimestamp.ToString("O"),-35} {ValueConverter.ToClrObject(dv.WrappedValue),-15} {status}");
|
||||||
}
|
}
|
||||||
|
|
||||||
await console.Output.WriteLineAsync();
|
await console.Output.WriteLineAsync();
|
||||||
@@ -276,23 +277,25 @@ public class HistoryReadCommand : CommandBase
|
|||||||
internal static EventFilter BuildEventFilter(IReadOnlyList<string> fields, NodeId? ofType)
|
internal static EventFilter BuildEventFilter(IReadOnlyList<string> fields, NodeId? ofType)
|
||||||
{
|
{
|
||||||
var filter = new EventFilter();
|
var filter = new EventFilter();
|
||||||
|
var clauses = new List<SimpleAttributeOperand>();
|
||||||
foreach (var field in fields)
|
foreach (var field in fields)
|
||||||
{
|
{
|
||||||
var type = string.Equals(field, "ConditionName", StringComparison.Ordinal)
|
var type = string.Equals(field, "ConditionName", StringComparison.Ordinal)
|
||||||
? ObjectTypeIds.ConditionType
|
? ObjectTypeIds.ConditionType
|
||||||
: ObjectTypeIds.BaseEventType;
|
: ObjectTypeIds.BaseEventType;
|
||||||
filter.SelectClauses.Add(new SimpleAttributeOperand(type, new QualifiedName(field)) { AttributeId = Attributes.Value });
|
clauses.Add(new SimpleAttributeOperand(type, new QualifiedName(field)) { AttributeId = Attributes.Value });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
filter.SelectClauses = clauses.ToArrayOf();
|
||||||
if (ofType is not null)
|
if (ofType is not null)
|
||||||
filter.WhereClause.Push(FilterOperator.OfType, new LiteralOperand(new Variant(ofType)));
|
filter.WhereClause.Push(FilterOperator.OfType, Variant.From(ofType.Value));
|
||||||
return filter;
|
return filter;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Formats one projected event field for the console: byte strings as hex, times as ISO 8601, null as <c>-</c>.</summary>
|
/// <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>
|
/// <param name="field">The field value.</param>
|
||||||
/// <returns>Its display text.</returns>
|
/// <returns>Its display text.</returns>
|
||||||
private static string FormatEventField(Variant field) => field.Value switch
|
private static string FormatEventField(Variant field) => ValueConverter.ToClrObject(field) switch
|
||||||
{
|
{
|
||||||
null => "-",
|
null => "-",
|
||||||
byte[] bytes => Convert.ToHexString(bytes),
|
byte[] bytes => Convert.ToHexString(bytes),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using CliFx.Attributes;
|
using CliFx.Attributes;
|
||||||
using CliFx.Exceptions;
|
using CliFx.Exceptions;
|
||||||
@@ -89,11 +90,11 @@ public class HistoryUpdateCommand : CommandBase
|
|||||||
throw new CommandException(
|
throw new CommandException(
|
||||||
$"--value and --time must be supplied in pairs ({values.Count} value(s), {times.Count} time(s)).");
|
$"--value and --time must be supplied in pairs ({values.Count} value(s), {times.Count} time(s)).");
|
||||||
|
|
||||||
var updates = new DataValueCollection();
|
var updates = new List<DataValue>();
|
||||||
for (var i = 0; i < values.Count; i++)
|
for (var i = 0; i < values.Count; i++)
|
||||||
{
|
{
|
||||||
var ts = ParseTime(times[i], "--time");
|
var ts = ParseTime(times[i], "--time");
|
||||||
updates.Add(new DataValue(new Variant(ParseValue(values[i], Type)), StatusCodes.Good, ts, ts));
|
updates.Add(new DataValue(ValueConverter.ToVariant(ParseValue(values[i], Type)), StatusCodes.Good, ts, ts));
|
||||||
}
|
}
|
||||||
|
|
||||||
return new UpdateDataDetails
|
return new UpdateDataDetails
|
||||||
@@ -105,7 +106,7 @@ public class HistoryUpdateCommand : CommandBase
|
|||||||
"replace" => PerformUpdateType.Replace,
|
"replace" => PerformUpdateType.Replace,
|
||||||
_ => PerformUpdateType.Update
|
_ => PerformUpdateType.Update
|
||||||
},
|
},
|
||||||
UpdateValues = updates
|
UpdateValues = updates.ToArrayOf()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case "delete-raw":
|
case "delete-raw":
|
||||||
@@ -134,9 +135,9 @@ public class HistoryUpdateCommand : CommandBase
|
|||||||
throw new CommandException("--mode delete-at-time takes --time only; --value is not allowed.");
|
throw new CommandException("--mode delete-at-time takes --time only; --value is not allowed.");
|
||||||
if (times.Count == 0)
|
if (times.Count == 0)
|
||||||
throw new CommandException("--mode delete-at-time requires at least one --time.");
|
throw new CommandException("--mode delete-at-time requires at least one --time.");
|
||||||
var req = new DateTimeCollection();
|
var req = new List<DateTimeUtc>();
|
||||||
foreach (var t in times) req.Add(ParseTime(t, "--time"));
|
foreach (var t in times) req.Add(ParseTime(t, "--time"));
|
||||||
return new DeleteAtTimeDetails { NodeId = nodeId, ReqTimes = req };
|
return new DeleteAtTimeDetails { NodeId = nodeId, ReqTimes = req.ToArrayOf() };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -238,8 +239,8 @@ public class HistoryUpdateCommand : CommandBase
|
|||||||
/// <returns>The status name, or <c>0xXXXXXXXX</c>.</returns>
|
/// <returns>The status name, or <c>0xXXXXXXXX</c>.</returns>
|
||||||
private static string StatusName(StatusCode code)
|
private static string StatusName(StatusCode code)
|
||||||
{
|
{
|
||||||
var name = StatusCodes.GetBrowseName(code.Code);
|
var name = code.SymbolicId;
|
||||||
if (string.IsNullOrEmpty(name)) name = StatusCodes.GetBrowseName(code.Code & 0xFFFF0000u);
|
if (string.IsNullOrEmpty(name)) name = new StatusCode(code.Code & 0xFFFF0000u).SymbolicId;
|
||||||
return string.IsNullOrEmpty(name) ? $"0x{code.Code:X8}" : name;
|
return string.IsNullOrEmpty(name) ? $"0x{code.Code:X8}" : name;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,8 +248,8 @@ public class HistoryUpdateCommand : CommandBase
|
|||||||
{
|
{
|
||||||
return details switch
|
return details switch
|
||||||
{
|
{
|
||||||
UpdateDataDetails u => u.UpdateValues.Select(v => v.SourceTimestamp.ToString("O")).ToList(),
|
UpdateDataDetails u => (u.UpdateValues.ToArray() ?? []).Select(v => v.SourceTimestamp.ToString("O")).ToList(),
|
||||||
DeleteAtTimeDetails d => d.ReqTimes.Select(t => t.ToString("O")).ToList(),
|
DeleteAtTimeDetails d => (d.ReqTimes.ToArray() ?? []).Select(t => t.ToString("O")).ToList(),
|
||||||
DeleteRawModifiedDetails r => [$"{r.StartTime:O}..{r.EndTime:O}"],
|
DeleteRawModifiedDetails r => [$"{r.StartTime:O}..{r.EndTime:O}"],
|
||||||
_ => []
|
_ => []
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
|
||||||
using CliFx.Attributes;
|
using CliFx.Attributes;
|
||||||
using CliFx.Exceptions;
|
using CliFx.Exceptions;
|
||||||
using CliFx.Infrastructure;
|
using CliFx.Infrastructure;
|
||||||
@@ -59,12 +60,12 @@ public class ReadCommand : CommandBase
|
|||||||
// not crash with a stack trace — session-level failures still propagate.
|
// not crash with a stack trace — session-level failures still propagate.
|
||||||
await console.Output.WriteLineAsync($"Node: {NodeId}");
|
await console.Output.WriteLineAsync($"Node: {NodeId}");
|
||||||
await console.Output.WriteLineAsync($"Value: ");
|
await console.Output.WriteLineAsync($"Value: ");
|
||||||
await console.Output.WriteLineAsync($"Status: {(StatusCode)ex.StatusCode} ({StatusCodes.GetBrowseName(ex.StatusCode)})");
|
await console.Output.WriteLineAsync($"Status: {(StatusCode)ex.StatusCode} ({ex.StatusCode.SymbolicId})");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await console.Output.WriteLineAsync($"Node: {NodeId}");
|
await console.Output.WriteLineAsync($"Node: {NodeId}");
|
||||||
await console.Output.WriteLineAsync($"Value: {value.Value}");
|
await console.Output.WriteLineAsync($"Value: {ValueConverter.ToClrObject(value.WrappedValue)}");
|
||||||
await console.Output.WriteLineAsync($"Status: {value.StatusCode}");
|
await console.Output.WriteLineAsync($"Status: {value.StatusCode}");
|
||||||
await console.Output.WriteLineAsync($"Source Time: {value.SourceTimestamp:O}");
|
await console.Output.WriteLineAsync($"Source Time: {value.SourceTimestamp:O}");
|
||||||
await console.Output.WriteLineAsync($"Server Time: {value.ServerTimestamp:O}");
|
await console.Output.WriteLineAsync($"Server Time: {value.ServerTimestamp:O}");
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.Threading.Channels;
|
using System.Threading.Channels;
|
||||||
using CliFx.Attributes;
|
using CliFx.Attributes;
|
||||||
@@ -123,14 +124,14 @@ public class SubscribeCommand : CommandBase
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var key = e.NodeId.ToString();
|
var key = e.NodeId.ToString();
|
||||||
lastStatus[key] = (e.Value.StatusCode, DateTime.UtcNow, e.Value.Value);
|
lastStatus[key] = (e.Value.StatusCode, DateTime.UtcNow, ValueConverter.ToClrObject(e.Value.WrappedValue));
|
||||||
updateCount.AddOrUpdate(key, 1, (_, v) => v + 1);
|
updateCount.AddOrUpdate(key, 1, (_, v) => v + 1);
|
||||||
if (!StatusCode.IsGood(e.Value.StatusCode))
|
if (!StatusCode.IsGood(e.Value.StatusCode))
|
||||||
everBad.TryAdd(key, 0);
|
everBad.TryAdd(key, 0);
|
||||||
if (!Quiet)
|
if (!Quiet)
|
||||||
{
|
{
|
||||||
var line =
|
var line =
|
||||||
$"[{e.Value.SourceTimestamp:O}] {displayNameByNodeId.GetValueOrDefault(key, key)} = {e.Value.Value} ({e.Value.StatusCode})";
|
$"[{e.Value.SourceTimestamp:O}] {displayNameByNodeId.GetValueOrDefault(key, key)} = {ValueConverter.ToClrObject(e.Value.WrappedValue)} ({e.Value.StatusCode})";
|
||||||
outputChannel.Writer.TryWrite(line);
|
outputChannel.Writer.TryWrite(line);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -271,7 +272,7 @@ public class SubscribeCommand : CommandBase
|
|||||||
var childPath = $"{parentPath}/{child.DisplayName}";
|
var childPath = $"{parentPath}/{child.DisplayName}";
|
||||||
if (child.NodeClass == "Variable")
|
if (child.NodeClass == "Variable")
|
||||||
{
|
{
|
||||||
into.Add((nodeId, childPath));
|
into.Add((nodeId.Value, childPath));
|
||||||
}
|
}
|
||||||
if (child.HasChildren)
|
if (child.HasChildren)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ public class WriteCommand : CommandBase
|
|||||||
|
|
||||||
// Read current value to determine type for conversion
|
// Read current value to determine type for conversion
|
||||||
var currentValue = await service.ReadValueAsync(nodeId, ct);
|
var currentValue = await service.ReadValueAsync(nodeId, ct);
|
||||||
var typedValue = ValueConverter.ConvertValue(Value, currentValue.Value);
|
var typedValue = ValueConverter.ConvertValue(Value, ValueConverter.ToClrObject(currentValue.WrappedValue));
|
||||||
|
|
||||||
var statusCode = await service.WriteValueAsync(nodeId, typedValue, ct);
|
var statusCode = await service.WriteValueAsync(nodeId, typedValue, ct);
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,6 @@ public static class NodeIdParser
|
|||||||
var result = Parse(nodeIdString);
|
var result = Parse(nodeIdString);
|
||||||
if (result == null)
|
if (result == null)
|
||||||
throw new ArgumentException("Node ID is required but was not provided.");
|
throw new ArgumentException("Node ID is required but was not provided.");
|
||||||
return result;
|
return result.Value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+18
-12
@@ -58,20 +58,26 @@ internal sealed class DefaultApplicationConfigurationFactory : IApplicationConfi
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
await config.ValidateAsync(ApplicationType.Client, ct);
|
try
|
||||||
|
|
||||||
if (settings.AutoAcceptCertificates)
|
|
||||||
config.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
|
|
||||||
|
|
||||||
if (settings.SecurityMode != SecurityMode.None)
|
|
||||||
{
|
{
|
||||||
var app = new ApplicationInstance(ClientTelemetry.Context)
|
await config.ValidateAsync(ApplicationType.Client, ct);
|
||||||
|
|
||||||
|
if (settings.SecurityMode != SecurityMode.None)
|
||||||
{
|
{
|
||||||
ApplicationName = "OtOpcUaClient",
|
var app = new ApplicationInstance(ClientTelemetry.Context)
|
||||||
ApplicationType = ApplicationType.Client,
|
{
|
||||||
ApplicationConfiguration = config
|
ApplicationName = "OtOpcUaClient",
|
||||||
};
|
ApplicationType = ApplicationType.Client,
|
||||||
await app.CheckApplicationInstanceCertificatesAsync(false, 2048);
|
ApplicationConfiguration = config
|
||||||
|
};
|
||||||
|
await app.CheckApplicationInstanceCertificatesAsync(false, lifeTimeInMonths: 2048, ct: ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
try { (config.CertificateManager as IDisposable)?.Dispose(); }
|
||||||
|
catch (Exception ex) { Logger.Warning(ex, "Disposing certificate manager after configuration failure"); }
|
||||||
|
throw;
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.Debug("ApplicationConfiguration created for {EndpointUrl}", settings.EndpointUrl);
|
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.)
|
// obsolete synchronous CoreClientUtils.SelectEndpoint, which does the same filter + rewrite.)
|
||||||
using var client = await DiscoveryClient.CreateAsync(
|
using var client = await DiscoveryClient.CreateAsync(
|
||||||
config, new Uri(endpointUrl), DiagnosticsMasks.None, ct).ConfigureAwait(false);
|
config, new Uri(endpointUrl), DiagnosticsMasks.None, ct).ConfigureAwait(false);
|
||||||
var allEndpoints = await client.GetEndpointsAsync(null, ct).ConfigureAwait(false);
|
var allEndpoints = await client.GetEndpointsAsync(default, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
return EndpointSelector.SelectBest(allEndpoints, endpointUrl, requestedMode);
|
return EndpointSelector.SelectBest(allEndpoints.ToArray() ?? [], endpointUrl, requestedMode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +90,7 @@ internal static class EndpointSelector
|
|||||||
// advertises its own host:port (docker-dev containers all say :4840); following that would
|
// 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.
|
// 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.
|
// Only when the scheme matches: a different scheme (https vs opc.tcp) is a different transport.
|
||||||
var serverUri = new Uri(best.EndpointUrl);
|
var serverUri = new Uri(best.EndpointUrl ?? throw new InvalidOperationException("Endpoint has no URL."));
|
||||||
var requestedUri = new Uri(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.
|
// 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;
|
var port = requestedUri.Port > 0 ? requestedUri.Port : serverUri.Port;
|
||||||
|
|||||||
@@ -12,21 +12,25 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
|||||||
{
|
{
|
||||||
private static readonly ILogger Logger = Log.ForContext<DefaultSessionAdapter>();
|
private static readonly ILogger Logger = Log.ForContext<DefaultSessionAdapter>();
|
||||||
private readonly Session _session;
|
private readonly Session _session;
|
||||||
|
private readonly IDisposable? _certificateManager;
|
||||||
|
private int _disposed;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Wraps a live OPC UA session so the shared client can issue runtime operations through a testable adapter surface.
|
/// Wraps a live OPC UA session so the shared client can issue runtime operations through a testable adapter surface.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="session">The connected OPC UA session used for browsing, reads, writes, history, and subscriptions.</param>
|
/// <param name="session">The connected OPC UA session used for browsing, reads, writes, history, and subscriptions.</param>
|
||||||
public DefaultSessionAdapter(Session session)
|
/// <param name="certificateManager">The per-connection certificate manager owned until session disposal.</param>
|
||||||
|
public DefaultSessionAdapter(Session session, IDisposable? certificateManager = null)
|
||||||
{
|
{
|
||||||
_session = session;
|
_session = session;
|
||||||
|
_certificateManager = certificateManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public bool Connected => _session.Connected;
|
public bool Connected => _session.Connected;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public string SessionId => _session.SessionId?.ToString() ?? string.Empty;
|
public string SessionId => _session.SessionId.IsNull ? string.Empty : _session.SessionId.ToString();
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public string SessionName => _session.SessionName ?? string.Empty;
|
public string SessionName => _session.SessionName ?? string.Empty;
|
||||||
@@ -35,7 +39,7 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
|||||||
public string EndpointUrl => _session.Endpoint?.EndpointUrl ?? string.Empty;
|
public string EndpointUrl => _session.Endpoint?.EndpointUrl ?? string.Empty;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public string ServerName => _session.Endpoint?.Server?.ApplicationName?.Text ?? string.Empty;
|
public string ServerName => _session.Endpoint?.Server?.ApplicationName.Text ?? string.Empty;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public string SecurityMode => _session.Endpoint?.SecurityMode.ToString() ?? string.Empty;
|
public string SecurityMode => _session.Endpoint?.SecurityMode.ToString() ?? string.Empty;
|
||||||
@@ -72,13 +76,13 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
|||||||
Value = value
|
Value = value
|
||||||
};
|
};
|
||||||
|
|
||||||
var writeCollection = new WriteValueCollection { writeValue };
|
ArrayOf<WriteValue> writeCollection = [writeValue];
|
||||||
var response = await _session.WriteAsync(null, writeCollection, ct);
|
var response = await _session.WriteAsync(null, writeCollection, ct);
|
||||||
// A malformed or service-level-faulted response can come back with an empty
|
// A malformed or service-level-faulted response can come back with an empty
|
||||||
// Results collection alongside a service fault. Surface the service result
|
// Results collection alongside a service fault. Surface the service result
|
||||||
// (or BadUnexpectedError) rather than letting Results[0] throw
|
// (or BadUnexpectedError) rather than letting Results[0] throw
|
||||||
// IndexOutOfRangeException upstream.
|
// IndexOutOfRangeException upstream.
|
||||||
if (response.Results == null || response.Results.Count == 0)
|
if (response.Results.Count == 0)
|
||||||
{
|
{
|
||||||
var serviceResult = response.ResponseHeader?.ServiceResult.Code ?? StatusCodes.BadUnexpectedError;
|
var serviceResult = response.ResponseHeader?.ServiceResult.Code ?? StatusCodes.BadUnexpectedError;
|
||||||
throw new ServiceResultException(serviceResult,
|
throw new ServiceResultException(serviceResult,
|
||||||
@@ -89,7 +93,7 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseAsync(
|
public async Task<(byte[]? ContinuationPoint, ArrayOf<ReferenceDescription> References)> BrowseAsync(
|
||||||
NodeId nodeId, uint nodeClassMask, CancellationToken ct)
|
NodeId nodeId, uint nodeClassMask, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var (_, continuationPoint, references) = await _session.BrowseAsync(
|
var (_, continuationPoint, references) = await _session.BrowseAsync(
|
||||||
@@ -100,19 +104,25 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
|||||||
BrowseDirection.Forward,
|
BrowseDirection.Forward,
|
||||||
ReferenceTypeIds.HierarchicalReferences,
|
ReferenceTypeIds.HierarchicalReferences,
|
||||||
true,
|
true,
|
||||||
nodeClassMask);
|
nodeClassMask, ct);
|
||||||
|
|
||||||
return (continuationPoint, references ?? []);
|
return (continuationPoint.IsNull ? null : continuationPoint.ToArray(), references);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseNextAsync(
|
public async Task<(byte[]? ContinuationPoint, ArrayOf<ReferenceDescription> References)> BrowseNextAsync(
|
||||||
byte[] continuationPoint, CancellationToken ct)
|
byte[] continuationPoint, CancellationToken ct)
|
||||||
{
|
{
|
||||||
// Pass the caller's token so a cancelled browse does not block on the continuation
|
// Pass the caller's token so a cancelled browse does not block on the continuation
|
||||||
// round-trip.
|
// round-trip.
|
||||||
var (_, nextCp, nextRefs) = await _session.BrowseNextAsync(null, false, continuationPoint, ct);
|
var (_, nextCp, nextRefs) = await _session.BrowseNextAsync(null, false, ByteString.From(continuationPoint), ct);
|
||||||
return (nextCp, nextRefs ?? []);
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -120,16 +130,16 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
|||||||
{
|
{
|
||||||
var response = await _session.HistoryUpdateAsync(
|
var response = await _session.HistoryUpdateAsync(
|
||||||
null,
|
null,
|
||||||
new ExtensionObjectCollection { new ExtensionObject(details) },
|
[new ExtensionObject(details)],
|
||||||
ct).ConfigureAwait(false);
|
ct).ConfigureAwait(false);
|
||||||
|
|
||||||
var statuses = new List<StatusCode>();
|
var statuses = new List<StatusCode>();
|
||||||
var ops = new List<IReadOnlyList<StatusCode>>();
|
var ops = new List<IReadOnlyList<StatusCode>>();
|
||||||
if (response.Results != null)
|
if (!response.Results.IsEmpty)
|
||||||
foreach (var r in response.Results)
|
foreach (var r in response.Results)
|
||||||
{
|
{
|
||||||
statuses.Add(r.StatusCode);
|
statuses.Add(r.StatusCode);
|
||||||
ops.Add(r.OperationResults?.ToArray() ?? Array.Empty<StatusCode>());
|
ops.Add(r.OperationResults.ToArray() ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Models.HistoryUpdateOutcome(response.ResponseHeader.ServiceResult, statuses, ops);
|
return new Models.HistoryUpdateOutcome(response.ResponseHeader.ServiceResult, statuses, ops);
|
||||||
@@ -152,10 +162,10 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
|||||||
// by the cap releases the server's cursor (HistoryReadPaging). This path used to pass
|
// 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,
|
// `releaseContinuationPoints: continuationPoint != null`, which RELEASED the second page instead of reading it,
|
||||||
// silently truncating every raw read to its first server page.
|
// silently truncating every raw read to its first server page.
|
||||||
return await HistoryReadPaging.ReadAllAsync(
|
return await HistoryReadPaging.ReadAllAsync<DataValue>(
|
||||||
(cp, release, token) => ReadHistoryPageAsync(details, nodeId, cp, release, TimestampsToReturn.Source, token),
|
(cp, release, token) => ReadHistoryPageAsync(details, nodeId, cp, release, TimestampsToReturn.Source, token),
|
||||||
result => result.HistoryData is ExtensionObject { Body: HistoryData historyData }
|
result => result.HistoryData.TryGetValue(out HistoryData? historyData)
|
||||||
? historyData.DataValues
|
? historyData.DataValues.ToArray() ?? []
|
||||||
: [],
|
: [],
|
||||||
maxValues, nodeId, ct).ConfigureAwait(false);
|
maxValues, nodeId, ct).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
@@ -193,21 +203,21 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
|||||||
|
|
||||||
return await HistoryReadPaging.ReadAllAsync(
|
return await HistoryReadPaging.ReadAllAsync(
|
||||||
(cp, release, token) => ReadHistoryPageAsync(details, nodeId, cp, release, TimestampsToReturn.Both, token),
|
(cp, release, token) => ReadHistoryPageAsync(details, nodeId, cp, release, TimestampsToReturn.Both, token),
|
||||||
result => result.HistoryData is ExtensionObject { Body: HistoryEvent historyEvent }
|
result => result.HistoryData.TryGetValue(out HistoryEvent? historyEvent)
|
||||||
? historyEvent.Events.Select(e => new Models.HistoryEventEntry(e.EventFields?.ToArray() ?? []))
|
? (historyEvent.Events.ToArray() ?? []).Select(e => new Models.HistoryEventEntry(e.EventFields.ToArray() ?? []))
|
||||||
: [],
|
: [],
|
||||||
maxEvents, nodeId, ct).ConfigureAwait(false);
|
maxEvents, nodeId, ct).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IEnumerable<Models.HistoryModifiedEntry> ToModifiedEntries(HistoryReadResult result)
|
private static IEnumerable<Models.HistoryModifiedEntry> ToModifiedEntries(HistoryReadResult result)
|
||||||
{
|
{
|
||||||
if (result.HistoryData is not ExtensionObject { Body: HistoryModifiedData modified }) yield break;
|
if (!result.HistoryData.TryGetValue(out HistoryModifiedData? modified)) yield break;
|
||||||
for (var i = 0; i < modified.DataValues.Count; i++)
|
for (var i = 0; i < modified.DataValues.Count; i++)
|
||||||
{
|
{
|
||||||
var info = i < modified.ModificationInfos.Count ? modified.ModificationInfos[i] : null;
|
var info = i < modified.ModificationInfos.Count ? modified.ModificationInfos[i] : null;
|
||||||
yield return new Models.HistoryModifiedEntry(
|
yield return new Models.HistoryModifiedEntry(
|
||||||
modified.DataValues[i],
|
modified.DataValues[i],
|
||||||
info?.ModificationTime ?? DateTime.MinValue,
|
info?.ModificationTime.ToDateTime() ?? DateTime.MinValue,
|
||||||
info?.UpdateType ?? default,
|
info?.UpdateType ?? default,
|
||||||
info?.UserName);
|
info?.UserName);
|
||||||
}
|
}
|
||||||
@@ -218,10 +228,10 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
|||||||
HistoryReadDetails details, NodeId nodeId, byte[]? continuationPoint, bool release,
|
HistoryReadDetails details, NodeId nodeId, byte[]? continuationPoint, bool release,
|
||||||
TimestampsToReturn timestamps, CancellationToken ct)
|
TimestampsToReturn timestamps, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var nodesToRead = new HistoryReadValueIdCollection
|
ArrayOf<HistoryReadValueId> nodesToRead =
|
||||||
{
|
[
|
||||||
new HistoryReadValueId { NodeId = nodeId, ContinuationPoint = continuationPoint }
|
new HistoryReadValueId { NodeId = nodeId, ContinuationPoint = ByteString.From(continuationPoint) }
|
||||||
};
|
];
|
||||||
|
|
||||||
// The async overload honours the token and does not block the caller's thread (the UI dispatcher for
|
// The async overload honours the token and does not block the caller's thread (the UI dispatcher for
|
||||||
// client.ui consumers).
|
// client.ui consumers).
|
||||||
@@ -244,10 +254,7 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
|||||||
AggregateType = [aggregateId]
|
AggregateType = [aggregateId]
|
||||||
};
|
};
|
||||||
|
|
||||||
var nodesToRead = new HistoryReadValueIdCollection
|
ArrayOf<HistoryReadValueId> nodesToRead = [new HistoryReadValueId { NodeId = nodeId }];
|
||||||
{
|
|
||||||
new HistoryReadValueId { NodeId = nodeId }
|
|
||||||
};
|
|
||||||
|
|
||||||
// Use the async overload so the method honors the cancellation token and
|
// Use the async overload so the method honors the cancellation token and
|
||||||
// does not block on a synchronous service round-trip.
|
// does not block on a synchronous service round-trip.
|
||||||
@@ -262,13 +269,23 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
|||||||
var results = response.Results;
|
var results = response.Results;
|
||||||
var allValues = new List<DataValue>();
|
var allValues = new List<DataValue>();
|
||||||
|
|
||||||
if (results != null && results.Count > 0)
|
if (results.Count > 0)
|
||||||
{
|
{
|
||||||
var result = results[0];
|
var result = results[0];
|
||||||
HistoryReadStatus.ThrowIfRefused(result.StatusCode, nodeId);
|
try
|
||||||
if (result.HistoryData is ExtensionObject ext &&
|
{
|
||||||
ext.Body is HistoryData historyData)
|
HistoryReadStatus.ThrowIfRefused(result.StatusCode, nodeId);
|
||||||
allValues.AddRange(historyData.DataValues);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return allValues;
|
return allValues;
|
||||||
@@ -322,6 +339,7 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
|
if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (_session.Connected) DisposeBridge.Run(ct => _session.CloseAsync(ct));
|
if (_session.Connected) DisposeBridge.Run(ct => _session.CloseAsync(ct));
|
||||||
@@ -330,7 +348,8 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
|||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
_session.Dispose();
|
try { _session.Dispose(); }
|
||||||
|
finally { _certificateManager?.Dispose(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -339,20 +358,20 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
|||||||
{
|
{
|
||||||
var result = await _session.CallAsync(
|
var result = await _session.CallAsync(
|
||||||
null,
|
null,
|
||||||
new CallMethodRequestCollection
|
new CallMethodRequest[]
|
||||||
{
|
{
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
ObjectId = objectId,
|
ObjectId = objectId,
|
||||||
MethodId = methodId,
|
MethodId = methodId,
|
||||||
InputArguments = new VariantCollection(inputArguments.Select(a => new Variant(a)))
|
InputArguments = inputArguments.Select(ValueConverter.ToVariant).ToArrayOf()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
ct);
|
ct);
|
||||||
|
|
||||||
// An empty Results collection paired with a service fault must surface as
|
// An empty Results collection paired with a service fault must surface as
|
||||||
// a ServiceResultException, not an IndexOutOfRangeException from Results[0].
|
// a ServiceResultException, not an IndexOutOfRangeException from Results[0].
|
||||||
if (result.Results == null || result.Results.Count == 0)
|
if (result.Results.Count == 0)
|
||||||
{
|
{
|
||||||
var serviceResult = result.ResponseHeader?.ServiceResult.Code ?? StatusCodes.BadUnexpectedError;
|
var serviceResult = result.ResponseHeader?.ServiceResult.Code ?? StatusCodes.BadUnexpectedError;
|
||||||
throw new ServiceResultException(serviceResult,
|
throw new ServiceResultException(serviceResult,
|
||||||
@@ -363,6 +382,7 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
|||||||
if (StatusCode.IsBad(callResult.StatusCode))
|
if (StatusCode.IsBad(callResult.StatusCode))
|
||||||
throw new ServiceResultException(callResult.StatusCode);
|
throw new ServiceResultException(callResult.StatusCode);
|
||||||
|
|
||||||
return callResult.OutputArguments?.Select(v => v.Value).ToList();
|
return callResult.OutputArguments.IsNull ? null
|
||||||
|
: (callResult.OutputArguments.ToArray() ?? []).Select(v => ValueConverter.ToClrObject(v)!).ToList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,13 +30,13 @@ internal sealed class DefaultSessionFactory : ISessionFactory
|
|||||||
sessionName,
|
sessionName,
|
||||||
sessionTimeoutMs,
|
sessionTimeoutMs,
|
||||||
identity,
|
identity,
|
||||||
null,
|
default,
|
||||||
ct);
|
ct);
|
||||||
var session = created as Session
|
var session = created as Session
|
||||||
?? throw new InvalidOperationException(
|
?? throw new InvalidOperationException(
|
||||||
$"Session factory returned {created.GetType().Name}, expected {nameof(Session)}.");
|
$"Session factory returned {created.GetType().Name}, expected {nameof(Session)}.");
|
||||||
|
|
||||||
Logger.Information("Session created: {SessionName} -> {EndpointUrl}", sessionName, endpoint.EndpointUrl);
|
Logger.Information("Session created: {SessionName} -> {EndpointUrl}", sessionName, endpoint.EndpointUrl);
|
||||||
return new DefaultSessionAdapter(session);
|
return new DefaultSessionAdapter(session, config.CertificateManager as IDisposable);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -14,6 +14,8 @@ internal sealed class DefaultSubscriptionAdapter : ISubscriptionAdapter
|
|||||||
private readonly Subscription _subscription;
|
private readonly Subscription _subscription;
|
||||||
private readonly Func<Subscription, CancellationToken, Task>? _detach;
|
private readonly Func<Subscription, CancellationToken, Task>? _detach;
|
||||||
private int _detached;
|
private int _detached;
|
||||||
|
private int _finishing;
|
||||||
|
private readonly TaskCompletionSource _finished = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Wraps a live OPC UA subscription so client code can manage monitored items through a testable abstraction.
|
/// Wraps a live OPC UA subscription so client code can manage monitored items through a testable abstraction.
|
||||||
@@ -47,7 +49,7 @@ internal sealed class DefaultSubscriptionAdapter : ISubscriptionAdapter
|
|||||||
|
|
||||||
item.Notification += (_, e) =>
|
item.Notification += (_, e) =>
|
||||||
{
|
{
|
||||||
if (e.NotificationValue is MonitoredItemNotification notification)
|
if (Volatile.Read(ref _finishing) == 0 && e.NotificationValue is MonitoredItemNotification notification)
|
||||||
onDataChange(nodeId.ToString(), notification.Value);
|
onDataChange(nodeId.ToString(), notification.Value);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -90,7 +92,7 @@ internal sealed class DefaultSubscriptionAdapter : ISubscriptionAdapter
|
|||||||
|
|
||||||
item.Notification += (_, e) =>
|
item.Notification += (_, e) =>
|
||||||
{
|
{
|
||||||
if (e.NotificationValue is EventFieldList eventFields) onEvent(eventFields);
|
if (Volatile.Read(ref _finishing) == 0 && e.NotificationValue is EventFieldList eventFields) onEvent(eventFields);
|
||||||
};
|
};
|
||||||
|
|
||||||
_subscription.AddItem(item);
|
_subscription.AddItem(item);
|
||||||
@@ -112,19 +114,28 @@ internal sealed class DefaultSubscriptionAdapter : ISubscriptionAdapter
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task DeleteAsync(CancellationToken ct)
|
public async Task DeleteAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
if (Interlocked.Exchange(ref _finishing, 1) != 0)
|
||||||
|
{
|
||||||
|
await _finished.Task.WaitAsync(ct).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Forward the caller's token so the delete can be cancelled.
|
try { await _subscription.DeleteAsync(true, ct).ConfigureAwait(false); }
|
||||||
await _subscription.DeleteAsync(true, ct);
|
catch (Exception ex) { Logger.Warning(ex, "Error deleting subscription"); }
|
||||||
}
|
|
||||||
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.
|
// A cancelled delete still has local resources to release. Cleanup gets its own bounded token.
|
||||||
await DetachAsync(ct);
|
using var cleanup = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||||
_monitoredItems.Clear();
|
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"); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally { _finished.TrySetResult(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task DetachAsync(CancellationToken ct)
|
private async Task DetachAsync(CancellationToken ct)
|
||||||
@@ -145,21 +156,6 @@ internal sealed class DefaultSubscriptionAdapter : ISubscriptionAdapter
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
// One bounded bridge (5 s) for delete + detach, so a dispose never blocks longer than before (review m6).
|
DisposeBridge.Run(DeleteAsync);
|
||||||
// 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="nodeClassMask">The node classes that should be returned to the caller.</param>
|
||||||
/// <param name="ct">The cancellation token that aborts the browse request.</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>
|
/// <returns>A continuation point (or <c>null</c> when complete) and the references found.</returns>
|
||||||
Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseAsync(
|
Task<(byte[]? ContinuationPoint, ArrayOf<ReferenceDescription> References)> BrowseAsync(
|
||||||
NodeId nodeId, uint nodeClassMask = 0, CancellationToken ct = default);
|
NodeId nodeId, uint nodeClassMask = 0, CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -87,9 +87,15 @@ internal interface ISessionAdapter : IDisposable
|
|||||||
/// <param name="continuationPoint">The continuation token returned by a prior browse result page.</param>
|
/// <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>
|
/// <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>
|
/// <returns>The next continuation point (or <c>null</c> when exhausted) and the additional references.</returns>
|
||||||
Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseNextAsync(
|
Task<(byte[]? ContinuationPoint, ArrayOf<ReferenceDescription> References)> BrowseNextAsync(
|
||||||
byte[] continuationPoint, CancellationToken ct = default);
|
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>
|
/// <summary>
|
||||||
/// Reads raw historical data.
|
/// Reads raw historical data.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -56,15 +56,15 @@ internal static class HistoryReadPaging
|
|||||||
HistoryReadStatus.ThrowIfRefused(result.StatusCode, nodeId);
|
HistoryReadStatus.ThrowIfRefused(result.StatusCode, nodeId);
|
||||||
all.AddRange(extract(result));
|
all.AddRange(extract(result));
|
||||||
|
|
||||||
continuationPoint = result.ContinuationPoint;
|
continuationPoint = result.ContinuationPoint.IsNull ? null : result.ContinuationPoint.ToArray();
|
||||||
if (continuationPoint is not { Length: > 0 }) return Trim(all, maxValues);
|
if (continuationPoint is not { Length: > 0 }) return Trim(all, maxValues);
|
||||||
if (all.Count >= maxValues) break;
|
if (all.Count >= maxValues) break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch
|
||||||
{
|
{
|
||||||
// Cancelled between pages (or during page N+1) with a cursor outstanding: release it rather than leave it
|
// A refused/failed/cancelled continuation still leaves the last cursor owned by this read.
|
||||||
// occupying one of the session's continuation-point slots, then surface the cancellation.
|
// Best-effort release must preserve the original operation failure.
|
||||||
if (continuationPoint is { Length: > 0 }) await ReleaseAsync(readPage, continuationPoint, nodeId);
|
if (continuationPoint is { Length: > 0 }) await ReleaseAsync(readPage, continuationPoint, nodeId);
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
@@ -81,12 +81,12 @@ internal static class HistoryReadPaging
|
|||||||
return all;
|
return all;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task ReleaseAsync(PageReader readPage, byte[] continuationPoint, NodeId nodeId)
|
internal static async Task ReleaseAsync(PageReader readPage, byte[] continuationPoint, NodeId nodeId)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var cts = new CancellationTokenSource(ReleaseTimeout);
|
using var cts = new CancellationTokenSource(ReleaseTimeout);
|
||||||
await readPage(continuationPoint, true, cts.Token).ConfigureAwait(false);
|
await readPage(continuationPoint, true, cts.Token).WaitAsync(cts.Token).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -19,6 +19,6 @@ internal static class HistoryReadStatus
|
|||||||
{
|
{
|
||||||
if (!StatusCode.IsBad(status) || status.Code == StatusCodes.BadNoData) return;
|
if (!StatusCode.IsBad(status) || status.Code == StatusCodes.BadNoData) return;
|
||||||
throw new ServiceResultException(status.Code,
|
throw new ServiceResultException(status.Code,
|
||||||
$"HistoryRead of {nodeId} failed: {StatusCodes.GetBrowseName(status.Code)} (0x{status.Code:X8}).");
|
$"HistoryRead of {nodeId} failed: {status.ToString()} (0x{status.Code:X8}).");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Opc.Ua;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
|
namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -6,6 +8,49 @@ namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static class ValueConverter
|
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>
|
/// <summary>
|
||||||
/// Converts a raw string value into the runtime type expected by the target node.
|
/// Converts a raw string value into the runtime type expected by the target node.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -221,13 +221,13 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
|||||||
if (value is string rawString)
|
if (value is string rawString)
|
||||||
{
|
{
|
||||||
var currentDataValue = await _session!.ReadValueAsync(nodeId, ct);
|
var currentDataValue = await _session!.ReadValueAsync(nodeId, ct);
|
||||||
if (!StatusCode.IsGood(currentDataValue.StatusCode) || currentDataValue.Value == null)
|
if (!StatusCode.IsGood(currentDataValue.StatusCode) || ValueConverter.ToClrObject(currentDataValue.WrappedValue) == null)
|
||||||
throw new InvalidOperationException(
|
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.");
|
$"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, currentDataValue.Value);
|
typedValue = ValueConverter.ConvertValue(rawString, ValueConverter.ToClrObject(currentDataValue.WrappedValue));
|
||||||
}
|
}
|
||||||
|
|
||||||
var dataValue = new DataValue(new Variant(typedValue));
|
var dataValue = new DataValue(ValueConverter.ToVariant(typedValue));
|
||||||
return await _session!.WriteValueAsync(nodeId, dataValue, ct);
|
return await _session!.WriteValueAsync(nodeId, dataValue, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,22 +242,39 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
|||||||
var nodeClassMask = (uint)NodeClass.Object | (uint)NodeClass.Variable | (uint)NodeClass.Method;
|
var nodeClassMask = (uint)NodeClass.Object | (uint)NodeClass.Variable | (uint)NodeClass.Method;
|
||||||
var results = new List<BrowseResult>();
|
var results = new List<BrowseResult>();
|
||||||
|
|
||||||
var (continuationPoint, references) = await _session!.BrowseAsync(startNode, nodeClassMask, ct);
|
// A cursor belongs to the session that issued it, including while failover swaps _session.
|
||||||
|
var session = _session!;
|
||||||
while (references.Count > 0)
|
byte[]? continuationPoint = null;
|
||||||
|
try
|
||||||
{
|
{
|
||||||
// Child detection is lazy: an Object MAY have children and is confirmed when the caller expands or
|
ArrayOf<ReferenceDescription> references;
|
||||||
// recurses into it. Probing each Object child with its own Browse made one level of N folders cost N + 1
|
(continuationPoint, references) = await session.BrowseAsync(startNode, nodeClassMask, ct);
|
||||||
// round trips, and a recursive subscribe over a 50k-node tree ~50k extra Browse calls.
|
while (references.Count > 0)
|
||||||
foreach (var reference in references)
|
{
|
||||||
results.Add(new BrowseResult(
|
foreach (var reference in references)
|
||||||
reference.NodeId.ToString(),
|
results.Add(new BrowseResult(
|
||||||
reference.DisplayName?.Text ?? string.Empty,
|
reference.NodeId.ToString(),
|
||||||
reference.NodeClass.ToString(),
|
reference.DisplayName.Text ?? string.Empty,
|
||||||
reference.NodeClass == NodeClass.Object));
|
reference.NodeClass.ToString(),
|
||||||
|
reference.NodeClass == NodeClass.Object));
|
||||||
|
|
||||||
if (continuationPoint is not { Length: > 0 } pending) break;
|
if (continuationPoint is not { Length: > 0 } pending) break;
|
||||||
(continuationPoint, references) = await _session.BrowseNextAsync(pending, ct);
|
ct.ThrowIfCancellationRequested();
|
||||||
|
(continuationPoint, references) = await session.BrowseNextAsync(pending, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
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"); }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
@@ -293,7 +310,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
|||||||
}
|
}
|
||||||
|
|
||||||
var handle = await subscription.AddDataChangeMonitoredItemAsync(
|
var handle = await subscription.AddDataChangeMonitoredItemAsync(
|
||||||
nodeId, intervalMs, OnDataChangeNotification, ct);
|
nodeId, intervalMs, DataCallback(session, subscription), ct);
|
||||||
|
|
||||||
lock (_subscriptionLock)
|
lock (_subscriptionLock)
|
||||||
{
|
{
|
||||||
@@ -377,7 +394,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
|||||||
|
|
||||||
var filter = CreateAlarmEventFilter();
|
var filter = CreateAlarmEventFilter();
|
||||||
await subscription.AddEventMonitoredItemAsync(
|
await subscription.AddEventMonitoredItemAsync(
|
||||||
monitorNode, intervalMs, filter, OnAlarmEventNotification, ct);
|
monitorNode, intervalMs, filter, AlarmCallback(session, subscription), ct);
|
||||||
|
|
||||||
lock (_subscriptionLock)
|
lock (_subscriptionLock)
|
||||||
{
|
{
|
||||||
@@ -683,15 +700,15 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
|||||||
var redundancySupportValue =
|
var redundancySupportValue =
|
||||||
await _session!.ReadValueAsync(VariableIds.Server_ServerRedundancy_RedundancySupport, ct);
|
await _session!.ReadValueAsync(VariableIds.Server_ServerRedundancy_RedundancySupport, ct);
|
||||||
RedundancySupport redundancySupport;
|
RedundancySupport redundancySupport;
|
||||||
if (StatusCode.IsGood(redundancySupportValue.StatusCode) && redundancySupportValue.Value != null)
|
if (StatusCode.IsGood(redundancySupportValue.StatusCode) && ValueConverter.ToClrObject(redundancySupportValue.WrappedValue) != null)
|
||||||
redundancySupport = (RedundancySupport)Convert.ToInt32(redundancySupportValue.Value);
|
redundancySupport = (RedundancySupport)Convert.ToInt32(ValueConverter.ToClrObject(redundancySupportValue.WrappedValue));
|
||||||
else
|
else
|
||||||
redundancySupport = RedundancySupport.None;
|
redundancySupport = RedundancySupport.None;
|
||||||
var redundancyMode = redundancySupport.ToString();
|
var redundancyMode = redundancySupport.ToString();
|
||||||
|
|
||||||
var serviceLevelValue = await _session.ReadValueAsync(VariableIds.Server_ServiceLevel, ct);
|
var serviceLevelValue = await _session.ReadValueAsync(VariableIds.Server_ServiceLevel, ct);
|
||||||
var serviceLevel = StatusCode.IsGood(serviceLevelValue.StatusCode) && serviceLevelValue.Value != null
|
var serviceLevel = StatusCode.IsGood(serviceLevelValue.StatusCode) && ValueConverter.ToClrObject(serviceLevelValue.WrappedValue) != null
|
||||||
? Convert.ToByte(serviceLevelValue.Value)
|
? Convert.ToByte(ValueConverter.ToClrObject(serviceLevelValue.WrappedValue))
|
||||||
: (byte)0;
|
: (byte)0;
|
||||||
|
|
||||||
string[] serverUris = [];
|
string[] serverUris = [];
|
||||||
@@ -699,7 +716,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
|||||||
{
|
{
|
||||||
var serverUriArrayValue =
|
var serverUriArrayValue =
|
||||||
await _session.ReadValueAsync(VariableIds.Server_ServerRedundancy_ServerUriArray, ct);
|
await _session.ReadValueAsync(VariableIds.Server_ServerRedundancy_ServerUriArray, ct);
|
||||||
if (serverUriArrayValue.Value is string[] uris)
|
if (ValueConverter.ToClrObject(serverUriArrayValue.WrappedValue) is string[] uris)
|
||||||
serverUris = uris;
|
serverUris = uris;
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
@@ -711,7 +728,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var serverArrayValue = await _session.ReadValueAsync(VariableIds.Server_ServerArray, ct);
|
var serverArrayValue = await _session.ReadValueAsync(VariableIds.Server_ServerArray, ct);
|
||||||
if (serverArrayValue.Value is string[] serverArray && serverArray.Length > 0)
|
if (ValueConverter.ToClrObject(serverArrayValue.WrappedValue) is string[] serverArray && serverArray.Length > 0)
|
||||||
applicationUri = serverArray[0];
|
applicationUri = serverArray[0];
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
@@ -766,16 +783,26 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
|||||||
};
|
};
|
||||||
|
|
||||||
var config = await _configFactory.CreateAsync(effectiveSettings, ct);
|
var config = await _configFactory.CreateAsync(effectiveSettings, ct);
|
||||||
var requestedMode = SecurityModeMapper.ToMessageSecurityMode(settings.SecurityMode);
|
try
|
||||||
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
|
var identity = settings.Username != null
|
||||||
? new UserIdentity(settings.Username, Encoding.UTF8.GetBytes(settings.Password ?? ""))
|
? new UserIdentity(settings.Username, Encoding.UTF8.GetBytes(settings.Password ?? ""))
|
||||||
: new UserIdentity();
|
: new UserIdentity();
|
||||||
|
|
||||||
var sessionTimeoutMs = (uint)(settings.SessionTimeoutSeconds * 1000);
|
var sessionTimeoutMs = (uint)(settings.SessionTimeoutSeconds * 1000);
|
||||||
return await _sessionFactory.CreateSessionAsync(config, endpoint, "OtOpcUaClient", sessionTimeoutMs, identity,
|
return await _sessionFactory.CreateSessionAsync(config, endpoint, "OtOpcUaClient", sessionTimeoutMs, identity,
|
||||||
ct);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>A bad keep-alive from the session of connection <paramref name="epoch"/>.</summary>
|
/// <summary>A bad keep-alive from the session of connection <paramref name="epoch"/>.</summary>
|
||||||
@@ -962,7 +989,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
|||||||
}
|
}
|
||||||
|
|
||||||
var handle = await dataSubscription.AddDataChangeMonitoredItemAsync(
|
var handle = await dataSubscription.AddDataChangeMonitoredItemAsync(
|
||||||
nodeId, intervalMs, OnDataChangeNotification, CancellationToken.None);
|
nodeId, intervalMs, DataCallback(session, dataSubscription), CancellationToken.None);
|
||||||
|
|
||||||
lock (_subscriptionLock)
|
lock (_subscriptionLock)
|
||||||
{
|
{
|
||||||
@@ -985,7 +1012,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
|||||||
lock (_subscriptionLock) _alarmSubscription = replayedAlarms;
|
lock (_subscriptionLock) _alarmSubscription = replayedAlarms;
|
||||||
var filter = CreateAlarmEventFilter();
|
var filter = CreateAlarmEventFilter();
|
||||||
await replayedAlarms.AddEventMonitoredItemAsync(
|
await replayedAlarms.AddEventMonitoredItemAsync(
|
||||||
monitorNode, intervalMs, filter, OnAlarmEventNotification, CancellationToken.None);
|
monitorNode, intervalMs, filter, AlarmCallback(session, replayedAlarms), CancellationToken.None);
|
||||||
|
|
||||||
lock (_subscriptionLock)
|
lock (_subscriptionLock)
|
||||||
{
|
{
|
||||||
@@ -999,6 +1026,25 @@ 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)
|
private void OnDataChangeNotification(string nodeId, DataValue value)
|
||||||
{
|
{
|
||||||
DataChanged?.Invoke(this, new DataChangedEventArgs(nodeId, value));
|
DataChanged?.Invoke(this, new DataChangedEventArgs(nodeId, value));
|
||||||
@@ -1007,28 +1053,28 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
|||||||
private void OnAlarmEventNotification(EventFieldList eventFields)
|
private void OnAlarmEventNotification(EventFieldList eventFields)
|
||||||
{
|
{
|
||||||
var fields = eventFields.EventFields;
|
var fields = eventFields.EventFields;
|
||||||
if (fields == null || fields.Count < 1)
|
if (fields.Count < 1)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var eventId = fields.Count > 0 ? fields[0].Value as byte[] : null;
|
var eventId = fields.Count > 0 ? ValueConverter.ToClrObject(fields[0]) as byte[] : null;
|
||||||
var sourceName = fields.Count > 2 ? fields[2].Value as string ?? string.Empty : string.Empty;
|
var sourceName = fields.Count > 2 ? ValueConverter.ToClrObject(fields[2]) as string ?? string.Empty : string.Empty;
|
||||||
var time = DateTime.MinValue;
|
var time = DateTime.MinValue;
|
||||||
if (fields.Count > 3 && fields[3].Value != null)
|
if (fields.Count > 3 && ValueConverter.ToClrObject(fields[3]) != null)
|
||||||
{
|
{
|
||||||
if (fields[3].Value is DateTime dt)
|
if (ValueConverter.ToClrObject(fields[3]) is DateTime dt)
|
||||||
time = dt;
|
time = dt;
|
||||||
else if (DateTime.TryParse(fields[3].Value.ToString(), out var parsed))
|
else if (DateTime.TryParse(ValueConverter.ToClrObject(fields[3])?.ToString(), out var parsed))
|
||||||
time = parsed;
|
time = parsed;
|
||||||
}
|
}
|
||||||
var message = fields.Count > 4 ? (fields[4].Value as LocalizedText)?.Text ?? string.Empty : string.Empty;
|
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(fields[5].Value) : (ushort)0;
|
var severity = fields.Count > 5 ? Convert.ToUInt16(ValueConverter.ToClrObject(fields[5])) : (ushort)0;
|
||||||
var conditionName = fields.Count > 6 ? fields[6].Value as string ?? string.Empty : string.Empty;
|
var conditionName = fields.Count > 6 ? ValueConverter.ToClrObject(fields[6]) as string ?? string.Empty : string.Empty;
|
||||||
var retain = fields.Count > 7 && ParseBool(fields[7].Value);
|
var retain = fields.Count > 7 && ParseBool(ValueConverter.ToClrObject(fields[7]));
|
||||||
var conditionNodeId = fields.Count > 12 ? fields[12].Value?.ToString() : null;
|
var conditionNodeId = fields.Count > 12 ? ValueConverter.ToClrObject(fields[12])?.ToString() : null;
|
||||||
|
|
||||||
// Try standard OPC UA ActiveState/AckedState fields first
|
// Try standard OPC UA ActiveState/AckedState fields first
|
||||||
bool? ackedField = fields.Count > 8 && fields[8].Value != null ? ParseBool(fields[8].Value) : null;
|
bool? ackedField = fields.Count > 8 && ValueConverter.ToClrObject(fields[8]) != null ? ParseBool(ValueConverter.ToClrObject(fields[8])) : null;
|
||||||
bool? activeField = fields.Count > 9 && fields[9].Value != null ? ParseBool(fields[9].Value) : null;
|
bool? activeField = fields.Count > 9 && ValueConverter.ToClrObject(fields[9]) != null ? ParseBool(ValueConverter.ToClrObject(fields[9])) : null;
|
||||||
|
|
||||||
var ackedState = ackedField ?? false;
|
var ackedState = ackedField ?? false;
|
||||||
var activeState = activeField ?? false;
|
var activeState = activeField ?? false;
|
||||||
@@ -1053,39 +1099,25 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
|||||||
/// <returns>The populated <see cref="EventFilter"/>.</returns>
|
/// <returns>The populated <see cref="EventFilter"/>.</returns>
|
||||||
internal static EventFilter CreateAlarmEventFilter()
|
internal static EventFilter CreateAlarmEventFilter()
|
||||||
{
|
{
|
||||||
var filter = new EventFilter();
|
var filter = new EventFilter
|
||||||
// 0: EventId
|
{
|
||||||
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.EventId);
|
SelectClauses =
|
||||||
// 1: EventType
|
[
|
||||||
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.EventType);
|
new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.BaseEventType, AttributeId = Attributes.Value, BrowsePath = [new QualifiedName("EventId")] },
|
||||||
// 2: SourceName
|
new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.BaseEventType, AttributeId = Attributes.Value, BrowsePath = [new QualifiedName("EventType")] },
|
||||||
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.SourceName);
|
new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.BaseEventType, AttributeId = Attributes.Value, BrowsePath = [new QualifiedName("SourceName")] },
|
||||||
// 3: Time
|
new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.BaseEventType, AttributeId = Attributes.Value, BrowsePath = [new QualifiedName("Time")] },
|
||||||
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.Time);
|
new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.BaseEventType, AttributeId = Attributes.Value, BrowsePath = [new QualifiedName("Message")] },
|
||||||
// 4: Message
|
new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.BaseEventType, AttributeId = Attributes.Value, BrowsePath = [new QualifiedName("Severity")] },
|
||||||
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.Message);
|
new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.ConditionType, AttributeId = Attributes.Value, BrowsePath = [new QualifiedName("ConditionName")] },
|
||||||
// 5: Severity
|
new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.ConditionType, AttributeId = Attributes.Value, BrowsePath = [new QualifiedName("Retain")] },
|
||||||
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.Severity);
|
new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.AcknowledgeableConditionType, AttributeId = Attributes.Value, BrowsePath = [new QualifiedName("AckedState"), new QualifiedName("Id")] },
|
||||||
// 6: ConditionName
|
new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.AlarmConditionType, AttributeId = Attributes.Value, BrowsePath = [new QualifiedName("ActiveState"), new QualifiedName("Id")] },
|
||||||
filter.AddSelectClause(ObjectTypeIds.ConditionType, BrowseNames.ConditionName);
|
new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.ConditionType, AttributeId = Attributes.Value, BrowsePath = [new QualifiedName("EnabledState"), new QualifiedName("Id")] },
|
||||||
// 7: Retain
|
new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.AlarmConditionType, AttributeId = Attributes.Value, BrowsePath = [new QualifiedName("SuppressedOrShelved")] },
|
||||||
filter.AddSelectClause(ObjectTypeIds.ConditionType, BrowseNames.Retain);
|
new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.BaseEventType, AttributeId = Attributes.Value, BrowsePath = [new QualifiedName("SourceNode")] },
|
||||||
// 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;
|
return filter;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.MigrationAnalyzer" PrivateAssets="all" IncludeAssets="analyzers;build;buildtransitive" />
|
|
||||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client"/>
|
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client"/>
|
||||||
<PackageReference Include="Serilog"/>
|
<PackageReference Include="Serilog"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ internal static class StatusCodeFormatter
|
|||||||
var hex = $"0x{code:X8}";
|
var hex = $"0x{code:X8}";
|
||||||
|
|
||||||
// Try the SDK's browse name lookup first
|
// Try the SDK's browse name lookup first
|
||||||
var name = StatusCodes.GetBrowseName(code);
|
var name = statusCode.SymbolicId;
|
||||||
if (!string.IsNullOrEmpty(name))
|
if (!string.IsNullOrEmpty(name))
|
||||||
return $"{hex} ({name})";
|
return $"{hex} ({name})";
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ internal static class StatusCodeFormatter
|
|||||||
var mainCode = code & 0xFFFF0000;
|
var mainCode = code & 0xFFFF0000;
|
||||||
if (mainCode != code)
|
if (mainCode != code)
|
||||||
{
|
{
|
||||||
name = StatusCodes.GetBrowseName(mainCode);
|
name = new StatusCode(mainCode).SymbolicId;
|
||||||
if (!string.IsNullOrEmpty(name))
|
if (!string.IsNullOrEmpty(name))
|
||||||
return $"{hex} ({name})";
|
return $"{hex} ({name})";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ public partial class AlarmsViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var sourceNodeId = string.IsNullOrWhiteSpace(MonitoredNodeIdText)
|
NodeId? sourceNodeId = string.IsNullOrWhiteSpace(MonitoredNodeIdText)
|
||||||
? null
|
? null
|
||||||
: NodeId.Parse(MonitoredNodeIdText);
|
: NodeId.Parse(MonitoredNodeIdText);
|
||||||
|
|
||||||
@@ -275,7 +275,7 @@ public partial class AlarmsViewModel : ObservableObject
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var nodeId = string.IsNullOrWhiteSpace(sourceNodeId)
|
NodeId? nodeId = string.IsNullOrWhiteSpace(sourceNodeId)
|
||||||
? null
|
? null
|
||||||
: NodeId.Parse(sourceNodeId);
|
: NodeId.Parse(sourceNodeId);
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
@@ -97,7 +98,7 @@ public partial class HistoryViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
foreach (var dv in values)
|
foreach (var dv in values)
|
||||||
Results.Add(new HistoryValueViewModel(
|
Results.Add(new HistoryValueViewModel(
|
||||||
Helpers.ValueFormatter.Format(dv.Value),
|
Helpers.ValueFormatter.Format(ValueConverter.ToClrObject(dv.WrappedValue)),
|
||||||
Helpers.StatusCodeFormatter.Format(dv.StatusCode),
|
Helpers.StatusCodeFormatter.Format(dv.StatusCode),
|
||||||
dv.SourceTimestamp.ToString("O"),
|
dv.SourceTimestamp.ToString("O"),
|
||||||
dv.ServerTimestamp.ToString("O")));
|
dv.ServerTimestamp.ToString("O")));
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using Opc.Ua;
|
using Opc.Ua;
|
||||||
@@ -76,7 +77,7 @@ public partial class ReadWriteViewModel : ObservableObject
|
|||||||
|
|
||||||
_dispatcher.Post(() =>
|
_dispatcher.Post(() =>
|
||||||
{
|
{
|
||||||
CurrentValue = Helpers.ValueFormatter.Format(dataValue.Value);
|
CurrentValue = Helpers.ValueFormatter.Format(ValueConverter.ToClrObject(dataValue.WrappedValue));
|
||||||
CurrentStatus = Helpers.StatusCodeFormatter.Format(dataValue.StatusCode);
|
CurrentStatus = Helpers.StatusCodeFormatter.Format(dataValue.StatusCode);
|
||||||
SourceTimestamp = dataValue.SourceTimestamp.ToString("O");
|
SourceTimestamp = dataValue.SourceTimestamp.ToString("O");
|
||||||
ServerTimestamp = dataValue.ServerTimestamp.ToString("O");
|
ServerTimestamp = dataValue.ServerTimestamp.ToString("O");
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using System.Collections.Specialized;
|
using System.Collections.Specialized;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
@@ -75,7 +76,7 @@ public partial class SubscriptionsViewModel : ObservableObject
|
|||||||
if (!_rowsByNodeId.TryGetValue(e.NodeId, out var rows)) return;
|
if (!_rowsByNodeId.TryGetValue(e.NodeId, out var rows)) return;
|
||||||
foreach (var item in rows)
|
foreach (var item in rows)
|
||||||
{
|
{
|
||||||
item.Value = Helpers.ValueFormatter.Format(e.Value.Value);
|
item.Value = Helpers.ValueFormatter.Format(ValueConverter.ToClrObject(e.Value.WrappedValue));
|
||||||
item.Status = Helpers.StatusCodeFormatter.Format(e.Value.StatusCode);
|
item.Status = Helpers.StatusCodeFormatter.Format(e.Value.StatusCode);
|
||||||
item.Timestamp = e.Value.SourceTimestamp.ToString("O");
|
item.Timestamp = e.Value.SourceTimestamp.ToString("O");
|
||||||
}
|
}
|
||||||
@@ -281,7 +282,7 @@ public partial class SubscriptionsViewModel : ObservableObject
|
|||||||
|
|
||||||
// Read current value to determine target type
|
// Read current value to determine target type
|
||||||
var currentDataValue = await _service.ReadValueAsync(nodeId);
|
var currentDataValue = await _service.ReadValueAsync(nodeId);
|
||||||
var currentValue = currentDataValue.Value;
|
var currentValue = ValueConverter.ToClrObject(currentDataValue.WrappedValue);
|
||||||
|
|
||||||
// Try parsing to the target type before writing
|
// Try parsing to the target type before writing
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ public class AlarmsCommandTests
|
|||||||
|
|
||||||
fakeService.SubscribeAlarmsCalls.Count.ShouldBe(1);
|
fakeService.SubscribeAlarmsCalls.Count.ShouldBe(1);
|
||||||
fakeService.SubscribeAlarmsCalls[0].SourceNodeId.ShouldNotBeNull();
|
fakeService.SubscribeAlarmsCalls[0].SourceNodeId.ShouldNotBeNull();
|
||||||
fakeService.SubscribeAlarmsCalls[0].SourceNodeId!.Identifier.ShouldBe("AlarmSource");
|
fakeService.SubscribeAlarmsCalls[0].SourceNodeId!.Value.IdentifierAsString.ShouldBe("AlarmSource");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that Execute with refresh requests condition refresh.</summary>
|
/// <summary>Verifies that Execute with refresh requests condition refresh.</summary>
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ public class BrowseCommandTests
|
|||||||
|
|
||||||
fakeService.BrowseNodeIds.Count.ShouldBe(1);
|
fakeService.BrowseNodeIds.Count.ShouldBe(1);
|
||||||
fakeService.BrowseNodeIds[0].ShouldNotBeNull();
|
fakeService.BrowseNodeIds[0].ShouldNotBeNull();
|
||||||
fakeService.BrowseNodeIds[0]!.Identifier.ShouldBe("StartNode");
|
fakeService.BrowseNodeIds[0]!.Value.IdentifierAsString.ShouldBe("StartNode");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that Execute browses from null node when not specified.</summary>
|
/// <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.Count.ShouldBe(1);
|
||||||
fakeService.HistoryReadRawCalls[0].MaxValues.ShouldBe(500);
|
fakeService.HistoryReadRawCalls[0].MaxValues.ShouldBe(500);
|
||||||
fakeService.HistoryReadRawCalls[0].NodeId.Identifier.ShouldBe("HistNode");
|
fakeService.HistoryReadRawCalls[0].NodeId.IdentifierAsString.ShouldBe("HistNode");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies AggregateRead execution calls HistoryReadAggregate.</summary>
|
/// <summary>Verifies AggregateRead execution calls HistoryReadAggregate.</summary>
|
||||||
@@ -211,11 +211,11 @@ public class HistoryReadCommandTests
|
|||||||
|
|
||||||
var call = fakeService.HistoryReadEventsCalls.ShouldHaveSingleItem();
|
var call = fakeService.HistoryReadEventsCalls.ShouldHaveSingleItem();
|
||||||
call.MaxEvents.ShouldBe(25);
|
call.MaxEvents.ShouldBe(25);
|
||||||
call.Filter.SelectClauses.Select(c => c.BrowsePath.ShouldHaveSingleItem().Name)
|
call.Filter.SelectClauses.ToArray()!.Select(c => c.BrowsePath.ToArray()!.ShouldHaveSingleItem().Name)
|
||||||
.ShouldBe(["EventId", "EventType", "Time", "SourceName", "Severity", "Message", "ConditionName", "SourceNode"]);
|
.ShouldBe(["EventId", "EventType", "Time", "SourceName", "Severity", "Message", "ConditionName", "SourceNode"]);
|
||||||
call.Filter.SelectClauses.Single(c => c.BrowsePath[0].Name == "ConditionName").TypeDefinitionId
|
call.Filter.SelectClauses.ToArray()!.Single(c => c.BrowsePath[0].Name == "ConditionName").TypeDefinitionId
|
||||||
.ShouldBe(ObjectTypeIds.ConditionType);
|
.ShouldBe(ObjectTypeIds.ConditionType);
|
||||||
call.Filter.WhereClause.Elements.ShouldBeEmpty("no --of-type, no where-clause");
|
call.Filter.WhereClause.Elements.ToArray()!.ShouldBeEmpty("no --of-type, no where-clause");
|
||||||
fakeService.HistoryReadRawCalls.ShouldBeEmpty();
|
fakeService.HistoryReadRawCalls.ShouldBeEmpty();
|
||||||
|
|
||||||
var output = TestConsoleHelper.GetOutput(console);
|
var output = TestConsoleHelper.GetOutput(console);
|
||||||
@@ -243,10 +243,10 @@ public class HistoryReadCommandTests
|
|||||||
await command.ExecuteAsync(console);
|
await command.ExecuteAsync(console);
|
||||||
|
|
||||||
var filter = fakeService.HistoryReadEventsCalls.ShouldHaveSingleItem().Filter;
|
var filter = fakeService.HistoryReadEventsCalls.ShouldHaveSingleItem().Filter;
|
||||||
filter.SelectClauses.Select(c => c.BrowsePath[0].Name).ShouldBe(["EventType", "Severity"]);
|
filter.SelectClauses.ToArray()!.Select(c => c.BrowsePath[0].Name).ShouldBe(["EventType", "Severity"]);
|
||||||
var element = filter.WhereClause.Elements.ShouldHaveSingleItem();
|
var element = filter.WhereClause.Elements.ToArray()!.ShouldHaveSingleItem();
|
||||||
element.FilterOperator.ShouldBe(FilterOperator.OfType);
|
element.FilterOperator.ShouldBe(FilterOperator.OfType);
|
||||||
((LiteralOperand)ExtensionObject.ToEncodeable(element.FilterOperands[0])).Value.Value
|
(element.FilterOperands[0].TryGetValue(out LiteralOperand? literal) ? literal.Value : Variant.Null)
|
||||||
.ShouldBe(ObjectTypeIds.LimitAlarmType);
|
.ShouldBe(ObjectTypeIds.LimitAlarmType);
|
||||||
TestConsoleHelper.GetOutput(console).ShouldContain("0 events returned.");
|
TestConsoleHelper.GetOutput(console).ShouldContain("0 events returned.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,9 +33,9 @@ public class HistoryUpdateCommandTests
|
|||||||
.BuildDetails().ShouldBeOfType<UpdateDataDetails>();
|
.BuildDetails().ShouldBeOfType<UpdateDataDetails>();
|
||||||
d.PerformInsertReplace.ShouldBe(PerformUpdateType.Insert);
|
d.PerformInsertReplace.ShouldBe(PerformUpdateType.Insert);
|
||||||
d.UpdateValues.Count.ShouldBe(2);
|
d.UpdateValues.Count.ShouldBe(2);
|
||||||
d.UpdateValues[1].Value.ShouldBe(2.5);
|
d.UpdateValues[1].WrappedValue.ShouldBe(Variant.From(2.5));
|
||||||
d.UpdateValues[0].SourceTimestamp.ShouldBe(new DateTime(2026, 1, 15, 8, 0, 0, DateTimeKind.Utc));
|
d.UpdateValues[0].SourceTimestamp.ShouldBe(new DateTime(2026, 1, 15, 8, 0, 0, DateTimeKind.Utc));
|
||||||
d.NodeId.Identifier.ShouldBe("HistNode");
|
d.NodeId.IdentifierAsString.ShouldBe("HistNode");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Replace and update map to their update types.</summary>
|
/// <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)
|
var d = Make(values: [value], times: ["2026-01-15T08:00:00Z"], type: type)
|
||||||
.BuildDetails().ShouldBeOfType<UpdateDataDetails>();
|
.BuildDetails().ShouldBeOfType<UpdateDataDetails>();
|
||||||
d.UpdateValues[0].Value.ShouldBeOfType(expected);
|
ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers.ValueConverter.ToClrObject(d.UpdateValues[0].WrappedValue).ShouldBeOfType(expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>delete-raw builds DeleteRawModifiedDetails with IsDeleteModified=false.</summary>
|
/// <summary>delete-raw builds DeleteRawModifiedDetails with IsDeleteModified=false.</summary>
|
||||||
@@ -211,7 +211,7 @@ public class HistoryUpdateCommandTests
|
|||||||
public async Task Execute_UnnamedStatus_PrintsHex()
|
public async Task Execute_UnnamedStatus_PrintsHex()
|
||||||
{
|
{
|
||||||
const uint unnamed = 0x8FFF0000u;
|
const uint unnamed = 0x8FFF0000u;
|
||||||
StatusCodes.GetBrowseName(unnamed).ShouldBeNullOrEmpty(); // precondition: genuinely unnamed
|
new StatusCode(unnamed).SymbolicId.ShouldBeNullOrEmpty(); // precondition: genuinely unnamed
|
||||||
var svc = new FakeOpcUaClientService
|
var svc = new FakeOpcUaClientService
|
||||||
{
|
{
|
||||||
HistoryUpdateResult = new HistoryUpdateOutcome(StatusCodes.Good, [StatusCodes.Good],
|
HistoryUpdateResult = new HistoryUpdateOutcome(StatusCodes.Good, [StatusCodes.Good],
|
||||||
|
|||||||
@@ -34,8 +34,8 @@ public class NodeIdParserTests
|
|||||||
{
|
{
|
||||||
var result = NodeIdParser.Parse("ns=2;s=MyNode");
|
var result = NodeIdParser.Parse("ns=2;s=MyNode");
|
||||||
result.ShouldNotBeNull();
|
result.ShouldNotBeNull();
|
||||||
result.NamespaceIndex.ShouldBe((ushort)2);
|
result.Value.NamespaceIndex.ShouldBe((ushort)2);
|
||||||
result.Identifier.ShouldBe("MyNode");
|
result.Value.IdentifierAsString.ShouldBe("MyNode");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that Parse correctly parses numeric NodeId format (i=85).</summary>
|
/// <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");
|
var result = NodeIdParser.Parse("i=85");
|
||||||
result.ShouldNotBeNull();
|
result.ShouldNotBeNull();
|
||||||
result.IdType.ShouldBe(IdType.Numeric);
|
result.Value.IdType.ShouldBe(IdType.Numeric);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that Parse treats bare numeric input as namespace 0 numeric NodeId.</summary>
|
/// <summary>Verifies that Parse treats bare numeric input as namespace 0 numeric NodeId.</summary>
|
||||||
@@ -53,8 +53,9 @@ public class NodeIdParserTests
|
|||||||
{
|
{
|
||||||
var result = NodeIdParser.Parse("85");
|
var result = NodeIdParser.Parse("85");
|
||||||
result.ShouldNotBeNull();
|
result.ShouldNotBeNull();
|
||||||
result.NamespaceIndex.ShouldBe((ushort)0);
|
result.Value.NamespaceIndex.ShouldBe((ushort)0);
|
||||||
result.Identifier.ShouldBe((uint)85);
|
result.Value.TryGetValue(out uint identifier).ShouldBeTrue();
|
||||||
|
identifier.ShouldBe(85u);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that Parse trims whitespace padding from input.</summary>
|
/// <summary>Verifies that Parse trims whitespace padding from input.</summary>
|
||||||
@@ -63,7 +64,7 @@ public class NodeIdParserTests
|
|||||||
{
|
{
|
||||||
var result = NodeIdParser.Parse(" ns=2;s=MyNode ");
|
var result = NodeIdParser.Parse(" ns=2;s=MyNode ");
|
||||||
result.ShouldNotBeNull();
|
result.ShouldNotBeNull();
|
||||||
result.Identifier.ShouldBe("MyNode");
|
result.Value.IdentifierAsString.ShouldBe("MyNode");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that Parse throws FormatException for invalid input.</summary>
|
/// <summary>Verifies that Parse throws FormatException for invalid input.</summary>
|
||||||
@@ -92,7 +93,36 @@ public class NodeIdParserTests
|
|||||||
public void ParseRequired_ValidInput_ReturnsNodeId()
|
public void ParseRequired_ValidInput_ReturnsNodeId()
|
||||||
{
|
{
|
||||||
var result = NodeIdParser.ParseRequired("ns=2;s=TestNode");
|
var result = NodeIdParser.ParseRequired("ns=2;s=TestNode");
|
||||||
result.ShouldNotBeNull();
|
result.IsNull.ShouldBeFalse();
|
||||||
result.Identifier.ShouldBe("TestNode");
|
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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -56,7 +56,7 @@ public class ReadCommandTests
|
|||||||
await command.ExecuteAsync(console);
|
await command.ExecuteAsync(console);
|
||||||
|
|
||||||
fakeService.ReadNodeIds.Count.ShouldBe(1);
|
fakeService.ReadNodeIds.Count.ShouldBe(1);
|
||||||
fakeService.ReadNodeIds[0].Identifier.ShouldBe("MyVariable");
|
fakeService.ReadNodeIds[0].IdentifierAsString.ShouldBe("MyVariable");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that execute disconnects in finally.</summary>
|
/// <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.
|
// Both Variables (depth 1 + depth 2) should have been subscribed.
|
||||||
fakeService.SubscribeCalls.Count.ShouldBeGreaterThanOrEqualTo(2);
|
fakeService.SubscribeCalls.Count.ShouldBeGreaterThanOrEqualTo(2);
|
||||||
fakeService.SubscribeCalls.ShouldContain(c => c.NodeId.Identifier.ToString() == "Tag1");
|
fakeService.SubscribeCalls.ShouldContain(c => c.NodeId.IdentifierAsString.ToString() == "Tag1");
|
||||||
fakeService.SubscribeCalls.ShouldContain(c => c.NodeId.Identifier.ToString() == "Tag2");
|
fakeService.SubscribeCalls.ShouldContain(c => c.NodeId.IdentifierAsString.ToString() == "Tag2");
|
||||||
|
|
||||||
var output = TestConsoleHelper.GetOutput(console);
|
var output = TestConsoleHelper.GetOutput(console);
|
||||||
output.ShouldContain("Browsing subtree of ns=2;s=Root");
|
output.ShouldContain("Browsing subtree of ns=2;s=Root");
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ public class SubscribeCommandTests
|
|||||||
|
|
||||||
fakeService.SubscribeCalls.Count.ShouldBe(1);
|
fakeService.SubscribeCalls.Count.ShouldBe(1);
|
||||||
fakeService.SubscribeCalls[0].IntervalMs.ShouldBe(500);
|
fakeService.SubscribeCalls[0].IntervalMs.ShouldBe(500);
|
||||||
fakeService.SubscribeCalls[0].NodeId.Identifier.ShouldBe("TestVar");
|
fakeService.SubscribeCalls[0].NodeId.IdentifierAsString.ShouldBe("TestVar");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that ExecuteAsync unsubscribes when cancellation is requested.</summary>
|
/// <summary>Verifies that ExecuteAsync unsubscribes when cancellation is requested.</summary>
|
||||||
|
|||||||
+42
@@ -11,6 +11,48 @@ namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.Adapters;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class DefaultSubscriptionAdapterTests
|
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>
|
/// <summary>DeleteAsync detaches the wrapped subscription exactly once, even when Dispose follows.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task DeleteAsync_detaches_the_subscription_from_its_session_once()
|
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",
|
var best = EndpointSelector.SelectBest(endpoints, "opc.tcp://external-host:4840",
|
||||||
MessageSecurityMode.Sign);
|
MessageSecurityMode.Sign);
|
||||||
|
|
||||||
new Uri(best.EndpointUrl).Host.ShouldBe("external-host");
|
new Uri(best.EndpointUrl.ShouldNotBeNull()).Host.ShouldBe("external-host");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -169,7 +169,7 @@ public class EndpointSelectorTests
|
|||||||
|
|
||||||
var best = EndpointSelector.SelectBest(endpoints, "opc.tcp://localhost:4841", MessageSecurityMode.None);
|
var best = EndpointSelector.SelectBest(endpoints, "opc.tcp://localhost:4841", MessageSecurityMode.None);
|
||||||
|
|
||||||
var uri = new Uri(best.EndpointUrl);
|
var uri = new Uri(best.EndpointUrl.ShouldNotBeNull());
|
||||||
uri.Host.ShouldBe("localhost");
|
uri.Host.ShouldBe("localhost");
|
||||||
uri.Port.ShouldBe(4841);
|
uri.Port.ShouldBe(4841);
|
||||||
uri.AbsolutePath.ShouldBe("/OtOpcUa");
|
uri.AbsolutePath.ShouldBe("/OtOpcUa");
|
||||||
@@ -183,8 +183,8 @@ public class EndpointSelectorTests
|
|||||||
|
|
||||||
var best = EndpointSelector.SelectBest(endpoints, "opc.tcp://localhost:4841", MessageSecurityMode.None);
|
var best = EndpointSelector.SelectBest(endpoints, "opc.tcp://localhost:4841", MessageSecurityMode.None);
|
||||||
|
|
||||||
new Uri(best.EndpointUrl).Host.ShouldBe("internal-host");
|
new Uri(best.EndpointUrl.ShouldNotBeNull()).Host.ShouldBe("internal-host");
|
||||||
new Uri(best.EndpointUrl).Port.ShouldBe(4843);
|
new Uri(best.EndpointUrl.ShouldNotBeNull()).Port.ShouldBe(4843);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Identical host, port and scheme: nothing to rewrite.</summary>
|
/// <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 best = EndpointSelector.SelectBest(endpoints, "opc.tcp://localhost", MessageSecurityMode.None);
|
||||||
|
|
||||||
var uri = new Uri(best.EndpointUrl);
|
var uri = new Uri(best.EndpointUrl.ShouldNotBeNull());
|
||||||
uri.Host.ShouldBe("localhost");
|
uri.Host.ShouldBe("localhost");
|
||||||
uri.Port.ShouldBe(4840);
|
uri.Port.ShouldBe(4840);
|
||||||
}
|
}
|
||||||
@@ -219,7 +219,7 @@ public class EndpointSelectorTests
|
|||||||
|
|
||||||
var best = EndpointSelector.SelectBest(endpoints, "opc.tcp://[::1]:4841", MessageSecurityMode.None);
|
var best = EndpointSelector.SelectBest(endpoints, "opc.tcp://[::1]:4841", MessageSecurityMode.None);
|
||||||
|
|
||||||
var uri = new Uri(best.EndpointUrl);
|
var uri = new Uri(best.EndpointUrl.ShouldNotBeNull());
|
||||||
uri.Host.ShouldBe("[::1]");
|
uri.Host.ShouldBe("[::1]");
|
||||||
uri.Port.ShouldBe(4841);
|
uri.Port.ShouldBe(4841);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ public sealed class AlarmEventFilterTests
|
|||||||
{
|
{
|
||||||
var filter = OpcUaClientService.CreateAlarmEventFilter();
|
var filter = OpcUaClientService.CreateAlarmEventFilter();
|
||||||
|
|
||||||
filter.SelectClauses.SelectMany(c => c.BrowsePath).ShouldAllBe(q => !q.Name.Contains('/'));
|
filter.SelectClauses.ToArray()!.SelectMany(c => c.BrowsePath.ToArray()!).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>
|
/// <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;
|
var clauses = OpcUaClientService.CreateAlarmEventFilter().SelectClauses;
|
||||||
|
|
||||||
string Path(int i) => string.Join("/", clauses[i].BrowsePath.Select(q => q.Name));
|
string Path(int i) => string.Join("/", clauses[i].BrowsePath.ToArray()!.Select(q => q.Name));
|
||||||
Path(8).ShouldBe("AckedState/Id");
|
Path(8).ShouldBe("AckedState/Id");
|
||||||
clauses[8].BrowsePath.Count.ShouldBe(2);
|
clauses[8].BrowsePath.Count.ShouldBe(2);
|
||||||
Path(9).ShouldBe("ActiveState/Id");
|
Path(9).ShouldBe("ActiveState/Id");
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ public sealed class FailoverEpochTests : IDisposable
|
|||||||
var sub = new FakeSubscriptionAdapter();
|
var sub = new FakeSubscriptionAdapter();
|
||||||
_factory.EnqueueSession(new FakeSessionAdapter { NextSubscription = sub });
|
_factory.EnqueueSession(new FakeSessionAdapter { NextSubscription = sub });
|
||||||
await _service.ConnectAsync(Settings(), TestContext.Current.CancellationToken);
|
await _service.ConnectAsync(Settings(), TestContext.Current.CancellationToken);
|
||||||
var node = new Opc.Ua.NodeId("ns=2;s=X");
|
var node = Opc.Ua.NodeId.Parse("ns=2;s=X");
|
||||||
await _service.SubscribeAsync(node, ct: TestContext.Current.CancellationToken);
|
await _service.SubscribeAsync(node, ct: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
sub.ThrowOnRemove = true;
|
sub.ThrowOnRemove = true;
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ internal sealed class FakeEndpointDiscovery : IEndpointDiscovery
|
|||||||
: SecurityPolicies.Basic256Sha256,
|
: SecurityPolicies.Basic256Sha256,
|
||||||
Server = new ApplicationDescription
|
Server = new ApplicationDescription
|
||||||
{
|
{
|
||||||
ApplicationName = "FakeServer",
|
ApplicationName = new LocalizedText("FakeServer"),
|
||||||
ApplicationUri = "urn:localhost:FakeServer"
|
ApplicationUri = "urn:localhost:FakeServer"
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -48,11 +48,15 @@ internal sealed class FakeSessionAdapter : ISessionAdapter
|
|||||||
public bool ThrowOnBrowse { get; set; }
|
public bool ThrowOnBrowse { get; set; }
|
||||||
|
|
||||||
/// <summary>Gets or sets the browse references returned by browse operations.</summary>
|
/// <summary>Gets or sets the browse references returned by browse operations.</summary>
|
||||||
public ReferenceDescriptionCollection BrowseResponse { get; set; } = [];
|
public ArrayOf<ReferenceDescription> BrowseResponse { get; set; } = [];
|
||||||
/// <summary>Gets or sets the continuation point for browse operations.</summary>
|
/// <summary>Gets or sets the continuation point for browse operations.</summary>
|
||||||
public byte[]? BrowseContinuationPoint { get; set; }
|
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>
|
/// <summary>Gets or sets the browse references returned by browse-next operations.</summary>
|
||||||
public ReferenceDescriptionCollection BrowseNextResponse { get; set; } = [];
|
public ArrayOf<ReferenceDescription> BrowseNextResponse { get; set; } = [];
|
||||||
/// <summary>Gets or sets the continuation point for browse-next operations.</summary>
|
/// <summary>Gets or sets the continuation point for browse-next operations.</summary>
|
||||||
public byte[]? BrowseNextContinuationPoint { get; set; }
|
public byte[]? BrowseNextContinuationPoint { get; set; }
|
||||||
|
|
||||||
@@ -129,7 +133,7 @@ internal sealed class FakeSessionAdapter : ISessionAdapter
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseAsync(
|
public Task<(byte[]? ContinuationPoint, ArrayOf<ReferenceDescription> References)> BrowseAsync(
|
||||||
NodeId nodeId, uint nodeClassMask, CancellationToken ct)
|
NodeId nodeId, uint nodeClassMask, CancellationToken ct)
|
||||||
{
|
{
|
||||||
BrowseCount++;
|
BrowseCount++;
|
||||||
@@ -139,11 +143,19 @@ internal sealed class FakeSessionAdapter : ISessionAdapter
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseNextAsync(
|
public async Task<(byte[]? ContinuationPoint, ArrayOf<ReferenceDescription> References)> BrowseNextAsync(
|
||||||
byte[] continuationPoint, CancellationToken ct)
|
byte[] continuationPoint, CancellationToken ct)
|
||||||
{
|
{
|
||||||
BrowseNextCount++;
|
BrowseNextCount++;
|
||||||
return Task.FromResult((BrowseNextContinuationPoint, BrowseNextResponse));
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ internal sealed class FakeSessionFactory : ISessionFactory
|
|||||||
uint sessionTimeoutMs, UserIdentity identity, CancellationToken ct)
|
uint sessionTimeoutMs, UserIdentity identity, CancellationToken ct)
|
||||||
{
|
{
|
||||||
CreateCallCount++;
|
CreateCallCount++;
|
||||||
LastEndpointUrl = endpoint.EndpointUrl;
|
LastEndpointUrl = endpoint.EndpointUrl ?? string.Empty;
|
||||||
|
|
||||||
// One-shot: the gate holds only the call that observed it, so a later call (e.g. a user's Connect while a
|
// 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.
|
// failover is held) runs freely.
|
||||||
@@ -60,14 +60,14 @@ internal sealed class FakeSessionFactory : ISessionFactory
|
|||||||
else
|
else
|
||||||
session = new FakeSessionAdapter
|
session = new FakeSessionAdapter
|
||||||
{
|
{
|
||||||
EndpointUrl = endpoint.EndpointUrl,
|
EndpointUrl = endpoint.EndpointUrl ?? string.Empty,
|
||||||
ServerName = endpoint.Server?.ApplicationName?.Text ?? "FakeServer",
|
ServerName = endpoint.Server?.ApplicationName.Text ?? "FakeServer",
|
||||||
SecurityMode = endpoint.SecurityMode.ToString(),
|
SecurityMode = endpoint.SecurityMode.ToString(),
|
||||||
SecurityPolicyUri = endpoint.SecurityPolicyUri ?? string.Empty
|
SecurityPolicyUri = endpoint.SecurityPolicyUri ?? string.Empty
|
||||||
};
|
};
|
||||||
|
|
||||||
// Ensure endpoint URL matches
|
// Ensure endpoint URL matches
|
||||||
session.EndpointUrl = endpoint.EndpointUrl;
|
session.EndpointUrl = endpoint.EndpointUrl ?? string.Empty;
|
||||||
_createdSessions.Add(session);
|
_createdSessions.Add(session);
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,6 +129,16 @@ internal sealed class FakeSubscriptionAdapter : ISubscriptionAdapter
|
|||||||
lock (_itemsLock) _items.Clear();
|
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>
|
/// <summary>
|
||||||
/// Simulates a data change notification for testing.
|
/// Simulates a data change notification for testing.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
+4
-5
@@ -26,8 +26,7 @@ public class AggregateTypeMapperTests
|
|||||||
public void ToNodeId_ReturnsNonNullForAllValues(AggregateType aggregate)
|
public void ToNodeId_ReturnsNonNullForAllValues(AggregateType aggregate)
|
||||||
{
|
{
|
||||||
var nodeId = AggregateTypeMapper.ToNodeId(aggregate);
|
var nodeId = AggregateTypeMapper.ToNodeId(aggregate);
|
||||||
nodeId.ShouldNotBeNull();
|
nodeId.IsNull.ShouldBeFalse();
|
||||||
nodeId.IsNullNodeId.ShouldBeFalse();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that Average aggregate type maps to the correct OPC UA node ID.</summary>
|
/// <summary>Verifies that Average aggregate type maps to the correct OPC UA node ID.</summary>
|
||||||
@@ -109,9 +108,9 @@ public class AggregateTypeMapperTests
|
|||||||
[MemberData(nameof(AllAggregates))]
|
[MemberData(nameof(AllAggregates))]
|
||||||
public void ToNodeId_maps_to_the_same_named_AggregateFunction(AggregateType aggregate)
|
public void ToNodeId_maps_to_the_same_named_AggregateFunction(AggregateType aggregate)
|
||||||
{
|
{
|
||||||
var field = typeof(ObjectIds).GetField("AggregateFunction_" + aggregate);
|
var property = typeof(ObjectIds).GetProperty("AggregateFunction_" + aggregate);
|
||||||
field.ShouldNotBeNull();
|
property.ShouldNotBeNull();
|
||||||
AggregateTypeMapper.ToNodeId(aggregate).ShouldBe((NodeId)field.GetValue(null)!);
|
AggregateTypeMapper.ToNodeId(aggregate).ShouldBe((NodeId)property.GetValue(null)!);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Every client aggregate.</summary>
|
/// <summary>Every client aggregate.</summary>
|
||||||
|
|||||||
+26
-10
@@ -29,19 +29,18 @@ public class HistoryReadPagingTests
|
|||||||
Calls.Add(new Call(cp, release));
|
Calls.Add(new Call(cp, release));
|
||||||
if (release) return Task.FromResult<HistoryReadResult?>(new HistoryReadResult { StatusCode = StatusCodes.Good });
|
if (release) return Task.FromResult<HistoryReadResult?>(new HistoryReadResult { StatusCode = StatusCodes.Good });
|
||||||
var (count, next) = _pages.Dequeue();
|
var (count, next) = _pages.Dequeue();
|
||||||
var data = new HistoryData();
|
var data = new HistoryData { DataValues = Enumerable.Range(0, count).Select(i => new DataValue(Variant.From(i))).ToArrayOf() };
|
||||||
for (var i = 0; i < count; i++) data.DataValues.Add(new DataValue(new Variant(i)));
|
|
||||||
return Task.FromResult<HistoryReadResult?>(new HistoryReadResult
|
return Task.FromResult<HistoryReadResult?>(new HistoryReadResult
|
||||||
{
|
{
|
||||||
StatusCode = StatusCodes.Good,
|
StatusCode = StatusCodes.Good,
|
||||||
ContinuationPoint = next,
|
ContinuationPoint = ByteString.From(next),
|
||||||
HistoryData = new ExtensionObject(data)
|
HistoryData = new ExtensionObject(data)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IEnumerable<DataValue> Values(HistoryReadResult r) =>
|
private static IEnumerable<DataValue> Values(HistoryReadResult r) =>
|
||||||
r.HistoryData?.Body is HistoryData d ? d.DataValues : [];
|
r.HistoryData.TryGetValue(out HistoryData? d) ? d.DataValues.ToArray() ?? [] : [];
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A server page limit below the caller's cap hands back a continuation point: the follow-up must READ the next
|
/// A server page limit below the caller's cap hands back a continuation point: the follow-up must READ the next
|
||||||
@@ -136,8 +135,7 @@ public class HistoryReadPagingTests
|
|||||||
calls.Add((cp, release, ct.IsCancellationRequested));
|
calls.Add((cp, release, ct.IsCancellationRequested));
|
||||||
if (release) return Task.FromResult<HistoryReadResult?>(new HistoryReadResult { StatusCode = StatusCodes.Good });
|
if (release) return Task.FromResult<HistoryReadResult?>(new HistoryReadResult { StatusCode = StatusCodes.Good });
|
||||||
if (cp != null) { cts.Cancel(); ct.ThrowIfCancellationRequested(); }
|
if (cp != null) { cts.Cancel(); ct.ThrowIfCancellationRequested(); }
|
||||||
var data = new HistoryData();
|
var data = new HistoryData { DataValues = [new DataValue(Variant.From(1))] };
|
||||||
data.DataValues.Add(new DataValue(new Variant(1)));
|
|
||||||
return Task.FromResult<HistoryReadResult?>(new HistoryReadResult
|
return Task.FromResult<HistoryReadResult?>(new HistoryReadResult
|
||||||
{
|
{
|
||||||
StatusCode = StatusCodes.Good, ContinuationPoint = [5], HistoryData = new ExtensionObject(data)
|
StatusCode = StatusCodes.Good, ContinuationPoint = [5], HistoryData = new ExtensionObject(data)
|
||||||
@@ -165,8 +163,7 @@ public class HistoryReadPagingTests
|
|||||||
releaseSawCancelled = ct.IsCancellationRequested;
|
releaseSawCancelled = ct.IsCancellationRequested;
|
||||||
return Task.FromResult<HistoryReadResult?>(new HistoryReadResult { StatusCode = StatusCodes.Good });
|
return Task.FromResult<HistoryReadResult?>(new HistoryReadResult { StatusCode = StatusCodes.Good });
|
||||||
}
|
}
|
||||||
var data = new HistoryData();
|
var data = new HistoryData { DataValues = [new DataValue(Variant.From(1))] };
|
||||||
data.DataValues.Add(new DataValue(new Variant(1)));
|
|
||||||
cts.Cancel(); // the caller gives up right as the capped page arrives
|
cts.Cancel(); // the caller gives up right as the capped page arrives
|
||||||
return Task.FromResult<HistoryReadResult?>(new HistoryReadResult
|
return Task.FromResult<HistoryReadResult?>(new HistoryReadResult
|
||||||
{
|
{
|
||||||
@@ -189,8 +186,7 @@ public class HistoryReadPagingTests
|
|||||||
{
|
{
|
||||||
calls++;
|
calls++;
|
||||||
if (release) throw new ServiceResultException(StatusCodes.BadSessionClosed);
|
if (release) throw new ServiceResultException(StatusCodes.BadSessionClosed);
|
||||||
var data = new HistoryData();
|
var data = new HistoryData { DataValues = [new DataValue(Variant.From(1))] };
|
||||||
data.DataValues.Add(new DataValue(new Variant(1)));
|
|
||||||
return Task.FromResult<HistoryReadResult?>(new HistoryReadResult
|
return Task.FromResult<HistoryReadResult?>(new HistoryReadResult
|
||||||
{
|
{
|
||||||
StatusCode = StatusCodes.Good, ContinuationPoint = [9], HistoryData = new ExtensionObject(data)
|
StatusCode = StatusCodes.Good, ContinuationPoint = [9], HistoryData = new ExtensionObject(data)
|
||||||
@@ -216,4 +212,24 @@ public class HistoryReadPagingTests
|
|||||||
await Should.ThrowAsync<ServiceResultException>(() =>
|
await Should.ThrowAsync<ServiceResultException>(() =>
|
||||||
HistoryReadPaging.ReadAllAsync(Read, Values, maxValues: 10, Node, CancellationToken.None));
|
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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-14
@@ -7,32 +7,29 @@ namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.Helpers;
|
|||||||
|
|
||||||
public class HistoryReadStatusTests
|
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>
|
/// <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>
|
/// <param name="code">The Bad per-node status code.</param>
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(StatusCodes.BadHistoryOperationUnsupported)]
|
[MemberData(nameof(Refusals))]
|
||||||
[InlineData(StatusCodes.BadUserAccessDenied)]
|
public void Bad_status_throws_ServiceResultException_with_the_code(StatusCode code)
|
||||||
[InlineData(StatusCodes.BadNodeIdUnknown)]
|
|
||||||
[InlineData(StatusCodes.BadTimeout)]
|
|
||||||
public void Bad_status_throws_ServiceResultException_with_the_code(uint code)
|
|
||||||
{
|
{
|
||||||
var ex = Should.Throw<ServiceResultException>(
|
var ex = Should.Throw<ServiceResultException>(
|
||||||
() => HistoryReadStatus.ThrowIfRefused(new StatusCode(code), new NodeId("n", 2)));
|
() => HistoryReadStatus.ThrowIfRefused(code, new NodeId("n", 2)));
|
||||||
|
|
||||||
ex.StatusCode.ShouldBe(code);
|
ex.StatusCode.ShouldBe(code.Code);
|
||||||
ex.Message.ShouldContain("ns=2;s=n");
|
ex.Message.ShouldContain("ns=2;s=n");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Good, GoodNoData, Uncertain and BadNoData are all non-refusals (the window may simply be empty).</summary>
|
/// <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>
|
/// <param name="code">The non-refusal status code.</param>
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(StatusCodes.Good)]
|
[MemberData(nameof(NonRefusals))]
|
||||||
[InlineData(StatusCodes.GoodNoData)]
|
public void Non_refusal_status_does_not_throw(StatusCode code)
|
||||||
[InlineData(StatusCodes.GoodMoreData)]
|
|
||||||
[InlineData(StatusCodes.UncertainNoCommunicationLastUsableValue)]
|
|
||||||
[InlineData(StatusCodes.BadNoData)]
|
|
||||||
public void Non_refusal_status_does_not_throw(uint code)
|
|
||||||
{
|
{
|
||||||
Should.NotThrow(() => HistoryReadStatus.ThrowIfRefused(new StatusCode(code), new NodeId("n", 2)));
|
Should.NotThrow(() => HistoryReadStatus.ThrowIfRefused(code, new NodeId("n", 2)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
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.NodeId.ShouldBe("ns=2;s=Temp");
|
||||||
args.Value.ShouldBe(value);
|
args.Value.ShouldBe(value);
|
||||||
args.Value.Value.ShouldBe(42);
|
args.Value.WrappedValue.ShouldBe(Variant.From(42));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that ConnectionStateChangedEventArgs constructs correctly with all properties.</summary>
|
/// <summary>Verifies that ConnectionStateChangedEventArgs constructs correctly with all properties.</summary>
|
||||||
|
|||||||
@@ -197,9 +197,9 @@ public class OpcUaClientServiceTests : IDisposable
|
|||||||
_sessionFactory.EnqueueSession(session);
|
_sessionFactory.EnqueueSession(session);
|
||||||
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
var result = await _service.ReadValueAsync(new NodeId("ns=2;s=MyNode"), TestContext.Current.CancellationToken);
|
var result = await _service.ReadValueAsync(NodeId.Parse("ns=2;s=MyNode"), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
result.Value.ShouldBe(42);
|
result.WrappedValue.ShouldBe(Variant.From(42));
|
||||||
session.ReadCount.ShouldBe(1);
|
session.ReadCount.ShouldBe(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,7 +210,7 @@ public class OpcUaClientServiceTests : IDisposable
|
|||||||
public async Task ReadValueAsync_WhenDisconnected_Throws()
|
public async Task ReadValueAsync_WhenDisconnected_Throws()
|
||||||
{
|
{
|
||||||
await Should.ThrowAsync<InvalidOperationException>(() =>
|
await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||||
_service.ReadValueAsync(new NodeId("ns=2;s=MyNode")));
|
_service.ReadValueAsync(NodeId.Parse("ns=2;s=MyNode")));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -224,7 +224,7 @@ public class OpcUaClientServiceTests : IDisposable
|
|||||||
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
await Should.ThrowAsync<ServiceResultException>(() =>
|
await Should.ThrowAsync<ServiceResultException>(() =>
|
||||||
_service.ReadValueAsync(new NodeId("ns=2;s=MyNode")));
|
_service.ReadValueAsync(NodeId.Parse("ns=2;s=MyNode")));
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Write tests ---
|
// --- Write tests ---
|
||||||
@@ -243,7 +243,7 @@ public class OpcUaClientServiceTests : IDisposable
|
|||||||
_sessionFactory.EnqueueSession(session);
|
_sessionFactory.EnqueueSession(session);
|
||||||
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
var result = await _service.WriteValueAsync(new NodeId("ns=2;s=MyNode"), 42, TestContext.Current.CancellationToken);
|
var result = await _service.WriteValueAsync(NodeId.Parse("ns=2;s=MyNode"), 42, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
result.ShouldBe(StatusCodes.Good);
|
result.ShouldBe(StatusCodes.Good);
|
||||||
session.WriteCount.ShouldBe(1);
|
session.WriteCount.ShouldBe(1);
|
||||||
@@ -262,7 +262,7 @@ public class OpcUaClientServiceTests : IDisposable
|
|||||||
_sessionFactory.EnqueueSession(session);
|
_sessionFactory.EnqueueSession(session);
|
||||||
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
await _service.WriteValueAsync(new NodeId("ns=2;s=MyNode"), "42", TestContext.Current.CancellationToken);
|
await _service.WriteValueAsync(NodeId.Parse("ns=2;s=MyNode"), "42", TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
session.WriteCount.ShouldBe(1);
|
session.WriteCount.ShouldBe(1);
|
||||||
session.ReadCount.ShouldBe(1); // Read for type inference
|
session.ReadCount.ShouldBe(1); // Read for type inference
|
||||||
@@ -278,7 +278,7 @@ public class OpcUaClientServiceTests : IDisposable
|
|||||||
_sessionFactory.EnqueueSession(session);
|
_sessionFactory.EnqueueSession(session);
|
||||||
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
await _service.WriteValueAsync(new NodeId("ns=2;s=MyNode"), 42, TestContext.Current.CancellationToken);
|
await _service.WriteValueAsync(NodeId.Parse("ns=2;s=MyNode"), 42, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
session.WriteCount.ShouldBe(1);
|
session.WriteCount.ShouldBe(1);
|
||||||
session.ReadCount.ShouldBe(0); // No read for non-string values
|
session.ReadCount.ShouldBe(0); // No read for non-string values
|
||||||
@@ -291,7 +291,7 @@ public class OpcUaClientServiceTests : IDisposable
|
|||||||
public async Task WriteValueAsync_WhenDisconnected_Throws()
|
public async Task WriteValueAsync_WhenDisconnected_Throws()
|
||||||
{
|
{
|
||||||
await Should.ThrowAsync<InvalidOperationException>(() =>
|
await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||||
_service.WriteValueAsync(new NodeId("ns=2;s=MyNode"), 42));
|
_service.WriteValueAsync(NodeId.Parse("ns=2;s=MyNode"), 42));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -303,13 +303,13 @@ public class OpcUaClientServiceTests : IDisposable
|
|||||||
{
|
{
|
||||||
var session = new FakeSessionAdapter
|
var session = new FakeSessionAdapter
|
||||||
{
|
{
|
||||||
ReadResponse = new DataValue(StatusCodes.BadNodeIdUnknown) // Bad status, null Value
|
ReadResponse = DataValue.FromStatusCode(StatusCodes.BadNodeIdUnknown) // Bad status, null Value
|
||||||
};
|
};
|
||||||
_sessionFactory.EnqueueSession(session);
|
_sessionFactory.EnqueueSession(session);
|
||||||
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
|
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||||
_service.WriteValueAsync(new NodeId("ns=2;s=MyNode"), "42"));
|
_service.WriteValueAsync(NodeId.Parse("ns=2;s=MyNode"), "42"));
|
||||||
|
|
||||||
ex.Message.ShouldContain("Cannot infer target type");
|
ex.Message.ShouldContain("Cannot infer target type");
|
||||||
}
|
}
|
||||||
@@ -323,17 +323,90 @@ public class OpcUaClientServiceTests : IDisposable
|
|||||||
{
|
{
|
||||||
var session = new FakeSessionAdapter
|
var session = new FakeSessionAdapter
|
||||||
{
|
{
|
||||||
ReadResponse = new DataValue(StatusCodes.BadNotReadable)
|
ReadResponse = DataValue.FromStatusCode(StatusCodes.BadNotReadable)
|
||||||
};
|
};
|
||||||
_sessionFactory.EnqueueSession(session);
|
_sessionFactory.EnqueueSession(session);
|
||||||
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
|
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||||
_service.WriteValueAsync(new NodeId("ns=2;s=MyNode"), "42"));
|
_service.WriteValueAsync(NodeId.Parse("ns=2;s=MyNode"), "42"));
|
||||||
|
|
||||||
ex.Message.ShouldContain("ns=2;s=MyNode");
|
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 ---
|
// --- Browse tests ---
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -348,7 +421,7 @@ public class OpcUaClientServiceTests : IDisposable
|
|||||||
[
|
[
|
||||||
new ReferenceDescription
|
new ReferenceDescription
|
||||||
{
|
{
|
||||||
NodeId = new ExpandedNodeId("ns=2;s=Child1"),
|
NodeId = ExpandedNodeId.Parse("ns=2;s=Child1"),
|
||||||
DisplayName = new LocalizedText("Child1"),
|
DisplayName = new LocalizedText("Child1"),
|
||||||
NodeClass = NodeClass.Variable
|
NodeClass = NodeClass.Variable
|
||||||
}
|
}
|
||||||
@@ -397,19 +470,19 @@ public class OpcUaClientServiceTests : IDisposable
|
|||||||
[
|
[
|
||||||
new ReferenceDescription
|
new ReferenceDescription
|
||||||
{
|
{
|
||||||
NodeId = new ExpandedNodeId("ns=2;s=Folder1"),
|
NodeId = ExpandedNodeId.Parse("ns=2;s=Folder1"),
|
||||||
DisplayName = new LocalizedText("Folder1"),
|
DisplayName = new LocalizedText("Folder1"),
|
||||||
NodeClass = NodeClass.Object
|
NodeClass = NodeClass.Object
|
||||||
},
|
},
|
||||||
new ReferenceDescription
|
new ReferenceDescription
|
||||||
{
|
{
|
||||||
NodeId = new ExpandedNodeId("ns=2;s=Folder2"),
|
NodeId = ExpandedNodeId.Parse("ns=2;s=Folder2"),
|
||||||
DisplayName = new LocalizedText("Folder2"),
|
DisplayName = new LocalizedText("Folder2"),
|
||||||
NodeClass = NodeClass.Object
|
NodeClass = NodeClass.Object
|
||||||
},
|
},
|
||||||
new ReferenceDescription
|
new ReferenceDescription
|
||||||
{
|
{
|
||||||
NodeId = new ExpandedNodeId("ns=2;s=Tag"),
|
NodeId = ExpandedNodeId.Parse("ns=2;s=Tag"),
|
||||||
DisplayName = new LocalizedText("Tag"),
|
DisplayName = new LocalizedText("Tag"),
|
||||||
NodeClass = NodeClass.Variable
|
NodeClass = NodeClass.Variable
|
||||||
}
|
}
|
||||||
@@ -437,7 +510,7 @@ public class OpcUaClientServiceTests : IDisposable
|
|||||||
[
|
[
|
||||||
new ReferenceDescription
|
new ReferenceDescription
|
||||||
{
|
{
|
||||||
NodeId = new ExpandedNodeId("ns=2;s=A"),
|
NodeId = ExpandedNodeId.Parse("ns=2;s=A"),
|
||||||
DisplayName = new LocalizedText("A"),
|
DisplayName = new LocalizedText("A"),
|
||||||
NodeClass = NodeClass.Variable
|
NodeClass = NodeClass.Variable
|
||||||
}
|
}
|
||||||
@@ -447,7 +520,7 @@ public class OpcUaClientServiceTests : IDisposable
|
|||||||
[
|
[
|
||||||
new ReferenceDescription
|
new ReferenceDescription
|
||||||
{
|
{
|
||||||
NodeId = new ExpandedNodeId("ns=2;s=B"),
|
NodeId = ExpandedNodeId.Parse("ns=2;s=B"),
|
||||||
DisplayName = new LocalizedText("B"),
|
DisplayName = new LocalizedText("B"),
|
||||||
NodeClass = NodeClass.Variable
|
NodeClass = NodeClass.Variable
|
||||||
}
|
}
|
||||||
@@ -484,7 +557,7 @@ public class OpcUaClientServiceTests : IDisposable
|
|||||||
_sessionFactory.EnqueueSession(session);
|
_sessionFactory.EnqueueSession(session);
|
||||||
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
await _service.SubscribeAsync(new NodeId("ns=2;s=MyNode"), 500, TestContext.Current.CancellationToken);
|
await _service.SubscribeAsync(NodeId.Parse("ns=2;s=MyNode"), 500, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
session.CreatedSubscriptions.Count.ShouldBe(1);
|
session.CreatedSubscriptions.Count.ShouldBe(1);
|
||||||
session.CreatedSubscriptions[0].AddDataChangeCount.ShouldBe(1);
|
session.CreatedSubscriptions[0].AddDataChangeCount.ShouldBe(1);
|
||||||
@@ -500,8 +573,8 @@ public class OpcUaClientServiceTests : IDisposable
|
|||||||
_sessionFactory.EnqueueSession(session);
|
_sessionFactory.EnqueueSession(session);
|
||||||
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
await _service.SubscribeAsync(new NodeId("ns=2;s=MyNode"), ct: TestContext.Current.CancellationToken);
|
await _service.SubscribeAsync(NodeId.Parse("ns=2;s=MyNode"), ct: TestContext.Current.CancellationToken);
|
||||||
await _service.SubscribeAsync(new NodeId("ns=2;s=MyNode"), ct: TestContext.Current.CancellationToken); // duplicate
|
await _service.SubscribeAsync(NodeId.Parse("ns=2;s=MyNode"), ct: TestContext.Current.CancellationToken); // duplicate
|
||||||
|
|
||||||
session.CreatedSubscriptions[0].AddDataChangeCount.ShouldBe(1);
|
session.CreatedSubscriptions[0].AddDataChangeCount.ShouldBe(1);
|
||||||
}
|
}
|
||||||
@@ -520,7 +593,7 @@ public class OpcUaClientServiceTests : IDisposable
|
|||||||
DataChangedEventArgs? received = null;
|
DataChangedEventArgs? received = null;
|
||||||
_service.DataChanged += (_, e) => received = e;
|
_service.DataChanged += (_, e) => received = e;
|
||||||
|
|
||||||
await _service.SubscribeAsync(new NodeId("ns=2;s=MyNode"), 500, TestContext.Current.CancellationToken);
|
await _service.SubscribeAsync(NodeId.Parse("ns=2;s=MyNode"), 500, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
// Simulate data change
|
// Simulate data change
|
||||||
var handle = fakeSub.ActiveHandles.First();
|
var handle = fakeSub.ActiveHandles.First();
|
||||||
@@ -528,7 +601,7 @@ public class OpcUaClientServiceTests : IDisposable
|
|||||||
|
|
||||||
received.ShouldNotBeNull();
|
received.ShouldNotBeNull();
|
||||||
received!.NodeId.ShouldBe("ns=2;s=MyNode");
|
received!.NodeId.ShouldBe("ns=2;s=MyNode");
|
||||||
received.Value.Value.ShouldBe(99);
|
received.Value.WrappedValue.ShouldBe(Variant.From(99));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -542,8 +615,8 @@ public class OpcUaClientServiceTests : IDisposable
|
|||||||
_sessionFactory.EnqueueSession(session);
|
_sessionFactory.EnqueueSession(session);
|
||||||
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
await _service.SubscribeAsync(new NodeId("ns=2;s=MyNode"), ct: TestContext.Current.CancellationToken);
|
await _service.SubscribeAsync(NodeId.Parse("ns=2;s=MyNode"), ct: TestContext.Current.CancellationToken);
|
||||||
await _service.UnsubscribeAsync(new NodeId("ns=2;s=MyNode"), TestContext.Current.CancellationToken);
|
await _service.UnsubscribeAsync(NodeId.Parse("ns=2;s=MyNode"), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
fakeSub.RemoveCount.ShouldBe(1);
|
fakeSub.RemoveCount.ShouldBe(1);
|
||||||
}
|
}
|
||||||
@@ -558,7 +631,7 @@ public class OpcUaClientServiceTests : IDisposable
|
|||||||
_sessionFactory.EnqueueSession(session);
|
_sessionFactory.EnqueueSession(session);
|
||||||
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
await _service.UnsubscribeAsync(new NodeId("ns=2;s=NotSubscribed"), TestContext.Current.CancellationToken);
|
await _service.UnsubscribeAsync(NodeId.Parse("ns=2;s=NotSubscribed"), TestContext.Current.CancellationToken);
|
||||||
// Should not throw
|
// Should not throw
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -569,7 +642,7 @@ public class OpcUaClientServiceTests : IDisposable
|
|||||||
public async Task SubscribeAsync_WhenDisconnected_Throws()
|
public async Task SubscribeAsync_WhenDisconnected_Throws()
|
||||||
{
|
{
|
||||||
await Should.ThrowAsync<InvalidOperationException>(() =>
|
await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||||
_service.SubscribeAsync(new NodeId("ns=2;s=MyNode")));
|
_service.SubscribeAsync(NodeId.Parse("ns=2;s=MyNode")));
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Alarm subscription tests ---
|
// --- Alarm subscription tests ---
|
||||||
@@ -742,7 +815,7 @@ public class OpcUaClientServiceTests : IDisposable
|
|||||||
_sessionFactory.EnqueueSession(session);
|
_sessionFactory.EnqueueSession(session);
|
||||||
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
var results = await _service.HistoryReadRawAsync(new NodeId("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, ct: TestContext.Current.CancellationToken);
|
var results = await _service.HistoryReadRawAsync(NodeId.Parse("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, ct: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
results.Count.ShouldBe(2);
|
results.Count.ShouldBe(2);
|
||||||
session.HistoryReadRawCount.ShouldBe(1);
|
session.HistoryReadRawCount.ShouldBe(1);
|
||||||
@@ -755,7 +828,7 @@ public class OpcUaClientServiceTests : IDisposable
|
|||||||
public async Task HistoryReadRawAsync_WhenDisconnected_Throws()
|
public async Task HistoryReadRawAsync_WhenDisconnected_Throws()
|
||||||
{
|
{
|
||||||
await Should.ThrowAsync<InvalidOperationException>(() =>
|
await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||||
_service.HistoryReadRawAsync(new NodeId("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow));
|
_service.HistoryReadRawAsync(NodeId.Parse("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -769,7 +842,7 @@ public class OpcUaClientServiceTests : IDisposable
|
|||||||
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
await Should.ThrowAsync<ServiceResultException>(() =>
|
await Should.ThrowAsync<ServiceResultException>(() =>
|
||||||
_service.HistoryReadRawAsync(new NodeId("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow));
|
_service.HistoryReadRawAsync(NodeId.Parse("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -786,7 +859,7 @@ public class OpcUaClientServiceTests : IDisposable
|
|||||||
_sessionFactory.EnqueueSession(session);
|
_sessionFactory.EnqueueSession(session);
|
||||||
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
await _service.ConnectAsync(ValidSettings(), 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);
|
var results = await _service.HistoryReadAggregateAsync(NodeId.Parse("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, AggregateType.Average, ct: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
results.Count.ShouldBe(1);
|
results.Count.ShouldBe(1);
|
||||||
session.HistoryReadAggregateCount.ShouldBe(1);
|
session.HistoryReadAggregateCount.ShouldBe(1);
|
||||||
@@ -800,7 +873,7 @@ public class OpcUaClientServiceTests : IDisposable
|
|||||||
{
|
{
|
||||||
await Should.ThrowAsync<InvalidOperationException>(() =>
|
await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||||
_service.HistoryReadAggregateAsync(
|
_service.HistoryReadAggregateAsync(
|
||||||
new NodeId("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow,
|
NodeId.Parse("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow,
|
||||||
AggregateType.Average));
|
AggregateType.Average));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -816,7 +889,7 @@ public class OpcUaClientServiceTests : IDisposable
|
|||||||
|
|
||||||
await Should.ThrowAsync<ServiceResultException>(() =>
|
await Should.ThrowAsync<ServiceResultException>(() =>
|
||||||
_service.HistoryReadAggregateAsync(
|
_service.HistoryReadAggregateAsync(
|
||||||
new NodeId("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow,
|
NodeId.Parse("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow,
|
||||||
AggregateType.Average));
|
AggregateType.Average));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -840,7 +913,7 @@ public class OpcUaClientServiceTests : IDisposable
|
|||||||
return new DataValue(new Variant(["urn:server1", "urn:server2"]), StatusCodes.Good);
|
return new DataValue(new Variant(["urn:server1", "urn:server2"]), StatusCodes.Good);
|
||||||
if (nodeId == VariableIds.Server_ServerArray)
|
if (nodeId == VariableIds.Server_ServerArray)
|
||||||
return new DataValue(new Variant(["urn:server1"]), StatusCodes.Good);
|
return new DataValue(new Variant(["urn:server1"]), StatusCodes.Good);
|
||||||
return new DataValue(StatusCodes.BadNodeIdUnknown);
|
return DataValue.FromStatusCode(StatusCodes.BadNodeIdUnknown);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
_sessionFactory.EnqueueSession(session);
|
_sessionFactory.EnqueueSession(session);
|
||||||
@@ -933,9 +1006,9 @@ public class OpcUaClientServiceTests : IDisposable
|
|||||||
ReadResponseFunc = nodeId =>
|
ReadResponseFunc = nodeId =>
|
||||||
{
|
{
|
||||||
if (nodeId == VariableIds.Server_ServerRedundancy_RedundancySupport)
|
if (nodeId == VariableIds.Server_ServerRedundancy_RedundancySupport)
|
||||||
return new DataValue(StatusCodes.BadNotReadable); // bad status, null Value
|
return DataValue.FromStatusCode(StatusCodes.BadNotReadable); // bad status, null Value
|
||||||
if (nodeId == VariableIds.Server_ServiceLevel)
|
if (nodeId == VariableIds.Server_ServiceLevel)
|
||||||
return new DataValue(StatusCodes.BadNotReadable);
|
return DataValue.FromStatusCode(StatusCodes.BadNotReadable);
|
||||||
throw new ServiceResultException(StatusCodes.BadNodeIdUnknown);
|
throw new ServiceResultException(StatusCodes.BadNodeIdUnknown);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1046,7 +1119,7 @@ public class OpcUaClientServiceTests : IDisposable
|
|||||||
var result = await _service.AcknowledgeAlarmAsync("ns=2;s=Cond", new byte[] { 1, 2 }, "acked", TestContext.Current.CancellationToken);
|
var result = await _service.AcknowledgeAlarmAsync("ns=2;s=Cond", new byte[] { 1, 2 }, "acked", TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
StatusCode.IsBad(result).ShouldBeTrue();
|
StatusCode.IsBad(result).ShouldBeTrue();
|
||||||
result.Code.ShouldBe(StatusCodes.BadConditionAlreadyEnabled);
|
result.Code.ShouldBe(StatusCodes.BadConditionAlreadyEnabled.Code);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -1105,7 +1178,7 @@ public class OpcUaClientServiceTests : IDisposable
|
|||||||
var result = await _service.ConfirmAlarmAsync("ns=2;s=Cond", new byte[] { 1, 2 }, "confirmed", TestContext.Current.CancellationToken);
|
var result = await _service.ConfirmAlarmAsync("ns=2;s=Cond", new byte[] { 1, 2 }, "confirmed", TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
StatusCode.IsBad(result).ShouldBeTrue();
|
StatusCode.IsBad(result).ShouldBeTrue();
|
||||||
result.Code.ShouldBe(StatusCodes.BadConditionAlreadyEnabled);
|
result.Code.ShouldBe(StatusCodes.BadConditionAlreadyEnabled.Code);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- ShelveAlarm tests ---
|
// --- ShelveAlarm tests ---
|
||||||
@@ -1186,7 +1259,7 @@ public class OpcUaClientServiceTests : IDisposable
|
|||||||
var result = await _service.ShelveAlarmAsync("ns=2;s=Cond", ShelveKind.OneShot, ct: TestContext.Current.CancellationToken);
|
var result = await _service.ShelveAlarmAsync("ns=2;s=Cond", ShelveKind.OneShot, ct: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
StatusCode.IsBad(result).ShouldBeTrue();
|
StatusCode.IsBad(result).ShouldBeTrue();
|
||||||
result.Code.ShouldBe(StatusCodes.BadConditionAlreadyShelved);
|
result.Code.ShouldBe(StatusCodes.BadConditionAlreadyShelved.Code);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Enable / Disable tests (H4 client path) ---
|
// --- Enable / Disable tests (H4 client path) ---
|
||||||
@@ -1249,7 +1322,7 @@ public class OpcUaClientServiceTests : IDisposable
|
|||||||
var result = await _service.EnableAsync("ns=2;s=Cond", TestContext.Current.CancellationToken);
|
var result = await _service.EnableAsync("ns=2;s=Cond", TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
StatusCode.IsBad(result).ShouldBeTrue();
|
StatusCode.IsBad(result).ShouldBeTrue();
|
||||||
result.Code.ShouldBe(StatusCodes.BadConditionAlreadyEnabled);
|
result.Code.ShouldBe(StatusCodes.BadConditionAlreadyEnabled.Code);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -1270,7 +1343,7 @@ public class OpcUaClientServiceTests : IDisposable
|
|||||||
var result = await _service.DisableAsync("ns=2;s=Cond", TestContext.Current.CancellationToken);
|
var result = await _service.DisableAsync("ns=2;s=Cond", TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
StatusCode.IsBad(result).ShouldBeTrue();
|
StatusCode.IsBad(result).ShouldBeTrue();
|
||||||
result.Code.ShouldBe(StatusCodes.BadConditionAlreadyDisabled);
|
result.Code.ShouldBe(StatusCodes.BadConditionAlreadyDisabled.Code);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Missing standard alarm fields (#598: the Galaxy-era supplemental reads are gone) ---
|
// --- Missing standard alarm fields (#598: the Galaxy-era supplemental reads are gone) ---
|
||||||
@@ -1484,7 +1557,7 @@ public class OpcUaClientServiceTests : IDisposable
|
|||||||
|
|
||||||
var gate = new TaskCompletionSource();
|
var gate = new TaskCompletionSource();
|
||||||
session1.CreateSubscriptionGate = gate;
|
session1.CreateSubscriptionGate = gate;
|
||||||
var subscribe = _service.SubscribeAsync(new NodeId("ns=2;s=Racer"), 1000, TestContext.Current.CancellationToken);
|
var subscribe = _service.SubscribeAsync(NodeId.Parse("ns=2;s=Racer"), 1000, TestContext.Current.CancellationToken);
|
||||||
await session1.CreateSubscriptionEntered.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
|
await session1.CreateSubscriptionEntered.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
session1.SimulateKeepAlive(false);
|
session1.SimulateKeepAlive(false);
|
||||||
@@ -1499,7 +1572,7 @@ public class OpcUaClientServiceTests : IDisposable
|
|||||||
replayed.AddDataChangeCount.ShouldBe(1);
|
replayed.AddDataChangeCount.ShouldBe(1);
|
||||||
|
|
||||||
// And it is the live subscription: unsubscribing removes it from session2's subscription, not session1's.
|
// And it is the live subscription: unsubscribing removes it from session2's subscription, not session1's.
|
||||||
await _service.UnsubscribeAsync(new NodeId("ns=2;s=Racer"), TestContext.Current.CancellationToken);
|
await _service.UnsubscribeAsync(NodeId.Parse("ns=2;s=Racer"), TestContext.Current.CancellationToken);
|
||||||
replayed.RemoveCount.ShouldBe(1);
|
replayed.RemoveCount.ShouldBe(1);
|
||||||
replayed.ActiveHandles.ShouldBeEmpty();
|
replayed.ActiveHandles.ShouldBeEmpty();
|
||||||
}
|
}
|
||||||
@@ -1548,7 +1621,7 @@ public class OpcUaClientServiceTests : IDisposable
|
|||||||
var tasks = new List<Task>();
|
var tasks = new List<Task>();
|
||||||
for (var i = 0; i < 50; i++)
|
for (var i = 0; i < 50; i++)
|
||||||
{
|
{
|
||||||
var nodeId = new NodeId($"ns=2;s=Node{i}");
|
var nodeId = NodeId.Parse($"ns=2;s=Node{i}");
|
||||||
tasks.Add(Task.Run(async () =>
|
tasks.Add(Task.Run(async () =>
|
||||||
{
|
{
|
||||||
await _service.SubscribeAsync(nodeId);
|
await _service.SubscribeAsync(nodeId);
|
||||||
@@ -1560,6 +1633,55 @@ public class OpcUaClientServiceTests : IDisposable
|
|||||||
await Should.NotThrowAsync(() => Task.WhenAll(tasks));
|
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 ---
|
// --- Dispose tests ---
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -1598,7 +1720,7 @@ public class OpcUaClientServiceTests : IDisposable
|
|||||||
await Should.ThrowAsync<ObjectDisposedException>(() =>
|
await Should.ThrowAsync<ObjectDisposedException>(() =>
|
||||||
_service.ConnectAsync(ValidSettings()));
|
_service.ConnectAsync(ValidSettings()));
|
||||||
await Should.ThrowAsync<ObjectDisposedException>(() =>
|
await Should.ThrowAsync<ObjectDisposedException>(() =>
|
||||||
_service.ReadValueAsync(new NodeId("ns=2;s=X")));
|
_service.ReadValueAsync(NodeId.Parse("ns=2;s=X")));
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Factory tests ---
|
// --- Factory tests ---
|
||||||
@@ -1643,7 +1765,7 @@ public class OpcUaClientServiceTests : IDisposable
|
|||||||
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
// Subscribe data (uses dataFakeSub from session.NextSubscription).
|
// Subscribe data (uses dataFakeSub from session.NextSubscription).
|
||||||
await _service.SubscribeAsync(new NodeId("ns=2;s=Node1"), ct: TestContext.Current.CancellationToken);
|
await _service.SubscribeAsync(NodeId.Parse("ns=2;s=Node1"), ct: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
// Now set NextSubscription on the session so SubscribeAlarmsAsync gets the alarm fake.
|
// Now set NextSubscription on the session so SubscribeAlarmsAsync gets the alarm fake.
|
||||||
session.NextSubscription = alarmFakeSub;
|
session.NextSubscription = alarmFakeSub;
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
|
|||||||
LastBrowseParentNodeId = parentNodeId;
|
LastBrowseParentNodeId = parentNodeId;
|
||||||
if (BrowseException != null) throw BrowseException;
|
if (BrowseException != null) throw BrowseException;
|
||||||
|
|
||||||
if (parentNodeId != null && BrowseResultsByParent.TryGetValue(parentNodeId.ToString(), out var perParent))
|
if (parentNodeId != null && BrowseResultsByParent.TryGetValue(parentNodeId.Value.ToString(), out var perParent))
|
||||||
return Task.FromResult(perParent);
|
return Task.FromResult(perParent);
|
||||||
|
|
||||||
return Task.FromResult(BrowseResults);
|
return Task.FromResult(BrowseResults);
|
||||||
|
|||||||
@@ -23,6 +23,26 @@ public class ReadWriteViewModelTests
|
|||||||
_vm = new ReadWriteViewModel(_service, dispatcher);
|
_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>
|
/// <summary>Verifies that the read command cannot execute when disconnected.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ReadCommand_CannotExecute_WhenDisconnected()
|
public void ReadCommand_CannotExecute_WhenDisconnected()
|
||||||
|
|||||||
Reference in New Issue
Block a user