fix(opcuaclient): HistoryRead honours the per-node StatusCode (BadAggregateNotSupported + other Bad) instead of returning empty data (#649)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
@@ -185,6 +185,26 @@ public sealed class HistoryAggregateNotSupportedException : NotSupportedExceptio
|
||||
=> Aggregate = aggregate;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Thrown by a history backend that is a passthrough to another OPC UA server when that server answered
|
||||
/// a node with a Bad per-node <c>HistoryReadResult.StatusCode</c> other than
|
||||
/// <c>BadAggregateNotSupported</c> (which has its own <see cref="HistoryAggregateNotSupportedException"/>).
|
||||
/// The Server HistoryRead path surfaces <see cref="StatusCode"/> verbatim as that node's result status
|
||||
/// instead of the generic <c>BadHistoryOperationUnsupported</c>, so a refused read is never mistaken for
|
||||
/// an empty Good one.
|
||||
/// </summary>
|
||||
public sealed class HistoryReadStatusException : Exception
|
||||
{
|
||||
/// <summary>The raw OPC UA status code the upstream server returned for the node.</summary>
|
||||
public uint StatusCode { get; }
|
||||
|
||||
/// <summary>Creates the exception for the upstream <paramref name="statusCode"/>.</summary>
|
||||
/// <param name="statusCode">The Bad OPC UA status code to surface for the node.</param>
|
||||
public HistoryReadStatusException(uint statusCode)
|
||||
: base($"The upstream server refused the history read with status 0x{statusCode:X8}.")
|
||||
=> StatusCode = statusCode;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One row returned by <see cref="IHistoryProvider.ReadEventsAsync"/> — a historical
|
||||
/// alarm/event record, not the OPC UA live-event stream. Fields match the minimum set the
|
||||
|
||||
@@ -2315,7 +2315,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
ProcessingInterval = interval.TotalMilliseconds,
|
||||
AggregateType = [aggregateId],
|
||||
};
|
||||
return await ExecuteHistoryReadAsync(fullReference, new ExtensionObject(details), cancellationToken)
|
||||
return await ExecuteHistoryReadAsync(fullReference, new ExtensionObject(details), cancellationToken, aggregate)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -2335,11 +2335,16 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
|
||||
/// <summary>
|
||||
/// Shared HistoryRead wire path — used by Raw/Processed/AtTime. Handles NodeId parse,
|
||||
/// Session.HistoryReadAsync call, Bad-StatusCode passthrough (no translation per §8
|
||||
/// cascading-quality rule), and HistoryData unwrap into <see cref="DataValueSnapshot"/>.
|
||||
/// Session.HistoryReadAsync call, per-sample StatusCode passthrough (no translation per §8
|
||||
/// cascading-quality rule), and HistoryData unwrap into <see cref="DataValueSnapshot"/>. A Bad
|
||||
/// per-NODE <c>HistoryReadResult.StatusCode</c> is never read as empty data: <c>BadAggregateNotSupported</c>
|
||||
/// throws <see cref="HistoryAggregateNotSupportedException"/>, any other Bad throws
|
||||
/// <see cref="HistoryReadStatusException"/> (Gitea #649). Good / GoodNoData / GoodMoreData / Uncertain are
|
||||
/// unchanged.
|
||||
/// </summary>
|
||||
private async Task<Core.Abstractions.HistoryReadResult> ExecuteHistoryReadAsync(
|
||||
string fullReference, ExtensionObject historyReadDetails, CancellationToken ct)
|
||||
string fullReference, ExtensionObject historyReadDetails, CancellationToken ct,
|
||||
HistoryAggregateType? aggregate = null)
|
||||
{
|
||||
// Make sure a session exists before queuing on the gate, but do NOT bind the wire call
|
||||
// (or the namespace-relative NodeId parse) to this reference — a reconnect can swap
|
||||
@@ -2375,6 +2380,15 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
if (resp.Results.Count == 0) return new Core.Abstractions.HistoryReadResult([], null);
|
||||
var r = resp.Results[0];
|
||||
|
||||
// The per-node status decides whether the payload means anything. BadNoData is the one Bad code
|
||||
// that legitimately means "nothing in the window", so it stays an empty result.
|
||||
if (StatusCode.IsBad(r.StatusCode) && r.StatusCode.Code != StatusCodes.BadNoData)
|
||||
{
|
||||
if (r.StatusCode.Code == StatusCodes.BadAggregateNotSupported && aggregate is { } agg)
|
||||
throw new HistoryAggregateNotSupportedException(agg);
|
||||
throw new HistoryReadStatusException(r.StatusCode.Code);
|
||||
}
|
||||
|
||||
// Unwrap HistoryData from the ExtensionObject-encoded payload the SDK returns.
|
||||
// Samples stay in chronological order per OPC UA Part 11; cascading-quality
|
||||
// rule: preserve each DataValue's upstream StatusCode + timestamps verbatim.
|
||||
|
||||
@@ -5419,6 +5419,14 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
errors[handle.Index] = StatusCodes.BadAggregateNotSupported;
|
||||
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadAggregateNotSupported };
|
||||
}
|
||||
catch (HistoryReadStatusException ex)
|
||||
{
|
||||
// A passthrough backend (OpcUaClient) relayed the upstream server's Bad per-node status - surface
|
||||
// it verbatim for THIS node rather than collapsing it to BadHistoryOperationUnsupported.
|
||||
_logger.LogDebug(ex, "OtOpcUaNodeManager: backend refused history read for node {NodeId}", handle.NodeId);
|
||||
errors[handle.Index] = new StatusCode(ex.StatusCode);
|
||||
results[handle.Index] = new SdkHistoryReadResult { StatusCode = new StatusCode(ex.StatusCode) };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// One node's backend failure (throw / timeout) must not throw out of the batch — surface a
|
||||
@@ -5620,6 +5628,13 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
errors[handle.Index] = StatusCodes.BadTimeout;
|
||||
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadTimeout };
|
||||
}
|
||||
catch (HistoryReadStatusException ex)
|
||||
{
|
||||
// Upstream Bad per-node status relayed by a passthrough backend - surface it verbatim (see ServeNodeAsync).
|
||||
_logger.LogDebug(ex, "OtOpcUaNodeManager: backend refused HistoryReadRaw for node {NodeId}", handle.NodeId);
|
||||
errors[handle.Index] = new StatusCode(ex.StatusCode);
|
||||
results[handle.Index] = new SdkHistoryReadResult { StatusCode = new StatusCode(ex.StatusCode) };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// One node's backend failure must not throw out of the batch — Bad for THIS node only.
|
||||
|
||||
Reference in New Issue
Block a user