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 9b4bbe05..1d25779e 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
@@ -1,6 +1,7 @@
using Opc.Ua;
using Opc.Ua.Client;
using Serilog;
+using ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Adapters;
@@ -172,8 +173,9 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
break;
var result = results[0];
- if (StatusCode.IsBad(result.StatusCode))
- break;
+ // A refused read (BadHistoryOperationUnsupported, BadUserAccessDenied, ...) must surface; only
+ // BadNoData is an empty result. Swallowing it printed "0 values returned" for a refusal.
+ HistoryReadStatus.ThrowIfRefused(result.StatusCode, nodeId);
if (result.HistoryData is ExtensionObject ext && ext.Body is HistoryData historyData)
allValues.AddRange(historyData.DataValues);
@@ -218,8 +220,8 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
if (results != null && results.Count > 0)
{
var result = results[0];
- if (!StatusCode.IsBad(result.StatusCode) &&
- result.HistoryData is ExtensionObject ext &&
+ HistoryReadStatus.ThrowIfRefused(result.StatusCode, nodeId);
+ if (result.HistoryData is ExtensionObject ext &&
ext.Body is HistoryData historyData)
allValues.AddRange(historyData.DataValues);
}
diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Helpers/HistoryReadStatus.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Helpers/HistoryReadStatus.cs
new file mode 100644
index 00000000..4a43d596
--- /dev/null
+++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Helpers/HistoryReadStatus.cs
@@ -0,0 +1,24 @@
+using Opc.Ua;
+
+namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
+
+///
+/// Classifies the per-node HistoryReadResult.StatusCode a server returns for a HistoryRead.
+/// A Bad per-node status (BadHistoryOperationUnsupported, BadUserAccessDenied,
+/// BadNodeIdUnknown, ...) is a refused read, not an empty one, so it must reach the caller
+/// instead of collapsing to "0 values returned". BadNoData is the one Bad code that genuinely
+/// means "the window held nothing" and stays an empty result.
+///
+internal static class HistoryReadStatus
+{
+ /// Throws a naming the node and status when the read was refused.
+ /// The per-node result status.
+ /// The node that was read, for the message.
+ /// is Bad and not BadNoData.
+ public static void ThrowIfRefused(StatusCode status, NodeId nodeId)
+ {
+ if (!StatusCode.IsBad(status) || status.Code == StatusCodes.BadNoData) return;
+ throw new ServiceResultException(status.Code,
+ $"HistoryRead of {nodeId} failed: {StatusCodes.GetBrowseName(status.Code)} (0x{status.Code:X8}).");
+ }
+}
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
new file mode 100644
index 00000000..91a70dc0
--- /dev/null
+++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Helpers/HistoryReadStatusTests.cs
@@ -0,0 +1,38 @@
+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 HistoryReadStatusTests
+{
+ /// A refused read must surface, carrying the status code, rather than read as an empty result.
+ /// The Bad per-node status code.
+ [Theory]
+ [InlineData(StatusCodes.BadHistoryOperationUnsupported)]
+ [InlineData(StatusCodes.BadUserAccessDenied)]
+ [InlineData(StatusCodes.BadNodeIdUnknown)]
+ [InlineData(StatusCodes.BadTimeout)]
+ public void Bad_status_throws_ServiceResultException_with_the_code(uint code)
+ {
+ var ex = Should.Throw(
+ () => HistoryReadStatus.ThrowIfRefused(new StatusCode(code), new NodeId("n", 2)));
+
+ ex.StatusCode.ShouldBe(code);
+ ex.Message.ShouldContain("ns=2;s=n");
+ }
+
+ /// Good, GoodNoData, Uncertain and BadNoData are all non-refusals (the window may simply be empty).
+ /// The non-refusal status code.
+ [Theory]
+ [InlineData(StatusCodes.Good)]
+ [InlineData(StatusCodes.GoodNoData)]
+ [InlineData(StatusCodes.GoodMoreData)]
+ [InlineData(StatusCodes.UncertainNoCommunicationLastUsableValue)]
+ [InlineData(StatusCodes.BadNoData)]
+ public void Non_refusal_status_does_not_throw(uint code)
+ {
+ Should.NotThrow(() => HistoryReadStatus.ThrowIfRefused(new StatusCode(code), new NodeId("n", 2)));
+ }
+}