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))); + } +}