diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/HistoryReadCommand.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/HistoryReadCommand.cs
index 54465e3b..fc18bc63 100644
--- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/HistoryReadCommand.cs
+++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/HistoryReadCommand.cs
@@ -1,3 +1,4 @@
+using ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
using System.Globalization;
using CliFx.Attributes;
using CliFx.Exceptions;
@@ -210,7 +211,7 @@ public class HistoryReadCommand : CommandBase
: StatusCode.IsBad(dv.StatusCode) ? "Bad"
: "Uncertain";
await console.Output.WriteLineAsync(
- $"{dv.SourceTimestamp.ToString("O"),-35} {ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers.ValueConverter.ToClrObject(dv.WrappedValue)?.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}");
}
@@ -244,7 +245,7 @@ public class HistoryReadCommand : CommandBase
: StatusCode.IsBad(dv.StatusCode) ? "Bad"
: "Uncertain";
await console.Output.WriteLineAsync(
- $"{dv.SourceTimestamp.ToString("O"),-35} {ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers.ValueConverter.ToClrObject(dv.WrappedValue),-15} {status}");
+ $"{dv.SourceTimestamp.ToString("O"),-35} {ValueConverter.ToClrObject(dv.WrappedValue),-15} {status}");
}
await console.Output.WriteLineAsync();
@@ -294,7 +295,7 @@ public class HistoryReadCommand : CommandBase
/// Formats one projected event field for the console: byte strings as hex, times as ISO 8601, null as -.
/// The field value.
/// Its display text.
- private static string FormatEventField(Variant field) => ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers.ValueConverter.ToClrObject(field) switch
+ private static string FormatEventField(Variant field) => ValueConverter.ToClrObject(field) switch
{
null => "-",
byte[] bytes => Convert.ToHexString(bytes),
diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/HistoryUpdateCommand.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/HistoryUpdateCommand.cs
index 2dcb4d85..7f6bee74 100644
--- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/HistoryUpdateCommand.cs
+++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/HistoryUpdateCommand.cs
@@ -1,3 +1,4 @@
+using ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
using System.Globalization;
using CliFx.Attributes;
using CliFx.Exceptions;
@@ -93,7 +94,7 @@ public class HistoryUpdateCommand : CommandBase
for (var i = 0; i < values.Count; i++)
{
var ts = ParseTime(times[i], "--time");
- updates.Add(new DataValue(ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers.ValueConverter.ToVariant(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
diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/ReadCommand.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/ReadCommand.cs
index 93121dac..5677b908 100644
--- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/ReadCommand.cs
+++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/ReadCommand.cs
@@ -1,3 +1,4 @@
+using ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
using CliFx.Attributes;
using CliFx.Exceptions;
using CliFx.Infrastructure;
@@ -64,7 +65,7 @@ public class ReadCommand : CommandBase
}
await console.Output.WriteLineAsync($"Node: {NodeId}");
- await console.Output.WriteLineAsync($"Value: {ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers.ValueConverter.ToClrObject(value.WrappedValue)}");
+ await console.Output.WriteLineAsync($"Value: {ValueConverter.ToClrObject(value.WrappedValue)}");
await console.Output.WriteLineAsync($"Status: {value.StatusCode}");
await console.Output.WriteLineAsync($"Source Time: {value.SourceTimestamp:O}");
await console.Output.WriteLineAsync($"Server Time: {value.ServerTimestamp:O}");
diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/SubscribeCommand.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/SubscribeCommand.cs
index 67876f65..13d37903 100644
--- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/SubscribeCommand.cs
+++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/SubscribeCommand.cs
@@ -1,3 +1,4 @@
+using ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
using System.Collections.Concurrent;
using System.Threading.Channels;
using CliFx.Attributes;
@@ -123,14 +124,14 @@ public class SubscribeCommand : CommandBase
try
{
var key = e.NodeId.ToString();
- lastStatus[key] = (e.Value.StatusCode, DateTime.UtcNow, ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers.ValueConverter.ToClrObject(e.Value.WrappedValue));
+ lastStatus[key] = (e.Value.StatusCode, DateTime.UtcNow, ValueConverter.ToClrObject(e.Value.WrappedValue));
updateCount.AddOrUpdate(key, 1, (_, v) => v + 1);
if (!StatusCode.IsGood(e.Value.StatusCode))
everBad.TryAdd(key, 0);
if (!Quiet)
{
var line =
- $"[{e.Value.SourceTimestamp:O}] {displayNameByNodeId.GetValueOrDefault(key, key)} = {ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers.ValueConverter.ToClrObject(e.Value.WrappedValue)} ({e.Value.StatusCode})";
+ $"[{e.Value.SourceTimestamp:O}] {displayNameByNodeId.GetValueOrDefault(key, key)} = {ValueConverter.ToClrObject(e.Value.WrappedValue)} ({e.Value.StatusCode})";
outputChannel.Writer.TryWrite(line);
}
}
diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/WriteCommand.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/WriteCommand.cs
index 9b288ad4..1810d286 100644
--- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/WriteCommand.cs
+++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/WriteCommand.cs
@@ -54,7 +54,7 @@ public class WriteCommand : CommandBase
// Read current value to determine type for conversion
var currentValue = await service.ReadValueAsync(nodeId, ct);
- var typedValue = ValueConverter.ConvertValue(Value, ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers.ValueConverter.ToClrObject(currentValue.WrappedValue));
+ var typedValue = ValueConverter.ConvertValue(Value, ValueConverter.ToClrObject(currentValue.WrappedValue));
var statusCode = await service.WriteValueAsync(nodeId, typedValue, ct);
diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultApplicationConfigurationFactory.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultApplicationConfigurationFactory.cs
index 0f7eedc0..ba33fd53 100644
--- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultApplicationConfigurationFactory.cs
+++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultApplicationConfigurationFactory.cs
@@ -70,7 +70,7 @@ internal sealed class DefaultApplicationConfigurationFactory : IApplicationConfi
ApplicationType = ApplicationType.Client,
ApplicationConfiguration = config
};
- await app.CheckApplicationInstanceCertificatesAsync(false, ct: ct);
+ await app.CheckApplicationInstanceCertificatesAsync(false, lifeTimeInMonths: 2048, ct: ct);
}
}
catch
diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultSessionAdapter.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultSessionAdapter.cs
index e140a924..70025bdd 100644
--- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultSessionAdapter.cs
+++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultSessionAdapter.cs
@@ -115,18 +115,14 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
{
// Pass the caller's token so a cancelled browse does not block on the continuation
// round-trip.
- var response = await _session.BrowseNextAsync(null, false, [ByteString.From(continuationPoint)], ct);
- if (response.Results.Count == 0)
- throw new ServiceResultException(response.ResponseHeader.ServiceResult, "BrowseNext response contained no results.");
- var result = response.Results[0];
- if (StatusCode.IsBad(result.StatusCode)) throw new ServiceResultException(result.StatusCode);
- return (result.ContinuationPoint.IsNull ? null : result.ContinuationPoint.ToArray(), result.References);
+ var (_, nextCp, nextRefs) = await _session.BrowseNextAsync(null, false, ByteString.From(continuationPoint), ct);
+ return (nextCp.IsNull ? null : nextCp.ToArray(), nextRefs);
}
///
public async Task ReleaseBrowseContinuationPointAsync(byte[] continuationPoint, CancellationToken ct)
{
- await _session.BrowseNextAsync(null, true, [ByteString.From(continuationPoint)], ct).ConfigureAwait(false);
+ await _session.BrowseNextAsync(null, true, ByteString.From(continuationPoint), ct).ConfigureAwait(false);
}
///
@@ -276,9 +272,20 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
if (results.Count > 0)
{
var result = results[0];
- HistoryReadStatus.ThrowIfRefused(result.StatusCode, nodeId);
- if (result.HistoryData.TryGetValue(out HistoryData? historyData))
- allValues.AddRange(historyData.DataValues);
+ try
+ {
+ HistoryReadStatus.ThrowIfRefused(result.StatusCode, nodeId);
+ if (result.HistoryData.TryGetValue(out HistoryData? historyData))
+ allValues.AddRange(historyData.DataValues);
+ }
+ finally
+ {
+ // Keep the existing one-shot processed read; it owns no outstanding server cursor on return.
+ if (!result.ContinuationPoint.IsEmpty)
+ await HistoryReadPaging.ReleaseAsync(
+ (cp, release, token) => ReadHistoryPageAsync(details, nodeId, cp, release, TimestampsToReturn.Source, token),
+ result.ContinuationPoint.ToArray(), nodeId).ConfigureAwait(false);
+ }
}
return allValues;
diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultSubscriptionAdapter.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultSubscriptionAdapter.cs
index 6473abf6..6a49f91c 100644
--- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultSubscriptionAdapter.cs
+++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultSubscriptionAdapter.cs
@@ -14,6 +14,8 @@ internal sealed class DefaultSubscriptionAdapter : ISubscriptionAdapter
private readonly Subscription _subscription;
private readonly Func? _detach;
private int _detached;
+ private int _finishing;
+ private readonly TaskCompletionSource _finished = new(TaskCreationOptions.RunContinuationsAsynchronously);
///
/// 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) =>
{
- if (e.NotificationValue is MonitoredItemNotification notification)
+ if (Volatile.Read(ref _finishing) == 0 && e.NotificationValue is MonitoredItemNotification notification)
onDataChange(nodeId.ToString(), notification.Value);
};
@@ -90,7 +92,7 @@ internal sealed class DefaultSubscriptionAdapter : ISubscriptionAdapter
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);
@@ -112,19 +114,28 @@ internal sealed class DefaultSubscriptionAdapter : ISubscriptionAdapter
///
public async Task DeleteAsync(CancellationToken ct)
{
+ if (Interlocked.Exchange(ref _finishing, 1) != 0)
+ {
+ await _finished.Task.WaitAsync(ct).ConfigureAwait(false);
+ return;
+ }
try
{
- // Forward the caller's token so the delete can be cancelled.
- await _subscription.DeleteAsync(true, ct);
- }
- catch (Exception ex)
- {
- Logger.Warning(ex, "Error deleting subscription");
- }
+ try { await _subscription.DeleteAsync(true, ct).ConfigureAwait(false); }
+ catch (Exception ex) { Logger.Warning(ex, "Error deleting subscription"); }
- // Detach whether or not the server-side delete succeeded: the adapter is finished with it either way.
- await DetachAsync(ct);
- _monitoredItems.Clear();
+ // A cancelled delete still has local resources to release. Cleanup gets its own bounded token.
+ using var cleanup = new CancellationTokenSource(TimeSpan.FromSeconds(5));
+ try { await DetachAsync(cleanup.Token).WaitAsync(cleanup.Token).ConfigureAwait(false); }
+ catch (Exception ex) { Logger.Warning(ex, "Error detaching subscription"); }
+ finally
+ {
+ _monitoredItems.Clear();
+ try { await Task.Run(_subscription.Dispose).WaitAsync(cleanup.Token).ConfigureAwait(false); }
+ catch (Exception ex) { Logger.Warning(ex, "Error disposing subscription resources"); }
+ }
+ }
+ finally { _finished.TrySetResult(); }
}
private async Task DetachAsync(CancellationToken ct)
@@ -145,21 +156,6 @@ internal sealed class DefaultSubscriptionAdapter : ISubscriptionAdapter
///
public void Dispose()
{
- // One bounded bridge (5 s) for delete + detach, so a dispose never blocks longer than before (review m6).
- // DetachAsync swallows its own failures; a failed delete must not skip the detach.
- try
- {
- DisposeBridge.Run(async ct =>
- {
- try { await _subscription.DeleteAsync(true, ct); }
- catch (Exception ex) { Logger.Warning(ex, "Error deleting subscription"); }
- await DetachAsync(ct);
- });
- }
- catch
- {
- }
-
- _monitoredItems.Clear();
+ DisposeBridge.Run(DeleteAsync);
}
}
diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Helpers/HistoryReadPaging.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Helpers/HistoryReadPaging.cs
index 51405b13..c1b30a83 100644
--- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Helpers/HistoryReadPaging.cs
+++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Helpers/HistoryReadPaging.cs
@@ -61,10 +61,10 @@ internal static class HistoryReadPaging
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
- // occupying one of the session's continuation-point slots, then surface the cancellation.
+ // A refused/failed/cancelled continuation still leaves the last cursor owned by this read.
+ // Best-effort release must preserve the original operation failure.
if (continuationPoint is { Length: > 0 }) await ReleaseAsync(readPage, continuationPoint, nodeId);
throw;
}
@@ -81,12 +81,12 @@ internal static class HistoryReadPaging
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
{
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)
{
diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Helpers/ValueConverter.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Helpers/ValueConverter.cs
index a2746d0f..bd161882 100644
--- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Helpers/ValueConverter.cs
+++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Helpers/ValueConverter.cs
@@ -15,20 +15,22 @@ public static class ValueConverter
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) return Variant.From(ByteString.From(bytes.AsSpan()));
- if (value is ByteString byteString) return Variant.From(byteString.Copy());
+ 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 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 (variant.TypeInfo.IsArray && variant.AsBoxedObject(Variant.BoxingBehavior.Legacy) is Array array)
+ 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.TryGetValue(out ByteString retainedBytes))
- variant = Variant.From(retainedBytes.Copy());
+ else if (variant.TypeInfo.IsScalar && variant.TryGetValue(out ByteString retainedBytes))
+ variant = Variant.From(retainedBytes.IsNull ? default : retainedBytes.Copy());
return variant;
}
diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs
index d6a4c817..513fb30f 100644
--- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs
+++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs
@@ -310,7 +310,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
}
var handle = await subscription.AddDataChangeMonitoredItemAsync(
- nodeId, intervalMs, OnDataChangeNotification, ct);
+ nodeId, intervalMs, DataCallback(session, subscription), ct);
lock (_subscriptionLock)
{
@@ -394,7 +394,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
var filter = CreateAlarmEventFilter();
await subscription.AddEventMonitoredItemAsync(
- monitorNode, intervalMs, filter, OnAlarmEventNotification, ct);
+ monitorNode, intervalMs, filter, AlarmCallback(session, subscription), ct);
lock (_subscriptionLock)
{
@@ -989,7 +989,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
}
var handle = await dataSubscription.AddDataChangeMonitoredItemAsync(
- nodeId, intervalMs, OnDataChangeNotification, CancellationToken.None);
+ nodeId, intervalMs, DataCallback(session, dataSubscription), CancellationToken.None);
lock (_subscriptionLock)
{
@@ -1012,7 +1012,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
lock (_subscriptionLock) _alarmSubscription = replayedAlarms;
var filter = CreateAlarmEventFilter();
await replayedAlarms.AddEventMonitoredItemAsync(
- monitorNode, intervalMs, filter, OnAlarmEventNotification, CancellationToken.None);
+ monitorNode, intervalMs, filter, AlarmCallback(session, replayedAlarms), CancellationToken.None);
lock (_subscriptionLock)
{
@@ -1026,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 DataCallback(ISessionAdapter session, ISubscriptionAdapter subscription) =>
+ (nodeId, value) =>
+ {
+ if (IsCurrentNotification(session, subscription, false)) OnDataChangeNotification(nodeId, value);
+ };
+
+ private Action AlarmCallback(ISessionAdapter session, ISubscriptionAdapter subscription) =>
+ fields =>
+ {
+ if (IsCurrentNotification(session, subscription, true)) OnAlarmEventNotification(fields);
+ };
+
private void OnDataChangeNotification(string nodeId, DataValue value)
{
DataChanged?.Invoke(this, new DataChangedEventArgs(nodeId, value));
diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/HistoryViewModel.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/HistoryViewModel.cs
index 15ca66cb..08f17cb3 100644
--- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/HistoryViewModel.cs
+++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/HistoryViewModel.cs
@@ -1,3 +1,4 @@
+using ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
@@ -97,7 +98,7 @@ public partial class HistoryViewModel : ObservableObject
{
foreach (var dv in values)
Results.Add(new HistoryValueViewModel(
- Helpers.ValueFormatter.Format(ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers.ValueConverter.ToClrObject(dv.WrappedValue)),
+ Helpers.ValueFormatter.Format(ValueConverter.ToClrObject(dv.WrappedValue)),
Helpers.StatusCodeFormatter.Format(dv.StatusCode),
dv.SourceTimestamp.ToString("O"),
dv.ServerTimestamp.ToString("O")));
diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/ReadWriteViewModel.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/ReadWriteViewModel.cs
index e312f11f..e56d4c8c 100644
--- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/ReadWriteViewModel.cs
+++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/ReadWriteViewModel.cs
@@ -1,3 +1,4 @@
+using ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Opc.Ua;
@@ -76,7 +77,7 @@ public partial class ReadWriteViewModel : ObservableObject
_dispatcher.Post(() =>
{
- CurrentValue = Helpers.ValueFormatter.Format(ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers.ValueConverter.ToClrObject(dataValue.WrappedValue));
+ CurrentValue = Helpers.ValueFormatter.Format(ValueConverter.ToClrObject(dataValue.WrappedValue));
CurrentStatus = Helpers.StatusCodeFormatter.Format(dataValue.StatusCode);
SourceTimestamp = dataValue.SourceTimestamp.ToString("O");
ServerTimestamp = dataValue.ServerTimestamp.ToString("O");
diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/SubscriptionsViewModel.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/SubscriptionsViewModel.cs
index 4bfc2b4b..08bbbe9d 100644
--- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/SubscriptionsViewModel.cs
+++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/SubscriptionsViewModel.cs
@@ -1,3 +1,4 @@
+using ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using CommunityToolkit.Mvvm.ComponentModel;
@@ -75,7 +76,7 @@ public partial class SubscriptionsViewModel : ObservableObject
if (!_rowsByNodeId.TryGetValue(e.NodeId, out var rows)) return;
foreach (var item in rows)
{
- item.Value = Helpers.ValueFormatter.Format(ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers.ValueConverter.ToClrObject(e.Value.WrappedValue));
+ item.Value = Helpers.ValueFormatter.Format(ValueConverter.ToClrObject(e.Value.WrappedValue));
item.Status = Helpers.StatusCodeFormatter.Format(e.Value.StatusCode);
item.Timestamp = e.Value.SourceTimestamp.ToString("O");
}
@@ -281,7 +282,7 @@ public partial class SubscriptionsViewModel : ObservableObject
// Read current value to determine target type
var currentDataValue = await _service.ReadValueAsync(nodeId);
- var currentValue = ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers.ValueConverter.ToClrObject(currentDataValue.WrappedValue);
+ var currentValue = ValueConverter.ToClrObject(currentDataValue.WrappedValue);
// Try parsing to the target type before writing
try
diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/AlarmsCommandTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/AlarmsCommandTests.cs
index 4f9ce7fa..9e53195f 100644
--- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/AlarmsCommandTests.cs
+++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/AlarmsCommandTests.cs
@@ -54,7 +54,7 @@ public class AlarmsCommandTests
fakeService.SubscribeAlarmsCalls.Count.ShouldBe(1);
fakeService.SubscribeAlarmsCalls[0].SourceNodeId.ShouldNotBeNull();
- fakeService.SubscribeAlarmsCalls[0].SourceNodeId!.Identifier.ShouldBe("AlarmSource");
+ fakeService.SubscribeAlarmsCalls[0].SourceNodeId!.Value.IdentifierAsString.ShouldBe("AlarmSource");
}
/// Verifies that Execute with refresh requests condition refresh.
diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/BrowseCommandTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/BrowseCommandTests.cs
index 5cf87e5d..e48744d3 100644
--- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/BrowseCommandTests.cs
+++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/BrowseCommandTests.cs
@@ -56,7 +56,7 @@ public class BrowseCommandTests
fakeService.BrowseNodeIds.Count.ShouldBe(1);
fakeService.BrowseNodeIds[0].ShouldNotBeNull();
- fakeService.BrowseNodeIds[0]!.Identifier.ShouldBe("StartNode");
+ fakeService.BrowseNodeIds[0]!.Value.IdentifierAsString.ShouldBe("StartNode");
}
/// Verifies that Execute browses from null node when not specified.
diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/HistoryReadCommandTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/HistoryReadCommandTests.cs
index 62c8e139..0e2e1763 100644
--- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/HistoryReadCommandTests.cs
+++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/HistoryReadCommandTests.cs
@@ -61,7 +61,7 @@ public class HistoryReadCommandTests
fakeService.HistoryReadRawCalls.Count.ShouldBe(1);
fakeService.HistoryReadRawCalls[0].MaxValues.ShouldBe(500);
- fakeService.HistoryReadRawCalls[0].NodeId.Identifier.ShouldBe("HistNode");
+ fakeService.HistoryReadRawCalls[0].NodeId.IdentifierAsString.ShouldBe("HistNode");
}
/// Verifies AggregateRead execution calls HistoryReadAggregate.
@@ -211,11 +211,11 @@ public class HistoryReadCommandTests
var call = fakeService.HistoryReadEventsCalls.ShouldHaveSingleItem();
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"]);
- 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);
- 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();
var output = TestConsoleHelper.GetOutput(console);
@@ -243,10 +243,10 @@ public class HistoryReadCommandTests
await command.ExecuteAsync(console);
var filter = fakeService.HistoryReadEventsCalls.ShouldHaveSingleItem().Filter;
- filter.SelectClauses.Select(c => c.BrowsePath[0].Name).ShouldBe(["EventType", "Severity"]);
- var element = filter.WhereClause.Elements.ShouldHaveSingleItem();
+ filter.SelectClauses.ToArray()!.Select(c => c.BrowsePath[0].Name).ShouldBe(["EventType", "Severity"]);
+ var element = filter.WhereClause.Elements.ToArray()!.ShouldHaveSingleItem();
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);
TestConsoleHelper.GetOutput(console).ShouldContain("0 events returned.");
}
diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/HistoryUpdateCommandTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/HistoryUpdateCommandTests.cs
index 1074ae1f..d0eb20b4 100644
--- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/HistoryUpdateCommandTests.cs
+++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/HistoryUpdateCommandTests.cs
@@ -33,9 +33,9 @@ public class HistoryUpdateCommandTests
.BuildDetails().ShouldBeOfType();
d.PerformInsertReplace.ShouldBe(PerformUpdateType.Insert);
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.NodeId.Identifier.ShouldBe("HistNode");
+ d.NodeId.IdentifierAsString.ShouldBe("HistNode");
}
/// Replace and update map to their update types.
@@ -60,7 +60,7 @@ public class HistoryUpdateCommandTests
{
var d = Make(values: [value], times: ["2026-01-15T08:00:00Z"], type: type)
.BuildDetails().ShouldBeOfType();
- d.UpdateValues[0].Value.ShouldBeOfType(expected);
+ ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers.ValueConverter.ToClrObject(d.UpdateValues[0].WrappedValue).ShouldBeOfType(expected);
}
/// delete-raw builds DeleteRawModifiedDetails with IsDeleteModified=false.
@@ -211,7 +211,7 @@ public class HistoryUpdateCommandTests
public async Task Execute_UnnamedStatus_PrintsHex()
{
const uint unnamed = 0x8FFF0000u;
- StatusCodes.GetBrowseName(unnamed).ShouldBeNullOrEmpty(); // precondition: genuinely unnamed
+ new StatusCode(unnamed).SymbolicId.ShouldBeNullOrEmpty(); // precondition: genuinely unnamed
var svc = new FakeOpcUaClientService
{
HistoryUpdateResult = new HistoryUpdateOutcome(StatusCodes.Good, [StatusCodes.Good],
diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/NodeIdParserTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/NodeIdParserTests.cs
index 2b018508..0d837a20 100644
--- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/NodeIdParserTests.cs
+++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/NodeIdParserTests.cs
@@ -34,8 +34,8 @@ public class NodeIdParserTests
{
var result = NodeIdParser.Parse("ns=2;s=MyNode");
result.ShouldNotBeNull();
- result.NamespaceIndex.ShouldBe((ushort)2);
- result.Identifier.ShouldBe("MyNode");
+ result.Value.NamespaceIndex.ShouldBe((ushort)2);
+ result.Value.IdentifierAsString.ShouldBe("MyNode");
}
/// Verifies that Parse correctly parses numeric NodeId format (i=85).
@@ -44,7 +44,7 @@ public class NodeIdParserTests
{
var result = NodeIdParser.Parse("i=85");
result.ShouldNotBeNull();
- result.IdType.ShouldBe(IdType.Numeric);
+ result.Value.IdType.ShouldBe(IdType.Numeric);
}
/// Verifies that Parse treats bare numeric input as namespace 0 numeric NodeId.
@@ -53,8 +53,9 @@ public class NodeIdParserTests
{
var result = NodeIdParser.Parse("85");
result.ShouldNotBeNull();
- result.NamespaceIndex.ShouldBe((ushort)0);
- result.Identifier.ShouldBe((uint)85);
+ result.Value.NamespaceIndex.ShouldBe((ushort)0);
+ result.Value.TryGetValue(out uint identifier).ShouldBeTrue();
+ identifier.ShouldBe(85u);
}
/// Verifies that Parse trims whitespace padding from input.
@@ -63,7 +64,7 @@ public class NodeIdParserTests
{
var result = NodeIdParser.Parse(" ns=2;s=MyNode ");
result.ShouldNotBeNull();
- result.Identifier.ShouldBe("MyNode");
+ result.Value.IdentifierAsString.ShouldBe("MyNode");
}
/// Verifies that Parse throws FormatException for invalid input.
@@ -92,7 +93,36 @@ public class NodeIdParserTests
public void ParseRequired_ValidInput_ReturnsNodeId()
{
var result = NodeIdParser.ParseRequired("ns=2;s=TestNode");
- result.ShouldNotBeNull();
- result.Identifier.ShouldBe("TestNode");
+ result.IsNull.ShouldBeFalse();
+ result.IdentifierAsString.ShouldBe("TestNode");
+ }
+
+ [Fact]
+ public void Parse_OpaqueIdentifier_PreservesBytes()
+ {
+ var node = NodeIdParser.ParseRequired("ns=3;b=AQID");
+ node.IdType.ShouldBe(IdType.Opaque);
+ node.NamespaceIndex.ShouldBe((ushort)3);
+ node.TryGetValue(out ByteString bytes).ShouldBeTrue();
+ bytes.ToArray().ShouldBe(new byte[] { 1, 2, 3 });
+ }
+
+ [Fact]
+ public void Parse_StringIdentifierContainingEncodedText_StaysLiteral()
+ {
+ var node = NodeIdParser.ParseRequired("ns=4;s=ns=2;b=AQID");
+ node.IdType.ShouldBe(IdType.String);
+ node.NamespaceIndex.ShouldBe((ushort)4);
+ node.TryGetValue(out string? identifier).ShouldBeTrue();
+ identifier.ShouldBe("ns=2;b=AQID");
+ }
+
+ [Theory]
+ [InlineData("ns=bad;i=1")]
+ [InlineData("ns=2;b=invalid!")]
+ [InlineData("ns=2;i=invalid")]
+ public void Parse_InvalidEncodedIdentifier_IsRejected(string input)
+ {
+ Should.Throw(() => NodeIdParser.ParseRequired(input));
}
}
\ No newline at end of file
diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/ReadCommandTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/ReadCommandTests.cs
index 1dddb1bc..24893386 100644
--- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/ReadCommandTests.cs
+++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/ReadCommandTests.cs
@@ -56,7 +56,7 @@ public class ReadCommandTests
await command.ExecuteAsync(console);
fakeService.ReadNodeIds.Count.ShouldBe(1);
- fakeService.ReadNodeIds[0].Identifier.ShouldBe("MyVariable");
+ fakeService.ReadNodeIds[0].IdentifierAsString.ShouldBe("MyVariable");
}
/// Verifies that execute disconnects in finally.
diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/SubscribeCommandSummaryTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/SubscribeCommandSummaryTests.cs
index 8c265ada..b4c68d57 100644
--- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/SubscribeCommandSummaryTests.cs
+++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/SubscribeCommandSummaryTests.cs
@@ -222,8 +222,8 @@ public class SubscribeCommandSummaryTests
// Both Variables (depth 1 + depth 2) should have been subscribed.
fakeService.SubscribeCalls.Count.ShouldBeGreaterThanOrEqualTo(2);
- fakeService.SubscribeCalls.ShouldContain(c => c.NodeId.Identifier.ToString() == "Tag1");
- fakeService.SubscribeCalls.ShouldContain(c => c.NodeId.Identifier.ToString() == "Tag2");
+ fakeService.SubscribeCalls.ShouldContain(c => c.NodeId.IdentifierAsString.ToString() == "Tag1");
+ fakeService.SubscribeCalls.ShouldContain(c => c.NodeId.IdentifierAsString.ToString() == "Tag2");
var output = TestConsoleHelper.GetOutput(console);
output.ShouldContain("Browsing subtree of ns=2;s=Root");
diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/SubscribeCommandTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/SubscribeCommandTests.cs
index 82454a38..ed527028 100644
--- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/SubscribeCommandTests.cs
+++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/SubscribeCommandTests.cs
@@ -33,7 +33,7 @@ public class SubscribeCommandTests
fakeService.SubscribeCalls.Count.ShouldBe(1);
fakeService.SubscribeCalls[0].IntervalMs.ShouldBe(500);
- fakeService.SubscribeCalls[0].NodeId.Identifier.ShouldBe("TestVar");
+ fakeService.SubscribeCalls[0].NodeId.IdentifierAsString.ShouldBe("TestVar");
}
/// Verifies that ExecuteAsync unsubscribes when cancellation is requested.
diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Adapters/DefaultSubscriptionAdapterTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Adapters/DefaultSubscriptionAdapterTests.cs
index 7d728616..eea5ad7f 100644
--- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Adapters/DefaultSubscriptionAdapterTests.cs
+++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Adapters/DefaultSubscriptionAdapterTests.cs
@@ -11,6 +11,48 @@ namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.Adapters;
///
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);
+ }
+
/// DeleteAsync detaches the wrapped subscription exactly once, even when Dispose follows.
[Fact]
public async Task DeleteAsync_detaches_the_subscription_from_its_session_once()
diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Adapters/EndpointSelectorTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Adapters/EndpointSelectorTests.cs
index ba917d82..4dd88a22 100644
--- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Adapters/EndpointSelectorTests.cs
+++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Adapters/EndpointSelectorTests.cs
@@ -117,7 +117,7 @@ public class EndpointSelectorTests
var best = EndpointSelector.SelectBest(endpoints, "opc.tcp://external-host:4840",
MessageSecurityMode.Sign);
- new Uri(best.EndpointUrl).Host.ShouldBe("external-host");
+ new Uri(best.EndpointUrl.ShouldNotBeNull()).Host.ShouldBe("external-host");
}
///
@@ -169,7 +169,7 @@ public class EndpointSelectorTests
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.Port.ShouldBe(4841);
uri.AbsolutePath.ShouldBe("/OtOpcUa");
@@ -183,8 +183,8 @@ public class EndpointSelectorTests
var best = EndpointSelector.SelectBest(endpoints, "opc.tcp://localhost:4841", MessageSecurityMode.None);
- new Uri(best.EndpointUrl).Host.ShouldBe("internal-host");
- new Uri(best.EndpointUrl).Port.ShouldBe(4843);
+ new Uri(best.EndpointUrl.ShouldNotBeNull()).Host.ShouldBe("internal-host");
+ new Uri(best.EndpointUrl.ShouldNotBeNull()).Port.ShouldBe(4843);
}
/// Identical host, port and scheme: nothing to rewrite.
@@ -206,7 +206,7 @@ public class EndpointSelectorTests
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.Port.ShouldBe(4840);
}
@@ -219,7 +219,7 @@ public class EndpointSelectorTests
var best = EndpointSelector.SelectBest(endpoints, "opc.tcp://[::1]:4841", MessageSecurityMode.None);
- var uri = new Uri(best.EndpointUrl);
+ var uri = new Uri(best.EndpointUrl.ShouldNotBeNull());
uri.Host.ShouldBe("[::1]");
uri.Port.ShouldBe(4841);
}
diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/AlarmEventFilterTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/AlarmEventFilterTests.cs
index f0349bc5..348f61f9 100644
--- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/AlarmEventFilterTests.cs
+++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/AlarmEventFilterTests.cs
@@ -18,7 +18,7 @@ public sealed class AlarmEventFilterTests
{
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('/'));
}
/// The two-state ids are selected by their two-element browse paths, at the indices the parser reads.
@@ -27,7 +27,7 @@ public sealed class AlarmEventFilterTests
{
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");
clauses[8].BrowsePath.Count.ShouldBe(2);
Path(9).ShouldBe("ActiveState/Id");
diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/FailoverEpochTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/FailoverEpochTests.cs
index df4508b9..8cb7caae 100644
--- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/FailoverEpochTests.cs
+++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/FailoverEpochTests.cs
@@ -164,7 +164,7 @@ public sealed class FailoverEpochTests : IDisposable
var sub = new FakeSubscriptionAdapter();
_factory.EnqueueSession(new FakeSessionAdapter { NextSubscription = sub });
await _service.ConnectAsync(Settings(), TestContext.Current.CancellationToken);
- var node = 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);
sub.ThrowOnRemove = true;
diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Fakes/FakeEndpointDiscovery.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Fakes/FakeEndpointDiscovery.cs
index ccbc3f99..373cec65 100644
--- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Fakes/FakeEndpointDiscovery.cs
+++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Fakes/FakeEndpointDiscovery.cs
@@ -35,7 +35,7 @@ internal sealed class FakeEndpointDiscovery : IEndpointDiscovery
: SecurityPolicies.Basic256Sha256,
Server = new ApplicationDescription
{
- ApplicationName = "FakeServer",
+ ApplicationName = new LocalizedText("FakeServer"),
ApplicationUri = "urn:localhost:FakeServer"
}
});
diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Fakes/FakeSessionAdapter.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Fakes/FakeSessionAdapter.cs
index 27504765..120d6e27 100644
--- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Fakes/FakeSessionAdapter.cs
+++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Fakes/FakeSessionAdapter.cs
@@ -48,11 +48,15 @@ internal sealed class FakeSessionAdapter : ISessionAdapter
public bool ThrowOnBrowse { get; set; }
/// Gets or sets the browse references returned by browse operations.
- public ReferenceDescriptionCollection BrowseResponse { get; set; } = [];
+ public ArrayOf BrowseResponse { get; set; } = [];
/// Gets or sets the continuation point for browse operations.
public byte[]? BrowseContinuationPoint { get; set; }
+ public Func? BeforeBrowseNext { get; set; }
+ public List<(byte[] Point, bool Cancelled)> BrowseReleases { get; } = [];
+ public bool ThrowOnBrowseRelease { get; set; }
+
/// Gets or sets the browse references returned by browse-next operations.
- public ReferenceDescriptionCollection BrowseNextResponse { get; set; } = [];
+ public ArrayOf BrowseNextResponse { get; set; } = [];
/// Gets or sets the continuation point for browse-next operations.
public byte[]? BrowseNextContinuationPoint { get; set; }
@@ -129,7 +133,7 @@ internal sealed class FakeSessionAdapter : ISessionAdapter
}
///
- public Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseAsync(
+ public Task<(byte[]? ContinuationPoint, ArrayOf References)> BrowseAsync(
NodeId nodeId, uint nodeClassMask, CancellationToken ct)
{
BrowseCount++;
@@ -139,11 +143,19 @@ internal sealed class FakeSessionAdapter : ISessionAdapter
}
///
- public Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseNextAsync(
+ public async Task<(byte[]? ContinuationPoint, ArrayOf References)> BrowseNextAsync(
byte[] continuationPoint, CancellationToken ct)
{
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;
}
///
diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Fakes/FakeSessionFactory.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Fakes/FakeSessionFactory.cs
index 24cbd13a..521b6fbb 100644
--- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Fakes/FakeSessionFactory.cs
+++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Fakes/FakeSessionFactory.cs
@@ -40,7 +40,7 @@ internal sealed class FakeSessionFactory : ISessionFactory
uint sessionTimeoutMs, UserIdentity identity, CancellationToken ct)
{
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
// failover is held) runs freely.
@@ -60,14 +60,14 @@ internal sealed class FakeSessionFactory : ISessionFactory
else
session = new FakeSessionAdapter
{
- EndpointUrl = endpoint.EndpointUrl,
- ServerName = endpoint.Server?.ApplicationName?.Text ?? "FakeServer",
+ EndpointUrl = endpoint.EndpointUrl ?? string.Empty,
+ ServerName = endpoint.Server?.ApplicationName.Text ?? "FakeServer",
SecurityMode = endpoint.SecurityMode.ToString(),
SecurityPolicyUri = endpoint.SecurityPolicyUri ?? string.Empty
};
// Ensure endpoint URL matches
- session.EndpointUrl = endpoint.EndpointUrl;
+ session.EndpointUrl = endpoint.EndpointUrl ?? string.Empty;
_createdSessions.Add(session);
return session;
}
diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Fakes/FakeSubscriptionAdapter.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Fakes/FakeSubscriptionAdapter.cs
index 1e442575..b567b15c 100644
--- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Fakes/FakeSubscriptionAdapter.cs
+++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Fakes/FakeSubscriptionAdapter.cs
@@ -129,6 +129,16 @@ internal sealed class FakeSubscriptionAdapter : ISubscriptionAdapter
lock (_itemsLock) _items.Clear();
}
+ public Action CaptureDataCallback(uint handle)
+ {
+ lock (_itemsLock) return _items[handle].DataCallback!;
+ }
+
+ public Action CaptureEventCallback(uint handle)
+ {
+ lock (_itemsLock) return _items[handle].EventCallback!;
+ }
+
///
/// Simulates a data change notification for testing.
///
diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Helpers/AggregateTypeMapperTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Helpers/AggregateTypeMapperTests.cs
index 267baf02..72823456 100644
--- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Helpers/AggregateTypeMapperTests.cs
+++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Helpers/AggregateTypeMapperTests.cs
@@ -26,8 +26,7 @@ public class AggregateTypeMapperTests
public void ToNodeId_ReturnsNonNullForAllValues(AggregateType aggregate)
{
var nodeId = AggregateTypeMapper.ToNodeId(aggregate);
- nodeId.ShouldNotBeNull();
- nodeId.IsNullNodeId.ShouldBeFalse();
+ nodeId.IsNull.ShouldBeFalse();
}
/// Verifies that Average aggregate type maps to the correct OPC UA node ID.
@@ -109,9 +108,9 @@ public class AggregateTypeMapperTests
[MemberData(nameof(AllAggregates))]
public void ToNodeId_maps_to_the_same_named_AggregateFunction(AggregateType aggregate)
{
- var field = typeof(ObjectIds).GetField("AggregateFunction_" + aggregate);
- field.ShouldNotBeNull();
- AggregateTypeMapper.ToNodeId(aggregate).ShouldBe((NodeId)field.GetValue(null)!);
+ var property = typeof(ObjectIds).GetProperty("AggregateFunction_" + aggregate);
+ property.ShouldNotBeNull();
+ AggregateTypeMapper.ToNodeId(aggregate).ShouldBe((NodeId)property.GetValue(null)!);
}
/// Every client aggregate.
diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Helpers/HistoryReadPagingTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Helpers/HistoryReadPagingTests.cs
index bd8c3d1c..82b70360 100644
--- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Helpers/HistoryReadPagingTests.cs
+++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Helpers/HistoryReadPagingTests.cs
@@ -29,19 +29,18 @@ public class HistoryReadPagingTests
Calls.Add(new Call(cp, release));
if (release) return Task.FromResult(new HistoryReadResult { StatusCode = StatusCodes.Good });
var (count, next) = _pages.Dequeue();
- var data = new HistoryData();
- for (var i = 0; i < count; i++) data.DataValues.Add(new DataValue(new Variant(i)));
+ var data = new HistoryData { DataValues = Enumerable.Range(0, count).Select(i => new DataValue(Variant.From(i))).ToArrayOf() };
return Task.FromResult(new HistoryReadResult
{
StatusCode = StatusCodes.Good,
- ContinuationPoint = next,
+ ContinuationPoint = ByteString.From(next),
HistoryData = new ExtensionObject(data)
});
}
}
private static IEnumerable Values(HistoryReadResult r) =>
- r.HistoryData?.Body is HistoryData d ? d.DataValues : [];
+ r.HistoryData.TryGetValue(out HistoryData? d) ? d.DataValues.ToArray() ?? [] : [];
///
/// 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));
if (release) return Task.FromResult(new HistoryReadResult { StatusCode = StatusCodes.Good });
if (cp != null) { cts.Cancel(); ct.ThrowIfCancellationRequested(); }
- var data = new HistoryData();
- data.DataValues.Add(new DataValue(new Variant(1)));
+ var data = new HistoryData { DataValues = [new DataValue(Variant.From(1))] };
return Task.FromResult(new HistoryReadResult
{
StatusCode = StatusCodes.Good, ContinuationPoint = [5], HistoryData = new ExtensionObject(data)
@@ -165,8 +163,7 @@ public class HistoryReadPagingTests
releaseSawCancelled = ct.IsCancellationRequested;
return Task.FromResult(new HistoryReadResult { StatusCode = StatusCodes.Good });
}
- var data = new HistoryData();
- data.DataValues.Add(new DataValue(new Variant(1)));
+ var data = new HistoryData { DataValues = [new DataValue(Variant.From(1))] };
cts.Cancel(); // the caller gives up right as the capped page arrives
return Task.FromResult(new HistoryReadResult
{
@@ -189,8 +186,7 @@ public class HistoryReadPagingTests
{
calls++;
if (release) throw new ServiceResultException(StatusCodes.BadSessionClosed);
- var data = new HistoryData();
- data.DataValues.Add(new DataValue(new Variant(1)));
+ var data = new HistoryData { DataValues = [new DataValue(Variant.From(1))] };
return Task.FromResult(new HistoryReadResult
{
StatusCode = StatusCodes.Good, ContinuationPoint = [9], HistoryData = new ExtensionObject(data)
@@ -216,4 +212,24 @@ public class HistoryReadPagingTests
await Should.ThrowAsync(() =>
HistoryReadPaging.ReadAllAsync(Read, Values, maxValues: 10, Node, CancellationToken.None));
}
+ [Fact]
+ public async Task RefusedContinuationPage_ReleasesCursorAndPreservesStatus()
+ {
+ var releases = new List();
+ Task Read(byte[]? cp, bool release, CancellationToken ct)
+ {
+ if (release)
+ {
+ releases.Add(cp!);
+ throw new ServiceResultException(StatusCodes.BadSessionClosed);
+ }
+ return Task.FromResult(cp == null
+ ? new HistoryReadResult { StatusCode = StatusCodes.Good, ContinuationPoint = [7] }
+ : new HistoryReadResult { StatusCode = StatusCodes.BadUserAccessDenied });
+ }
+ var exception = await Should.ThrowAsync(() =>
+ HistoryReadPaging.ReadAllAsync(Read, Values, 10, Node, TestContext.Current.CancellationToken));
+ exception.StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
+ releases.ShouldHaveSingleItem().ShouldBe(new byte[] { 7 });
+ }
}
diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Helpers/HistoryReadStatusTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Helpers/HistoryReadStatusTests.cs
index 91a70dc0..098e43be 100644
--- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Helpers/HistoryReadStatusTests.cs
+++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Helpers/HistoryReadStatusTests.cs
@@ -7,32 +7,29 @@ namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.Helpers;
public class HistoryReadStatusTests
{
+ public static IEnumerable