refactor(#655): delete the dead driver-side history capability

HistoryRead is served server-side by IHistorianDataSource; nothing ever dispatched
to a driver's IHistoryProvider. Removes IHistoryProvider, the OpcUaClient history
passthrough, DriverCapability.HistoryRead (+ resilience tier rows and AdminUI row),
HistoryReadStatusException and its node-manager catches, and the tests that only
covered them. Shared result types and HistoryAggregateNotSupportedException stay
(moved to Historian/HistoryReadContracts.cs). A stored ResilienceConfig still
carrying a HistoryRead key parses (diagnostic only; new test).

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 23:34:02 -04:00
parent ca17d2cf71
commit f7d497e532
23 changed files with 149 additions and 1608 deletions
@@ -5,10 +5,10 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// Each value corresponds to one method (or tightly-related method group) on the
/// <c>Core.Abstractions</c> capability interfaces (<see cref="IReadable"/>, <see cref="IWritable"/>,
/// <see cref="ITagDiscovery"/>, <see cref="ISubscribable"/>, <see cref="IHostConnectivityProbe"/>,
/// <see cref="IAlarmSource"/>, <see cref="IHistoryProvider"/>).
/// <see cref="IAlarmSource"/>).
/// </summary>
/// <remarks>
/// Per the per-capability retry policy in <c>docs/v2/plan.md</c>: Read / HistoryRead /
/// Per the per-capability retry policy in <c>docs/v2/plan.md</c>: Read /
/// Discover / Probe / AlarmSubscribe auto-retry; <see cref="Write"/> does NOT retry unless the
/// tag-definition carries <see cref="WriteIdempotentAttribute"/>. Alarm-acknowledge is treated
/// as a write for retry semantics (an alarm-ack is not idempotent at the plant-floor acknowledgement
@@ -37,9 +37,6 @@ public enum DriverCapability
/// <summary><see cref="IAlarmSource.AcknowledgeAsync"/>. Does NOT retry — ack is a write-shaped operation.</summary>
AlarmAcknowledge,
/// <summary><see cref="IHistoryProvider"/> reads (Raw/Processed/AtTime/Events). Retries by default.</summary>
HistoryRead,
/// <summary>
/// <c>IDriver.InitializeAsync</c> — connection establishment (R2-10 T10 closure: without this,
/// a driver that cannot connect contributes zero breaker input and the resilience panel stays
@@ -0,0 +1,92 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
// Shared history result contracts used by IHistorianDataSource and the historian gateway.
// Drivers carry no history capability (Gitea #655): HistoryRead is served server-side.
/// <summary>Result of a HistoryRead call.</summary>
/// <param name="Samples">Returned samples in chronological order.</param>
/// <param name="ContinuationPoint">Opaque token for the next call when more samples are available; null when complete.</param>
public sealed record HistoryReadResult(
IReadOnlyList<DataValueSnapshot> Samples,
byte[]? ContinuationPoint);
/// <summary>Aggregate function for processed history reads. Mirrors OPC UA Part 13 standard aggregates.</summary>
/// <remarks>
/// The first five members are the original set every historian backend serves. The remainder (#285) are
/// the further standard Part 13 aggregates a server-side HistoryRead can forward to a backend that
/// supports them (a gateway backend maps them to its own aggregate modes). A backend that cannot serve a
/// member must throw <see cref="HistoryAggregateNotSupportedException"/> — the server maps that to
/// <c>BadAggregateNotSupported</c> rather than a generic failure. <b>Append only</b>: the numeric values
/// are not persisted, but every <c>switch</c> over this enum must keep a total default.
/// </remarks>
public enum HistoryAggregateType
{
Average,
Minimum,
Maximum,
Total,
Count,
Interpolative,
TimeAverage,
Start,
End,
Delta,
Range,
MinimumActualTime,
MaximumActualTime,
StartBound,
EndBound,
DeltaBounds,
DurationGood,
DurationBad,
PercentGood,
PercentBad,
WorstQuality,
NumberOfTransitions,
AnnotationCount,
StandardDeviationPopulation,
VariancePopulation,
}
/// <summary>
/// Thrown by a history backend when it cannot serve the requested <see cref="HistoryAggregateType"/>.
/// The Server HistoryRead path translates it to <c>BadAggregateNotSupported</c> for that node only.
/// </summary>
public sealed class HistoryAggregateNotSupportedException : NotSupportedException
{
/// <summary>The aggregate the backend could not serve.</summary>
public HistoryAggregateType Aggregate { get; }
/// <summary>Creates the exception for <paramref name="aggregate"/>.</summary>
/// <param name="aggregate">The unsupported aggregate.</param>
public HistoryAggregateNotSupportedException(HistoryAggregateType aggregate)
: base($"The historian backend does not support the {aggregate} aggregate.")
=> Aggregate = aggregate;
}
/// <summary>
/// One row returned by <see cref="IHistorianDataSource.ReadEventsAsync"/> — a historical
/// alarm/event record, not the OPC UA live-event stream. Fields match the minimum set the
/// Server needs to populate a <c>HistoryEventFieldList</c> for HistoryReadEvents responses.
/// </summary>
/// <param name="EventId">Stable unique id for the event — driver-specific format.</param>
/// <param name="SourceName">Source object that emitted the event. May differ from the <c>sourceName</c> filter the caller passed (fuzzy matches).</param>
/// <param name="EventTimeUtc">Process-side timestamp — when the event actually occurred.</param>
/// <param name="ReceivedTimeUtc">Historian-side timestamp — when the historian persisted the row; may lag <paramref name="EventTimeUtc"/> by the historian's buffer flush cadence.</param>
/// <param name="Message">Human-readable message text.</param>
/// <param name="Severity">OPC UA severity (1-1000). Backends map their native priority scale onto this range.</param>
public sealed record HistoricalEvent(
string EventId,
string? SourceName,
DateTime EventTimeUtc,
DateTime ReceivedTimeUtc,
string? Message,
ushort Severity);
/// <summary>Result of a <see cref="IHistorianDataSource.ReadEventsAsync"/> call.</summary>
/// <param name="Events">Events in chronological order by <c>EventTimeUtc</c>.</param>
/// <param name="ContinuationPoint">Opaque token for the next call when more events are available; null when complete.</param>
public sealed record HistoricalEventsResult(
IReadOnlyList<HistoricalEvent> Events,
byte[]? ContinuationPoint);
@@ -5,15 +5,9 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// and resolved per OPC UA namespace, independent of any driver's lifecycle.
/// </summary>
/// <remarks>
/// Distinct from <see cref="IHistoryProvider"/>:
/// <list type="bullet">
/// <item><see cref="IHistoryProvider"/> is a *driver capability* — the server
/// dispatches to it via the driver instance.</item>
/// <item><see cref="IHistorianDataSource"/> is a *server registration* — the
/// server resolves it via namespace and calls it directly, so a single
/// historian (e.g. Wonderware) can serve many drivers' nodes, and drivers can
/// restart without dropping history availability.</item>
/// </list>
/// This is a *server registration*, not a driver capability: drivers carry no history
/// surface (Gitea #655). The server calls it directly, so a single historian can serve
/// many drivers' nodes, and drivers can restart without dropping history availability.
/// All values returned use the shared <see cref="DataValueSnapshot"/> /
/// <see cref="HistoricalEvent"/> shapes; backend-specific quality / type encodings
/// are translated to OPC UA <c>StatusCode</c> uints inside the data source.
@@ -86,9 +80,7 @@ public interface IHistorianDataSource : IDisposable
/// equivalent "use default" sentinel.
///
/// This surface declares <see cref="ReadAtTimeAsync"/> and <see cref="ReadEventsAsync"/>
/// as required members — a server-side historian owns the full read surface, unlike
/// <see cref="IHistoryProvider"/> where the same two methods are optional default-impl
/// methods so legacy drivers can stay raw-only. The asymmetry is intentional.
/// as required members — a server-side historian owns the full read surface.
/// </remarks>
/// <param name="sourceName">The source name to filter events, or null to return events from all sources.</param>
/// <param name="startUtc">The start of the time range in UTC.</param>
@@ -4,7 +4,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// Required capability for every driver instance. Owns lifecycle, metadata, health.
/// Other capabilities (<see cref="ITagDiscovery"/>, <see cref="IReadable"/>,
/// <see cref="IWritable"/>, <see cref="ISubscribable"/>, <see cref="IAlarmSource"/>,
/// <see cref="IHistoryProvider"/>, <see cref="IRediscoverable"/>,
/// <see cref="IRediscoverable"/>,
/// <see cref="IHostConnectivityProbe"/>) are composable — a driver implements only what its
/// backend actually supports.
/// </summary>
@@ -1,237 +0,0 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// Driver capability for historical-data reads (OPC UA HistoryRead). Optional —
/// only drivers whose backends carry historian data implement this. Currently:
/// Galaxy (Wonderware Historian via the optional plugin), OPC UA Client (forward
/// to upstream server).
/// </summary>
/// <remarks>
/// <see cref="ReadAtTimeAsync"/> and <see cref="ReadEventsAsync"/> are C# default interface
/// methods that throw <see cref="NotSupportedException"/> — drivers opt in by overriding so
/// a raw-only driver compiles without forcing it to provide at-time / event surfaces it
/// has no backend for. The sibling server-side surface, <see cref="IHistorianDataSource"/>,
/// declares both methods as required because a registered historian owns the full read
/// surface; the asymmetry is intentional.
/// </remarks>
public interface IHistoryProvider
{
/// <summary>
/// Read raw historical samples for a single attribute over a time range.
/// The Core wraps this with continuation-point handling.
/// </summary>
/// <param name="fullReference">The full reference path to the attribute.</param>
/// <param name="startUtc">Inclusive lower bound on the time range (UTC).</param>
/// <param name="endUtc">Exclusive upper bound on the time range (UTC).</param>
/// <param name="maxValuesPerNode">Maximum number of values to return per node.</param>
/// <param name="cancellationToken">A cancellation token to stop the operation.</param>
/// <returns>A task that returns the history read result containing samples and optional continuation point.</returns>
Task<HistoryReadResult> ReadRawAsync(
string fullReference,
DateTime startUtc,
DateTime endUtc,
uint maxValuesPerNode,
CancellationToken cancellationToken);
/// <summary>
/// Read processed (aggregated) samples — interval-bucketed average / min / max / etc.
/// Optional — drivers that only support raw history can throw <see cref="NotSupportedException"/>.
/// </summary>
/// <param name="fullReference">The full reference path to the attribute.</param>
/// <param name="startUtc">Inclusive lower bound on the time range (UTC).</param>
/// <param name="endUtc">Exclusive upper bound on the time range (UTC).</param>
/// <param name="interval">The time interval for bucketing samples.</param>
/// <param name="aggregate">The aggregate function to apply to each bucket.</param>
/// <param name="cancellationToken">A cancellation token to stop the operation.</param>
/// <returns>A task that returns the history read result containing aggregated samples and optional continuation point.</returns>
Task<HistoryReadResult> ReadProcessedAsync(
string fullReference,
DateTime startUtc,
DateTime endUtc,
TimeSpan interval,
HistoryAggregateType aggregate,
CancellationToken cancellationToken);
/// <summary>
/// Read one sample per requested timestamp — OPC UA HistoryReadAtTime service. The
/// driver interpolates (or returns the prior-boundary sample) when no exact match
/// exists. Optional; drivers that can't interpolate throw <see cref="NotSupportedException"/>.
/// </summary>
/// <remarks>
/// Default implementation throws. Drivers opt in by overriding; keeps existing
/// <c>IHistoryProvider</c> implementations compiling without forcing a ReadAtTime path
/// they may not have a backend for.
/// </remarks>
/// <param name="fullReference">The full reference path to the attribute.</param>
/// <param name="timestampsUtc">The list of timestamps at which to read values.</param>
/// <param name="cancellationToken">A cancellation token to stop the operation.</param>
/// <returns>A task that returns the history read result containing samples at the requested times.</returns>
Task<HistoryReadResult> ReadAtTimeAsync(
string fullReference,
IReadOnlyList<DateTime> timestampsUtc,
CancellationToken cancellationToken)
=> throw new NotSupportedException(
$"{GetType().Name} does not implement ReadAtTimeAsync. " +
"Drivers whose backends support at-time reads override this method.");
/// <summary>
/// Read historical alarm/event records — OPC UA HistoryReadEvents service. Distinct
/// from the live event stream — historical rows come from an event historian (Galaxy's
/// Alarm Provider history log, etc.) rather than the driver's active subscription.
/// </summary>
/// <param name="sourceName">
/// Optional filter: null means "all sources", otherwise restrict to events from that
/// source-object name. Drivers may ignore the filter if the backend doesn't support it.
/// </param>
/// <param name="startUtc">Inclusive lower bound on <c>EventTimeUtc</c>.</param>
/// <param name="endUtc">Exclusive upper bound on <c>EventTimeUtc</c>.</param>
/// <param name="maxEvents">
/// Upper cap on returned events — the driver's backend enforces this. The type is
/// <see cref="int"/> rather than <see cref="uint"/> (which the sibling raw / processed
/// reads use for <c>maxValuesPerNode</c>) because callers and downstream historian
/// adapters historically treat <c>maxEvents &lt;= 0</c> as a sentinel meaning
/// "use the backend's default cap" (see <c>WonderwareHistorianClient</c> /
/// <c>HistorianDataSource</c>). The asymmetry is intentional.
///
/// <b>Continuation contract when using the sentinel:</b> When <c>maxEvents &lt;= 0</c>
/// the backend applies its own cap. If the backend's cap truncates the result
/// (i.e. more matching events exist beyond the returned set), implementations MUST set
/// <see cref="HistoricalEventsResult.ContinuationPoint"/> to a non-null token so
/// callers can detect truncation and page. A null <c>ContinuationPoint</c> means all
/// matching events were returned — callers rely on this to decide whether to page.
/// </param>
/// <param name="cancellationToken">Request cancellation.</param>
/// <returns>A task that returns the historical events result containing matching event records and optional continuation point.</returns>
/// <remarks>
/// Default implementation throws. Drivers whose backend can serve historical events
/// override: Galaxy (Wonderware Alarm &amp; Events log) and the OPC UA Client driver
/// (forwarding HistoryReadEvents to its upstream server). Raw-only drivers such as Modbus
/// stay with the default and let callers see <c>BadHistoryOperationUnsupported</c>.
///
/// Note the type asymmetry with <see cref="ReadRawAsync"/> /
/// <see cref="ReadProcessedAsync"/> (both use <c>uint maxValuesPerNode</c>): event
/// readers accept a signed <c>int maxEvents</c> so callers can pass 0 / negative as a
/// "use default cap" sentinel without an extra parameter or overload.
/// </remarks>
Task<HistoricalEventsResult> ReadEventsAsync(
string? sourceName,
DateTime startUtc,
DateTime endUtc,
int maxEvents,
CancellationToken cancellationToken)
=> throw new NotSupportedException(
$"{GetType().Name} does not implement ReadEventsAsync. " +
"Drivers whose backends have an event historian override this method.");
}
/// <summary>Result of a HistoryRead call.</summary>
/// <param name="Samples">Returned samples in chronological order.</param>
/// <param name="ContinuationPoint">Opaque token for the next call when more samples are available; null when complete.</param>
public sealed record HistoryReadResult(
IReadOnlyList<DataValueSnapshot> Samples,
byte[]? ContinuationPoint);
/// <summary>Aggregate function for processed history reads. Mirrors OPC UA Part 13 standard aggregates.</summary>
/// <remarks>
/// The first five members are the original set every historian backend serves. The remainder (#285) are
/// the further standard Part 13 aggregates a server-side HistoryRead can forward to a backend that
/// supports them (the OPC UA Client driver forwards all of them upstream). A backend that cannot serve a
/// member must throw <see cref="HistoryAggregateNotSupportedException"/> — the server maps that to
/// <c>BadAggregateNotSupported</c> rather than a generic failure. <b>Append only</b>: the numeric values
/// are not persisted, but every <c>switch</c> over this enum must keep a total default.
/// </remarks>
public enum HistoryAggregateType
{
Average,
Minimum,
Maximum,
Total,
Count,
Interpolative,
TimeAverage,
Start,
End,
Delta,
Range,
MinimumActualTime,
MaximumActualTime,
StartBound,
EndBound,
DeltaBounds,
DurationGood,
DurationBad,
PercentGood,
PercentBad,
WorstQuality,
NumberOfTransitions,
AnnotationCount,
StandardDeviationPopulation,
VariancePopulation,
}
/// <summary>
/// Thrown by a history backend when it cannot serve the requested <see cref="HistoryAggregateType"/>.
/// The Server HistoryRead path translates it to <c>BadAggregateNotSupported</c> for that node only.
/// </summary>
public sealed class HistoryAggregateNotSupportedException : NotSupportedException
{
/// <summary>The aggregate the backend could not serve.</summary>
public HistoryAggregateType Aggregate { get; }
/// <summary>Creates the exception for <paramref name="aggregate"/>.</summary>
/// <param name="aggregate">The unsupported aggregate.</param>
public HistoryAggregateNotSupportedException(HistoryAggregateType aggregate)
: base($"The historian backend does not support the {aggregate} aggregate.")
=> 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>
/// <exception cref="ArgumentException"><paramref name="statusCode"/> is not a Bad code (severity bits <c>10</c>/<c>11</c>).</exception>
public HistoryReadStatusException(uint statusCode)
: base(IsBad(statusCode)
? $"The upstream server refused the history read with status 0x{statusCode:X8}."
: throw new ArgumentException($"0x{statusCode:X8} is not a Bad status code.", nameof(statusCode)))
=> StatusCode = statusCode;
private static bool IsBad(uint statusCode) => (statusCode & 0x80000000u) != 0;
}
/// <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
/// Server needs to populate a <c>HistoryEventFieldList</c> for HistoryReadEvents responses.
/// </summary>
/// <param name="EventId">Stable unique id for the event — driver-specific format.</param>
/// <param name="SourceName">Source object that emitted the event. May differ from the <c>sourceName</c> filter the caller passed (fuzzy matches).</param>
/// <param name="EventTimeUtc">Process-side timestamp — when the event actually occurred.</param>
/// <param name="ReceivedTimeUtc">Historian-side timestamp — when the historian persisted the row; may lag <paramref name="EventTimeUtc"/> by the historian's buffer flush cadence.</param>
/// <param name="Message">Human-readable message text.</param>
/// <param name="Severity">OPC UA severity (1-1000). Drivers map their native priority scale onto this range.</param>
public sealed record HistoricalEvent(
string EventId,
string? SourceName,
DateTime EventTimeUtc,
DateTime ReceivedTimeUtc,
string? Message,
ushort Severity);
/// <summary>Result of a <see cref="IHistoryProvider.ReadEventsAsync"/> call.</summary>
/// <param name="Events">Events in chronological order by <c>EventTimeUtc</c>.</param>
/// <param name="ContinuationPoint">Opaque token for the next call when more events are available; null when complete.</param>
public sealed record HistoricalEventsResult(
IReadOnlyList<HistoricalEvent> Events,
byte[]? ContinuationPoint);
@@ -81,7 +81,6 @@ public sealed record DriverResilienceOptions
[DriverCapability.Probe] = new(TimeoutSeconds: 2, RetryCount: 3, BreakerFailureThreshold: 5),
[DriverCapability.AlarmSubscribe] = new(TimeoutSeconds: 5, RetryCount: 3, BreakerFailureThreshold: 5),
[DriverCapability.AlarmAcknowledge] = new(TimeoutSeconds: 5, RetryCount: 0, BreakerFailureThreshold: 5),
[DriverCapability.HistoryRead] = new(TimeoutSeconds: 30, RetryCount: 2, BreakerFailureThreshold: 5),
// Initialize: no retry (the DriverInstanceActor reconnect timer is the retry loop) and a
// threshold of 2, NOT the Read-sized 5 — the breaker samples over 30 s and the reconnect
// timer fires every 10 s, so at most three init attempts fit one window; a threshold of 5
@@ -97,7 +96,6 @@ public sealed record DriverResilienceOptions
[DriverCapability.Probe] = new(TimeoutSeconds: 4, RetryCount: 3, BreakerFailureThreshold: 5),
[DriverCapability.AlarmSubscribe] = new(TimeoutSeconds: 8, RetryCount: 3, BreakerFailureThreshold: 5),
[DriverCapability.AlarmAcknowledge] = new(TimeoutSeconds: 8, RetryCount: 0, BreakerFailureThreshold: 5),
[DriverCapability.HistoryRead] = new(TimeoutSeconds: 60, RetryCount: 2, BreakerFailureThreshold: 5),
// See the Tier A Initialize note: threshold 2 is cadence-reachability, not a tuning choice.
[DriverCapability.Initialize] = new(TimeoutSeconds: 8, RetryCount: 0, BreakerFailureThreshold: 2),
},
@@ -110,7 +108,6 @@ public sealed record DriverResilienceOptions
[DriverCapability.Probe] = new(TimeoutSeconds: 10, RetryCount: 1, BreakerFailureThreshold: 0),
[DriverCapability.AlarmSubscribe] = new(TimeoutSeconds: 15, RetryCount: 1, BreakerFailureThreshold: 0),
[DriverCapability.AlarmAcknowledge] = new(TimeoutSeconds: 15, RetryCount: 0, BreakerFailureThreshold: 0),
[DriverCapability.HistoryRead] = new(TimeoutSeconds: 120, RetryCount: 1, BreakerFailureThreshold: 0),
// Tier C stays breaker-less like every other Tier C capability (process-level supervision).
[DriverCapability.Initialize] = new(TimeoutSeconds: 15, RetryCount: 0, BreakerFailureThreshold: 0),
},
@@ -29,7 +29,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
/// monitored-item handles. That mechanic lands in PR 69.
/// </para>
/// </remarks>
public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IAlarmSource, IHistoryProvider, ISkippedTagReporter, IDisposable, IAsyncDisposable
public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IAlarmSource, ISkippedTagReporter, IDisposable, IAsyncDisposable
{
/// <summary>Authored tags whose TagConfig carried no usable <c>nodeId</c>, so there is no upstream node
/// to dial (Gitea #541). This driver's skip path was the quietest of the twelve: <c>NamespaceMap</c>
@@ -2258,335 +2258,6 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
public string DiagnosticId => $"opcua-alarm-sub-{Id}";
}
// ---- IHistoryProvider (passthrough to upstream server) ----
/// <inheritdoc />
public async Task<Core.Abstractions.HistoryReadResult> ReadRawAsync(
string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode,
CancellationToken cancellationToken)
{
var details = new ReadRawModifiedDetails
{
IsReadModified = false,
StartTime = startUtc,
EndTime = endUtc,
NumValuesPerNode = maxValuesPerNode,
ReturnBounds = false,
};
return await ExecuteHistoryReadAsync(fullReference, new ExtensionObject(details), cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<Core.Abstractions.HistoryReadResult> ReadProcessedAsync(
string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval,
HistoryAggregateType aggregate, CancellationToken cancellationToken)
{
var aggregateId = MapAggregateToNodeId(aggregate);
var details = new ReadProcessedDetails
{
StartTime = startUtc,
EndTime = endUtc,
ProcessingInterval = interval.TotalMilliseconds,
AggregateType = [aggregateId],
};
return await ExecuteHistoryReadAsync(fullReference, new ExtensionObject(details), cancellationToken, aggregate)
.ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<Core.Abstractions.HistoryReadResult> ReadAtTimeAsync(
string fullReference, IReadOnlyList<DateTime> timestampsUtc, CancellationToken cancellationToken)
{
var reqTimes = new DateTimeCollection(timestampsUtc);
var details = new ReadAtTimeDetails
{
ReqTimes = reqTimes,
UseSimpleBounds = true,
};
return await ExecuteHistoryReadAsync(fullReference, new ExtensionObject(details), cancellationToken)
.ConfigureAwait(false);
}
/// <summary>
/// Shared HistoryRead wire path — used by Raw/Processed/AtTime. Handles NodeId parse,
/// 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,
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
// Session while we wait on _gate. The session actually used is re-read inside the gate.
_ = RequireSession();
await _gate.WaitAsync(ct).ConfigureAwait(false);
try
{
// Re-read Session inside the critical section: if a reconnect completed while we
// were blocked on _gate, OnReconnectComplete has already swapped in the new session.
// NodeId parsing is namespace-relative, so it must also use the current session's
// namespace table.
var session = Session;
if (session is null || !TryResolveTagNode(session, fullReference, out var nodeId))
{
return new Core.Abstractions.HistoryReadResult([], null);
}
var nodesToRead = new HistoryReadValueIdCollection
{
new HistoryReadValueId { NodeId = nodeId },
};
var resp = await session.HistoryReadAsync(
requestHeader: null,
historyReadDetails: historyReadDetails,
timestampsToReturn: TimestampsToReturn.Both,
releaseContinuationPoints: false,
nodesToRead: nodesToRead,
ct: ct).ConfigureAwait(false);
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.
// CodeBits (not Code) so an info/overflow sub-status bit on the upstream code does not dodge the match.
if (StatusCode.IsBad(r.StatusCode) && r.StatusCode.CodeBits != StatusCodes.BadNoData)
{
if (r.StatusCode.CodeBits == 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.
var samples = new List<DataValueSnapshot>();
if (r.HistoryData?.Body is HistoryData hd)
{
var now = DateTime.UtcNow;
foreach (var dv in hd.DataValues)
{
samples.Add(new DataValueSnapshot(
Value: dv.Value,
StatusCode: dv.StatusCode.Code,
SourceTimestampUtc: dv.SourceTimestamp == DateTime.MinValue ? null : dv.SourceTimestamp,
ServerTimestampUtc: dv.ServerTimestamp == DateTime.MinValue ? now : dv.ServerTimestamp));
}
}
var contPt = r.ContinuationPoint is { Length: > 0 } ? r.ContinuationPoint : null;
return new Core.Abstractions.HistoryReadResult(samples, contPt);
}
finally { _gate.Release(); }
}
/// <summary>Map <see cref="HistoryAggregateType"/> to the OPC UA Part 13 standard aggregate NodeId.</summary>
/// <param name="aggregate">The aggregation function type to map.</param>
/// <returns>The standard aggregate function NodeId.</returns>
internal static NodeId MapAggregateToNodeId(HistoryAggregateType aggregate) => aggregate switch
{
HistoryAggregateType.Average => ObjectIds.AggregateFunction_Average,
HistoryAggregateType.Minimum => ObjectIds.AggregateFunction_Minimum,
HistoryAggregateType.Maximum => ObjectIds.AggregateFunction_Maximum,
HistoryAggregateType.Total => ObjectIds.AggregateFunction_Total,
HistoryAggregateType.Count => ObjectIds.AggregateFunction_Count,
HistoryAggregateType.Interpolative => ObjectIds.AggregateFunction_Interpolative,
HistoryAggregateType.TimeAverage => ObjectIds.AggregateFunction_TimeAverage,
HistoryAggregateType.Start => ObjectIds.AggregateFunction_Start,
HistoryAggregateType.End => ObjectIds.AggregateFunction_End,
HistoryAggregateType.Delta => ObjectIds.AggregateFunction_Delta,
HistoryAggregateType.Range => ObjectIds.AggregateFunction_Range,
HistoryAggregateType.MinimumActualTime => ObjectIds.AggregateFunction_MinimumActualTime,
HistoryAggregateType.MaximumActualTime => ObjectIds.AggregateFunction_MaximumActualTime,
HistoryAggregateType.StartBound => ObjectIds.AggregateFunction_StartBound,
HistoryAggregateType.EndBound => ObjectIds.AggregateFunction_EndBound,
HistoryAggregateType.DeltaBounds => ObjectIds.AggregateFunction_DeltaBounds,
HistoryAggregateType.DurationGood => ObjectIds.AggregateFunction_DurationGood,
HistoryAggregateType.DurationBad => ObjectIds.AggregateFunction_DurationBad,
HistoryAggregateType.PercentGood => ObjectIds.AggregateFunction_PercentGood,
HistoryAggregateType.PercentBad => ObjectIds.AggregateFunction_PercentBad,
HistoryAggregateType.WorstQuality => ObjectIds.AggregateFunction_WorstQuality,
HistoryAggregateType.NumberOfTransitions => ObjectIds.AggregateFunction_NumberOfTransitions,
HistoryAggregateType.AnnotationCount => ObjectIds.AggregateFunction_AnnotationCount,
HistoryAggregateType.StandardDeviationPopulation => ObjectIds.AggregateFunction_StandardDeviationPopulation,
HistoryAggregateType.VariancePopulation => ObjectIds.AggregateFunction_VariancePopulation,
_ => throw new ArgumentOutOfRangeException(nameof(aggregate), aggregate, null),
};
// Canonical BaseEventType select-clause order — MapHistoryEvents maps by these indices.
private static readonly string[] EventFieldBrowseNames =
[
BrowseNames.EventId, // 0
BrowseNames.SourceName, // 1
BrowseNames.Time, // 2
BrowseNames.ReceiveTime, // 3
BrowseNames.Message, // 4
BrowseNames.Severity, // 5
];
/// <summary>
/// Builds the fixed canonical EventFilter the driver sends upstream for HistoryReadEvents —
/// the six BaseEventType fields the OtOpcUa server projects (<see cref="HistoricalEvent"/>).
/// The clause order is load-bearing: <see cref="MapHistoryEvents"/> reads results by index.
/// </summary>
/// <returns>An EventFilter with six SimpleAttributeOperand value clauses.</returns>
internal static EventFilter BuildBaseEventFilter()
{
var filter = new EventFilter();
foreach (var browseName in EventFieldBrowseNames)
{
filter.SelectClauses.Add(new SimpleAttributeOperand
{
TypeDefinitionId = ObjectTypeIds.BaseEventType,
BrowsePath = new QualifiedNameCollection { new QualifiedName(browseName) },
AttributeId = Attributes.Value,
});
}
return filter;
}
/// <summary>
/// Maps an upstream <see cref="HistoryEvent"/> (field arrays ordered to match
/// <see cref="BuildBaseEventFilter"/>) onto <see cref="HistoricalEvent"/> records. Defensive:
/// short / null / wrong-typed fields degrade to null/default rather than throwing.
/// </summary>
/// <param name="historyEvent">The upstream history-event payload.</param>
/// <returns>The mapped historical events in upstream order.</returns>
internal static IReadOnlyList<Core.Abstractions.HistoricalEvent> MapHistoryEvents(HistoryEvent historyEvent)
{
if (historyEvent?.Events is not { Count: > 0 } rows) return [];
var result = new List<Core.Abstractions.HistoricalEvent>(rows.Count);
foreach (var row in rows)
{
var fields = row?.EventFields;
result.Add(new Core.Abstractions.HistoricalEvent(
EventId: CoerceEventId(FieldAt(fields, 0)),
SourceName: CoerceString(FieldAt(fields, 1)),
EventTimeUtc: CoerceDateTime(FieldAt(fields, 2)),
ReceivedTimeUtc: CoerceDateTime(FieldAt(fields, 3)),
Message: CoerceString(FieldAt(fields, 4)),
Severity: CoerceSeverity(FieldAt(fields, 5))));
}
return result;
}
private static object? FieldAt(VariantCollection? fields, int index)
=> fields is not null && index < fields.Count ? fields[index].Value : null;
private static string CoerceEventId(object? value) => value switch
{
byte[] bytes => Convert.ToBase64String(bytes),
string s => s,
null => string.Empty,
_ => value.ToString() ?? string.Empty,
};
private static string? CoerceString(object? value) => value switch
{
LocalizedText lt => lt.Text,
string s => s,
null => null,
_ => value.ToString(),
};
// Missing-field sentinel is UTC-kinded so a downstream .ToUniversalTime() can't shift it.
private static readonly DateTime MissingTimeSentinel =
DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc);
private static DateTime CoerceDateTime(object? value)
=> value is DateTime dt ? dt : MissingTimeSentinel;
private static ushort CoerceSeverity(object? value)
{
try { return value is null ? (ushort)0 : Convert.ToUInt16(value); }
catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException) { return 0; }
}
/// <inheritdoc />
// Forwards OPC UA HistoryReadEvents to the upstream server. Sends the fixed canonical
// BuildBaseEventFilter and maps the result onto HistoricalEvent (the OtOpcUa server projects
// only those six BaseEventType fields, so a richer client filter would be discarded
// server-side — the driver supplies the canonical set itself). sourceName is the upstream
// event-notifier NodeId to read from (mirrors how fullReference is the upstream NodeId for
// raw reads); null/empty resolves to the upstream Server object (i=2253), the standard
// server-wide event notifier, and an unparseable id short-circuits to an empty result,
// matching the raw path's malformed-NodeId behavior.
public async Task<Core.Abstractions.HistoricalEventsResult> ReadEventsAsync(
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents,
CancellationToken cancellationToken)
{
// Confirm a session exists before queuing; the session actually used — and the
// namespace-relative source-NodeId parse — are re-read inside the gate so a reconnect
// mid-wait can't leave us reading from a closed session.
_ = RequireSession();
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
// Re-read Session inside the critical section: OnReconnectComplete may have swapped
// in a new session while we were blocked on _gate, and the source-NodeId parse is
// namespace-relative so it must bind against the current session's namespace table.
var session = Session;
if (session is null) return new Core.Abstractions.HistoricalEventsResult([], null);
NodeId notifierNodeId;
if (string.IsNullOrEmpty(sourceName))
{
notifierNodeId = ObjectIds.Server;
}
// sourceName is an upstream event-notifier node id (not a tag RawPath), so it
// re-binds directly against the session via the static resolve.
else if (!NamespaceMap.TryResolve(session, sourceName, out var parsed))
{
return new Core.Abstractions.HistoricalEventsResult([], null);
}
else
{
notifierNodeId = parsed;
}
var details = new ReadEventDetails
{
StartTime = startUtc,
EndTime = endUtc,
NumValuesPerNode = maxEvents <= 0 ? 0u : (uint)maxEvents,
Filter = BuildBaseEventFilter(),
};
var nodesToRead = new HistoryReadValueIdCollection
{
new HistoryReadValueId { NodeId = notifierNodeId },
};
var resp = await session.HistoryReadAsync(
requestHeader: null,
historyReadDetails: new ExtensionObject(details),
timestampsToReturn: TimestampsToReturn.Both,
releaseContinuationPoints: false,
nodesToRead: nodesToRead,
ct: cancellationToken).ConfigureAwait(false);
if (resp.Results.Count == 0) return new Core.Abstractions.HistoricalEventsResult([], null);
var r = resp.Results[0];
var events = r.HistoryData?.Body is HistoryEvent he
? MapHistoryEvents(he)
: [];
var contPt = r.ContinuationPoint is { Length: > 0 } ? r.ContinuationPoint : null;
return new Core.Abstractions.HistoricalEventsResult(events, contPt);
}
finally { _gate.Release(); }
}
// ---- IHostConnectivityProbe ----
/// <summary>
@@ -21,7 +21,7 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers;
public sealed class ResilienceFormModel
{
public static readonly string[] Capabilities =
["Read", "Write", "Discover", "Subscribe", "Probe", "AlarmSubscribe", "AlarmAcknowledge", "HistoryRead", "Initialize"];
["Read", "Write", "Discover", "Subscribe", "Probe", "AlarmSubscribe", "AlarmAcknowledge", "Initialize"];
// RecycleIntervalSeconds is GONE (Gitea #522) — the scheduled-recycle machinery it authored was
// deleted, so the field configured nothing. It is deliberately NOT explicitly stripped from stored
@@ -193,7 +193,7 @@ internal static class HistoryPaging
/// <param name="boundaryT">The single SourceTimestamp every tie in the cluster shares.</param>
/// <param name="endUtc">The (inclusive) upper bound of the read window; unchanged across pages.
/// OpcUaServer-003, accepted by design: the backend contract
/// (<c>IHistoryProvider.ReadRawAsync</c>) treats <c>endUtc</c> as EXCLUSIVE, so the
/// (<c>IHistorianDataSource.ReadRawAsync</c>) treats <c>endUtc</c> as EXCLUSIVE, so the
/// <c>next &lt;= endUtc</c> test here is deliberately the conservative side — at worst one extra
/// empty resume page (<c>[endUtc, endUtc)</c> → GoodNoData, no continuation point), never a
/// dropped or duplicated sample. Tightening to <c>&lt;</c> would be correct only if the live
@@ -5419,14 +5419,6 @@ 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
@@ -5628,13 +5620,6 @@ 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.
@@ -26,8 +26,8 @@ namespace ZB.MOM.WW.OtOpcUa.Analyzers;
/// <c>IReadable.ReadAsync</c> still trips the rule.
/// </para>
/// <para>
/// <b>Default-interface-method handling:</b> <c>IHistoryProvider.ReadAtTimeAsync</c> and
/// <c>IHistoryProvider.ReadEventsAsync</c> ship as DIM bodies. When a driver inherits the
/// <b>Default-interface-method handling:</b> a guarded interface member may ship as a DIM
/// body. When a driver inherits the
/// DIM (no override), <c>FindImplementationForInterfaceMember</c> returns the interface's
/// own method symbol, which still equals <paramref name="method"/> for an interface-typed
/// receiver. When a driver overrides the DIM, the override symbol equals
@@ -75,7 +75,6 @@ public sealed class UnwrappedCapabilityCallAnalyzer : DiagnosticAnalyzer
"ZB.MOM.WW.OtOpcUa.Core.Abstractions.ISubscribable",
"ZB.MOM.WW.OtOpcUa.Core.Abstractions.IHostConnectivityProbe",
"ZB.MOM.WW.OtOpcUa.Core.Abstractions.IAlarmSource",
"ZB.MOM.WW.OtOpcUa.Core.Abstractions.IHistoryProvider",
];
/// <summary>
@@ -106,7 +105,7 @@ public sealed class UnwrappedCapabilityCallAnalyzer : DiagnosticAnalyzer
category: "OtOpcUa.Resilience",
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true,
description: "Phase 6.1 Stream A requires every IReadable/IWritable/ITagDiscovery/ISubscribable/IHostConnectivityProbe/IAlarmSource/IHistoryProvider call to route through the shared Polly pipeline. Direct calls skip the pipeline + lose per-host isolation, retry semantics, and telemetry. If the caller is Core/Server/Driver dispatch code, wrap the call in CapabilityInvoker.ExecuteAsync. If the caller is a unit test invoking the driver directly to test its wire-level behavior, either suppress with a pragma or move the suppression into a NoWarn for the test project.");
description: "Phase 6.1 Stream A requires every IReadable/IWritable/ITagDiscovery/ISubscribable/IHostConnectivityProbe/IAlarmSource call to route through the shared Polly pipeline. Direct calls skip the pipeline + lose per-host isolation, retry semantics, and telemetry. If the caller is Core/Server/Driver dispatch code, wrap the call in CapabilityInvoker.ExecuteAsync. If the caller is a unit test invoking the driver directly to test its wire-level behavior, either suppress with a pragma or move the suppression into a NoWarn for the test project.");
/// <inheritdoc />
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get; } = ImmutableArray.Create(Rule);
@@ -1,21 +0,0 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.Historian;
public sealed class HistoryReadStatusExceptionTests
{
[Theory]
[InlineData(0x801F0000u)] // BadUserAccessDenied
[InlineData(0xC0000000u)] // severity 11 (reserved Bad)
public void A_Bad_code_is_carried_verbatim(uint code) =>
new HistoryReadStatusException(code).StatusCode.ShouldBe(code);
[Theory]
[InlineData(0x00000000u)] // Good
[InlineData(0x00A70000u)] // GoodNoData
[InlineData(0x40000000u)] // Uncertain
public void A_non_Bad_code_is_rejected(uint code) =>
Should.Throw<ArgumentException>(() => new HistoryReadStatusException(code));
}
@@ -153,22 +153,6 @@ public sealed class IHistorianDataSourceContractTests
parameter!.ParameterType.ShouldBe(expectedType);
}
/// <summary>Verifies that history provider max parameters have the expected type.</summary>
/// <param name="methodName">The name of the method to test.</param>
/// <param name="parameterName">The name of the parameter to verify.</param>
/// <param name="expectedType">The expected parameter type.</param>
[Theory]
[InlineData("ReadRawAsync", "maxValuesPerNode", typeof(uint))]
[InlineData("ReadEventsAsync", "maxEvents", typeof(int))]
public void HistoryProvider_MaxParameter_TypePinned(string methodName, string parameterName, Type expectedType)
{
var method = typeof(IHistoryProvider).GetMethod(methodName);
method.ShouldNotBeNull();
var parameter = method!.GetParameters().FirstOrDefault(p => p.Name == parameterName);
parameter.ShouldNotBeNull($"Method {methodName} should expose a '{parameterName}' parameter.");
parameter!.ParameterType.ShouldBe(expectedType);
}
/// <summary>
/// Core.Abstractions-009 (OpcUaServer-002): when <c>maxEvents &lt;= 0</c> the contract
/// requires implementations to set <see cref="HistoricalEventsResult.ContinuationPoint"/>
@@ -195,30 +179,4 @@ public sealed class IHistorianDataSourceContractTests
continuationProp!.PropertyType.ShouldBe(typeof(byte[]),
"ContinuationPoint is byte[]? — null means no more pages, non-null means caller must page");
}
/// <summary>
/// Core.Abstractions-008: <see cref="IHistoryProvider.ReadAtTimeAsync"/> and
/// <see cref="IHistoryProvider.ReadEventsAsync"/> are C# default interface methods
/// (drivers opt in), whereas <see cref="IHistorianDataSource.ReadAtTimeAsync"/> and
/// <see cref="IHistorianDataSource.ReadEventsAsync"/> are required (the server-side
/// historian must implement them). This test pins the asymmetry so an implementer cannot
/// accidentally collapse the two surfaces and so the documented rationale stays load-bearing.
/// </summary>
/// <param name="methodName">The name of the optional method to check.</param>
[Theory]
[InlineData("ReadAtTimeAsync")]
[InlineData("ReadEventsAsync")]
public void HistoryProvider_OptionalMethods_HaveDefaultImplementation(string methodName)
{
var providerMethod = typeof(IHistoryProvider).GetMethod(methodName);
providerMethod.ShouldNotBeNull();
// Default interface methods carry a method body — IsAbstract is false.
providerMethod!.IsAbstract.ShouldBeFalse(
$"IHistoryProvider.{methodName} must remain a default-impl-throws method so legacy drivers continue to compile.");
var dataSourceMethod = typeof(IHistorianDataSource).GetMethod(methodName);
dataSourceMethod.ShouldNotBeNull();
dataSourceMethod!.IsAbstract.ShouldBeTrue(
$"IHistorianDataSource.{methodName} must remain required — server-side historians own the full surface.");
}
}
@@ -62,7 +62,6 @@ public sealed class InterfaceIndependenceTests
[InlineData(typeof(IWritable))]
[InlineData(typeof(ISubscribable))]
[InlineData(typeof(IAlarmSource))]
[InlineData(typeof(IHistoryProvider))]
[InlineData(typeof(IRediscoverable))]
[InlineData(typeof(IHostConnectivityProbe))]
[InlineData(typeof(ISkippedTagReporter))]
@@ -73,4 +72,15 @@ public sealed class InterfaceIndependenceTests
type.IsPublic.ShouldBeTrue($"{type.Name} must be public — drivers in separate assemblies implement it.");
type.IsInterface.ShouldBeTrue($"{type.Name} must be an interface, not a class.");
}
/// <summary>
/// Gitea #655: HistoryRead is served server-side by <c>IHistorianDataSource</c>; no driver-side history
/// capability exists. Re-adding one without also adding a dispatch path would recreate dead surface.
/// </summary>
[Fact]
public void NoDriverSideHistoryCapability_Exists()
{
Assembly.GetType("ZB.MOM.WW.OtOpcUa.Core.Abstractions.IHistoryProvider").ShouldBeNull();
Enum.GetNames<DriverCapability>().ShouldNotContain("HistoryRead");
}
}
@@ -138,6 +138,33 @@ public sealed class DriverResilienceOptionsParserTests
DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Read]);
}
/// <summary>
/// Gitea #655: the retired <c>HistoryRead</c> capability may still sit in stored
/// <c>DriverInstance.ResilienceConfig</c> rows. It must parse (diagnostic only, never a throw), be skipped,
/// and leave every other override in the same blob applied.
/// </summary>
[Fact]
public void StoredConfig_WithRetiredHistoryReadKey_StillParses_AndOtherOverridesApply()
{
var json = """
{
"capabilityPolicies": {
"HistoryRead": { "timeoutSeconds": 45, "retryCount": 1, "breakerFailureThreshold": 3 },
"Read": { "timeoutSeconds": 7 }
}
}
""";
var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.A, json, out var diag);
diag.ShouldNotBeNull();
diag.ShouldContain("HistoryRead");
diag.ShouldContain("skipped");
options.Resolve(DriverCapability.Read).TimeoutSeconds.ShouldBe(7);
options.Resolve(DriverCapability.Write).ShouldBe(
DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Write]);
}
/// <summary>Verifies that top-level property names are case insensitive.</summary>
[Fact]
public void PropertyNames_AreCaseInsensitive()
@@ -116,7 +116,6 @@ public sealed class DriverResilienceOptionsTests
/// <param name="capability">The driver capability to test.</param>
[Theory]
[InlineData(DriverTier.A, DriverCapability.Read)]
[InlineData(DriverTier.A, DriverCapability.HistoryRead)]
[InlineData(DriverTier.B, DriverCapability.Discover)]
[InlineData(DriverTier.B, DriverCapability.Probe)]
[InlineData(DriverTier.C, DriverCapability.AlarmSubscribe)]
@@ -16,9 +16,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests;
/// <para>
/// <b>Why opc-plc over loopback against our own server</b> — (1) independent
/// cert chain + user-token handling catches interop bugs loopback can't;
/// (2) built-in alarm ConditionType + history simulation gives
/// <see cref="Core.Abstractions.IAlarmSource"/> +
/// <see cref="Core.Abstractions.IHistoryProvider"/> coverage without a custom
/// (2) built-in alarm ConditionType simulation gives
/// <see cref="Core.Abstractions.IAlarmSource"/> coverage without a custom
/// driver fake; (3) pinned image tag fixes the test surface in a way our own
/// evolving server wouldn't. Follow-up: add <c>open62541/open62541</c> as a
/// second image once this lands, for fully-independent-stack interop.
@@ -92,32 +92,4 @@ public sealed class OpcUaClientSmokeTests(OpcPlcFixture sim)
await drv.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
}
/// <summary>
/// Verifies HistoryReadEvents passthrough issues a well-formed request and returns a
/// result without throwing. opc-plc exposes live alarm conditions (--alm) but is NOT a
/// historian, so the upstream may return zero historical events or reject the service —
/// either way the driver must produce a HistoricalEventsResult, never throw. This proves
/// the wire request + unwrap path; a non-empty event list is infra-gated on an upstream
/// that historizes events.
/// </summary>
[Fact]
public async Task Client_reads_events_returns_result_without_throwing()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
var options = OpcPlcProfile.BuildOptions(sim.EndpointUrl);
await using var drv = new OpcUaClientDriver(options, driverInstanceId: "opcua-smoke-events");
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var result = await drv.ReadEventsAsync(
sourceName: null, // null → upstream Server object (i=2253)
startUtc: DateTime.UtcNow.AddHours(-1),
endUtc: DateTime.UtcNow,
maxEvents: 100,
cancellationToken: TestContext.Current.CancellationToken);
result.ShouldNotBeNull();
result.Events.ShouldNotBeNull();
}
}
@@ -1,178 +0,0 @@
using Opc.Ua;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
[Trait("Category", "Unit")]
public sealed class OpcUaClientHistoryTests
{
/// <summary>Every <see cref="HistoryAggregateType"/> member (#285: the full Part 13 set, not just five).</summary>
public static IEnumerable<object[]> AllAggregates() =>
Enum.GetValues<HistoryAggregateType>().Select(a => new object[] { a });
/// <summary>Every member maps to a DISTINCT aggregate NodeId (a copy/paste mapping would collide).</summary>
[Fact]
public void MapAggregateToNodeId_is_injective_over_the_whole_enum()
{
var ids = Enum.GetValues<HistoryAggregateType>().Select(OpcUaClientDriver.MapAggregateToNodeId).ToList();
ids.Distinct().Count().ShouldBe(ids.Count);
Enum.GetValues<HistoryAggregateType>().Length.ShouldBeGreaterThan(5);
}
/// <summary>Verifies MapAggregateToNodeId returns standard Part 13 aggregate for every enum.</summary>
/// <param name="agg">The history aggregate type to test.</param>
[Theory]
[MemberData(nameof(AllAggregates))]
public void MapAggregateToNodeId_returns_standard_Part13_aggregate_for_every_enum(HistoryAggregateType agg)
{
var nodeId = OpcUaClientDriver.MapAggregateToNodeId(agg);
NodeId.IsNull(nodeId).ShouldBeFalse();
// Every mapping should resolve to an AggregateFunction_* NodeId (namespace 0, numeric id).
nodeId.NamespaceIndex.ShouldBe((ushort)0);
}
/// <summary>Verifies MapAggregateToNodeId rejects invalid enum values.</summary>
[Fact]
public void MapAggregateToNodeId_rejects_invalid_enum_value()
{
// Defense-in-depth: a future HistoryAggregateType addition mustn't silently fall through.
Should.Throw<ArgumentOutOfRangeException>(() =>
OpcUaClientDriver.MapAggregateToNodeId((HistoryAggregateType)99));
}
/// <summary>Verifies ReadRawAsync throws without initialization.</summary>
[Fact]
public async Task ReadRawAsync_without_initialize_throws_InvalidOperationException()
{
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-hist-uninit");
await Should.ThrowAsync<InvalidOperationException>(async () =>
await drv.ReadRawAsync("ns=2;s=Counter",
DateTime.UtcNow.AddMinutes(-5), DateTime.UtcNow, 1000,
TestContext.Current.CancellationToken));
}
/// <summary>Verifies ReadRawAsync with malformed NodeId returns empty result.</summary>
[Fact]
public async Task ReadRawAsync_with_malformed_NodeId_returns_empty_result_not_throw()
{
// Same defensive pattern as ReadAsync / WriteAsync — malformed NodeId short-circuits
// to an empty result rather than crashing a batch history call. Needs init via the
// throw path first, then we pass "" to trigger the parse-fail branch inside
// ExecuteHistoryReadAsync. The init itself fails against 127.0.0.1:1 so we stop there.
// Not runnable without init — keep as placeholder for when the in-process fixture
// PR lands.
await Task.CompletedTask;
}
/// <summary>Verifies ReadProcessedAsync throws without initialization.</summary>
[Fact]
public async Task ReadProcessedAsync_without_initialize_throws_InvalidOperationException()
{
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-hist-uninit");
await Should.ThrowAsync<InvalidOperationException>(async () =>
await drv.ReadProcessedAsync("ns=2;s=Counter",
DateTime.UtcNow.AddMinutes(-5), DateTime.UtcNow,
TimeSpan.FromSeconds(10), HistoryAggregateType.Average,
TestContext.Current.CancellationToken));
}
/// <summary>Verifies ReadAtTimeAsync throws without initialization.</summary>
[Fact]
public async Task ReadAtTimeAsync_without_initialize_throws_InvalidOperationException()
{
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-hist-uninit");
await Should.ThrowAsync<InvalidOperationException>(async () =>
await drv.ReadAtTimeAsync("ns=2;s=Counter",
[DateTime.UtcNow.AddMinutes(-5), DateTime.UtcNow],
TestContext.Current.CancellationToken));
}
/// <summary>ReadEventsAsync requires an initialized session like the sibling history reads.</summary>
[Fact]
public async Task ReadEventsAsync_without_initialize_throws_InvalidOperationException()
{
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-events-uninit");
await Should.ThrowAsync<InvalidOperationException>(async () =>
await drv.ReadEventsAsync(
sourceName: null,
startUtc: DateTime.UtcNow.AddMinutes(-5),
endUtc: DateTime.UtcNow,
maxEvents: 100,
cancellationToken: TestContext.Current.CancellationToken));
}
/// <summary>BuildBaseEventFilter emits the six canonical BaseEventType select clauses in order.</summary>
[Fact]
public void BuildBaseEventFilter_has_six_canonical_BaseEventType_value_clauses()
{
var filter = OpcUaClientDriver.BuildBaseEventFilter();
filter.SelectClauses.Count.ShouldBe(6);
string[] expected = ["EventId", "SourceName", "Time", "ReceiveTime", "Message", "Severity"];
for (var i = 0; i < expected.Length; i++)
{
var clause = filter.SelectClauses[i];
clause.TypeDefinitionId.ShouldBe(ObjectTypeIds.BaseEventType);
clause.AttributeId.ShouldBe(Attributes.Value);
clause.BrowsePath.Count.ShouldBe(1);
clause.BrowsePath[0].Name.ShouldBe(expected[i]);
clause.BrowsePath[0].NamespaceIndex.ShouldBe((ushort)0); // BaseEventType fields live in ns 0
}
}
/// <summary>MapHistoryEvents maps every field by its canonical select-clause index.</summary>
[Fact]
public void MapHistoryEvents_maps_all_six_fields_by_canonical_index()
{
var eventTime = new DateTime(2026, 6, 18, 10, 0, 0, DateTimeKind.Utc);
var recvTime = eventTime.AddSeconds(1);
var he = new HistoryEvent();
var fields = new HistoryEventFieldList();
fields.EventFields.Add(new Variant(new byte[] { 1, 2, 3 })); // 0 EventId
fields.EventFields.Add(new Variant("Pump17")); // 1 SourceName
fields.EventFields.Add(new Variant(eventTime)); // 2 Time
fields.EventFields.Add(new Variant(recvTime)); // 3 ReceiveTime
fields.EventFields.Add(new Variant(new LocalizedText("High temp"))); // 4 Message
fields.EventFields.Add(new Variant((ushort)700)); // 5 Severity
he.Events.Add(fields);
var mapped = OpcUaClientDriver.MapHistoryEvents(he);
mapped.Count.ShouldBe(1);
var e = mapped[0];
e.EventId.ShouldBe(Convert.ToBase64String(new byte[] { 1, 2, 3 }));
e.SourceName.ShouldBe("Pump17");
e.EventTimeUtc.ShouldBe(eventTime);
e.ReceivedTimeUtc.ShouldBe(recvTime);
e.Message.ShouldBe("High temp");
e.Severity.ShouldBe((ushort)700);
}
/// <summary>MapHistoryEvents returns empty for a HistoryEvent with no rows.</summary>
[Fact]
public void MapHistoryEvents_with_no_events_returns_empty()
{
OpcUaClientDriver.MapHistoryEvents(new HistoryEvent()).ShouldBeEmpty();
}
/// <summary>MapHistoryEvents tolerates a field list shorter than six without throwing.</summary>
[Fact]
public void MapHistoryEvents_tolerates_short_field_list_without_throwing()
{
var he = new HistoryEvent();
var fields = new HistoryEventFieldList();
fields.EventFields.Add(new Variant(new byte[] { 9 })); // only EventId present
he.Events.Add(fields);
var mapped = OpcUaClientDriver.MapHistoryEvents(he);
mapped.Count.ShouldBe(1);
mapped[0].EventId.ShouldBe(Convert.ToBase64String(new byte[] { 9 }));
mapped[0].SourceName.ShouldBeNull();
mapped[0].EventTimeUtc.ShouldBe(DateTime.MinValue);
mapped[0].EventTimeUtc.Kind.ShouldBe(DateTimeKind.Utc); // sentinel is UTC-kinded, not Unspecified
mapped[0].Severity.ShouldBe((ushort)0);
}
}
@@ -8,13 +8,13 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
/// <summary>
/// Regression coverage for the stale-session concurrency race in the history/read wire
/// paths. A background reconnect handler can swap <see cref="OpcUaClientDriver.Session"/>
/// Regression coverage for the stale-session concurrency race in the alarm-acknowledge wire
/// path. A background reconnect handler can swap <see cref="OpcUaClientDriver.Session"/>
/// while a caller is parked on <c>_gate.WaitAsync</c>. If a method captures the session
/// reference <i>before</i> the gate and uses it afterwards, it issues the wire call against
/// the closed/stale session. The correct idiom — already used by ReadAsync/WriteAsync — is
/// a fast-fail guard outside the gate plus an authoritative re-read of <c>Session</c>
/// <i>inside</i> the gate. These tests prove the history/read funnels follow it.
/// <i>inside</i> the gate. This test proves the acknowledge path follows it.
/// </summary>
/// <remarks>
/// The swap is staged so the race is exercised, not raced against:
@@ -41,13 +41,6 @@ public sealed class OpcUaClientStaleSessionRaceTests
{
private static Mock<ISession> NewSessionMock(TaskCompletionSource? prologueReached = null)
{
var resp = new HistoryReadResponse
{
ResponseHeader = new ResponseHeader(),
Results = new HistoryReadResultCollection(),
DiagnosticInfos = new DiagnosticInfoCollection(),
};
var mock = new Mock<ISession>(MockBehavior.Loose);
// NodeId parsing inside the driver resolves the reference against the session's
// MessageContext. This getter is read in the driver's synchronous prologue (before the
@@ -56,14 +49,6 @@ public sealed class OpcUaClientStaleSessionRaceTests
.Returns(new ServiceMessageContext(ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.SdkTelemetry.Context))
.Callback(() => prologueReached?.TrySetResult());
mock.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
mock.Setup(s => s.HistoryReadAsync(
It.IsAny<RequestHeader>(),
It.IsAny<ExtensionObject>(),
It.IsAny<TimestampsToReturn>(),
It.IsAny<bool>(),
It.IsAny<HistoryReadValueIdCollection>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(resp);
// Acknowledge issues its wire call via CallAsync; stub it so AcknowledgeAsync can run to
// completion against whichever session it ends up binding to.
mock.Setup(s => s.CallAsync(
@@ -79,18 +64,6 @@ public sealed class OpcUaClientStaleSessionRaceTests
return mock;
}
private static void VerifyWireHitNewSessionNotOld(Mock<ISession> newSession, Mock<ISession> oldSession)
{
newSession.Verify(s => s.HistoryReadAsync(
It.IsAny<RequestHeader>(), It.IsAny<ExtensionObject>(), It.IsAny<TimestampsToReturn>(),
It.IsAny<bool>(), It.IsAny<HistoryReadValueIdCollection>(), It.IsAny<CancellationToken>()),
Times.Once);
oldSession.Verify(s => s.HistoryReadAsync(
It.IsAny<RequestHeader>(), It.IsAny<ExtensionObject>(), It.IsAny<TimestampsToReturn>(),
It.IsAny<bool>(), It.IsAny<HistoryReadValueIdCollection>(), It.IsAny<CancellationToken>()),
Times.Never);
}
private static void VerifyAckWireHitNewSessionNotOld(Mock<ISession> newSession, Mock<ISession> oldSession)
{
newSession.Verify(s => s.CallAsync(
@@ -101,82 +74,6 @@ public sealed class OpcUaClientStaleSessionRaceTests
Times.Never);
}
/// <summary>
/// ExecuteHistoryReadAsync (the ReadRaw/ReadProcessed/ReadAtTime funnel) must issue the
/// HistoryRead against the session current <i>after</i> the gate, not a reference
/// captured before WaitAsync.
/// </summary>
[Fact]
public async Task ReadRawAsync_uses_session_swapped_in_across_the_gate_not_the_captured_one()
{
var ct = TestContext.Current.CancellationToken;
// v3: a HistoryRead over a variable is keyed by the tag's RawPath, resolved through the
// authored RawTags table to its upstream node id. Author "ns=2;s=Counter" as both the
// RawPath and the TagConfig.nodeId so the two-stage resolve reaches the wire.
var options = new OpcUaClientDriverOptions
{
RawTags = [new RawTagEntry("ns=2;s=Counter", """{"nodeId":"ns=2;s=Counter"}""", WriteIdempotent: false)],
};
using var drv = new OpcUaClientDriver(options, "opcua-stale-raw");
drv.SetNamespaceMapForTest(NamespaceMap.FromTable(new NamespaceTable(), options.RawTags));
var prologueReached = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var oldSession = NewSessionMock(prologueReached);
var newSession = NewSessionMock();
drv.SetSessionForTest(oldSession.Object);
// Hold the gate so the method parks on WaitAsync after running its synchronous prologue.
await drv.Gate.WaitAsync(ct);
var call = drv.ReadRawAsync(
"ns=2;s=Counter", DateTime.UtcNow.AddMinutes(-5), DateTime.UtcNow, 1000, ct);
// Wait until the buggy capture-before-gate has grabbed the OLD session (signalled via
// its MessageContext read), then stage the reconnect swap and release the gate. The
// fixed path reads no pre-gate session member, so the signal never fires and the
// bounded fallback elapses — harmless, because the fixed read happens only after the
// gate is released and therefore always sees the swapped session.
await WaitForPreGateCaptureOrFallbackAsync(prologueReached.Task, ct);
drv.SetSessionForTest(newSession.Object);
drv.Gate.Release();
await call;
VerifyWireHitNewSessionNotOld(newSession, oldSession);
}
/// <summary>
/// ReadEventsAsync is a separate HistoryRead site with the same hazard — it must also
/// re-read Session inside the gate rather than use the pre-gate capture.
/// </summary>
[Fact]
public async Task ReadEventsAsync_uses_session_swapped_in_across_the_gate_not_the_captured_one()
{
var ct = TestContext.Current.CancellationToken;
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-stale-events");
var prologueReached = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
// ReadEventsAsync only parses the source NodeId when sourceName is non-empty, so pass a
// concrete source id to force the prologue's MessageContext read (and its signal).
var oldSession = NewSessionMock(prologueReached);
var newSession = NewSessionMock();
drv.SetSessionForTest(oldSession.Object);
await drv.Gate.WaitAsync(ct);
var call = drv.ReadEventsAsync(
sourceName: "ns=2;s=Pump17", startUtc: DateTime.UtcNow.AddMinutes(-5),
endUtc: DateTime.UtcNow, maxEvents: 100, cancellationToken: ct);
await WaitForPreGateCaptureOrFallbackAsync(prologueReached.Task, ct);
drv.SetSessionForTest(newSession.Object);
drv.Gate.Release();
await call;
VerifyWireHitNewSessionNotOld(newSession, oldSession);
}
/// <summary>
/// AcknowledgeAsync issues its Acknowledge method <c>CallAsync</c> against the session
/// current after the gate — and parses the namespace-relative ConditionId against that
@@ -1,258 +0,0 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
/// <summary>
/// HistoryRead leg of the OpcUaClient driver's wire coverage (Gitea #622): the real
/// <see cref="OpcUaClientDriver"/> issues Raw / Processed / AtTime reads over loopback to the in-process
/// OtOpcUa server, whose node manager dispatches to an injected deterministic
/// <see cref="IHistorianDataSource"/> (<see cref="FakeHistorian"/>) behind a historized raw variable.
/// </summary>
[Trait("Category", "Integration")]
public sealed class OpcUaClientDriverHistoryWireTests
{
private const string HistRef = "Sim/dev1/Hist";
private const string HistTag = "Plant.Hist.Series";
private const uint Uncertain = 0x40000000u;
private const uint BadNoData = 0x809B0000u;
private const uint BadUserAccessDenied = 0x801F0000u;
private const uint BadHistoryOperationUnsupported = 0x80720000u;
private static readonly DateTime T0 = new(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc);
private static DateTime At(int seconds) => T0.AddSeconds(seconds);
/// <summary>Five samples, one per second, value = 1..5; the third is Uncertain.</summary>
private static readonly DataValueSnapshot[] Series =
[
new(1.0, 0u, At(0), At(0)),
new(2.0, 0u, At(1), At(1)),
new(3.0, Uncertain, At(2), At(2)),
new(4.0, 0u, At(3), At(3)),
new(5.0, 0u, At(4), At(4)),
];
private sealed class FakeHistorian : IHistorianDataSource
{
public List<string> Calls { get; } = [];
public Task<HistoryReadResult> ReadRawAsync(
string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode, CancellationToken ct)
{
lock (Calls) Calls.Add($"raw:{fullReference}");
var hits = Series.Where(s => s.SourceTimestampUtc >= startUtc && s.SourceTimestampUtc < endUtc);
if (maxValuesPerNode > 0) hits = hits.Take((int)maxValuesPerNode);
return Task.FromResult(new HistoryReadResult([.. hits], null));
}
public Task<HistoryReadResult> ReadProcessedAsync(
string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval,
HistoryAggregateType aggregate, CancellationToken ct)
{
lock (Calls) Calls.Add($"processed:{fullReference}:{aggregate}");
// Minimum stands in for "the backend refuses with some OTHER Bad status" (#649).
if (aggregate == HistoryAggregateType.Minimum)
throw new HistoryReadStatusException(BadUserAccessDenied);
// Maximum stands in for a node-level BadNoData ("nothing in the window"), which must stay empty.
if (aggregate == HistoryAggregateType.Maximum)
throw new HistoryReadStatusException(BadNoData);
if (aggregate != HistoryAggregateType.Average)
throw new HistoryAggregateNotSupportedException(aggregate);
var buckets = new List<DataValueSnapshot>();
for (var b = startUtc; b < endUtc; b += interval)
{
var inBucket = Series.Where(s => s.SourceTimestampUtc >= b && s.SourceTimestampUtc < b + interval).ToList();
if (inBucket.Count == 0) continue;
buckets.Add(new DataValueSnapshot(inBucket.Average(s => (double)s.Value!), 0u, b, b));
}
return Task.FromResult(new HistoryReadResult(buckets, null));
}
public Task<HistoryReadResult> ReadAtTimeAsync(
string fullReference, IReadOnlyList<DateTime> timestampsUtc, CancellationToken ct)
{
lock (Calls) Calls.Add($"attime:{fullReference}");
// Step semantics: the last sample at or before the requested instant; BadNoData before the first.
var result = timestampsUtc.Select(t =>
{
var prior = Series.LastOrDefault(s => s.SourceTimestampUtc <= t);
return prior is null
? new DataValueSnapshot(null, 0x809B0000u /* BadNoData */, t, t)
: new DataValueSnapshot(prior.Value, prior.StatusCode, t, t);
}).ToList();
return Task.FromResult(new HistoryReadResult(result, null));
}
public Task<HistoricalEventsResult> ReadEventsAsync(
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, CancellationToken ct) =>
Task.FromResult(new HistoricalEventsResult([], null));
public HistorianHealthSnapshot GetHealthSnapshot() =>
new(0, 0, 0, 0, null, null, null, false, false, null, null, []);
public void Dispose() { }
}
private static async Task<(ServerEventRig Rig, FakeHistorian Fake, OpcUaClientDriver Drv)> StartAsync(
string name, CancellationToken ct)
{
var rig = await OpcUaClientDriverWireTests.StartRigAsync(name, ct);
var fake = new FakeHistorian();
rig.Nm.HistorianDataSource = fake;
var id = rig.EnsureRawVariable("Hist", "Double", writable: false, historianTagname: HistTag);
OpcUaClientDriverWireTests.Publish(rig, id, 5.0);
var drv = OpcUaClientDriverWireTests.NewDriver(
rig, name,
extraTags: [OpcUaClientDriverWireTests.Tag(HistRef, ServerEventRig.RawNsu(id))]);
await drv.InitializeAsync("{}", ct);
return (rig, fake, drv);
}
[Fact]
public async Task ReadRaw_returns_the_historians_samples_with_values_timestamps_and_statuses()
{
var ct = TestContext.Current.CancellationToken;
var (rig, fake, drv) = await StartAsync("hist-raw", ct);
await using var _r = rig;
await using var _d = drv;
var result = await drv.ReadRawAsync(HistRef, At(0), At(10), 0, ct);
result.Samples.Select(s => Convert.ToDouble(s.Value)).ShouldBe([1.0, 2.0, 3.0, 4.0, 5.0]);
result.Samples.Select(s => s.SourceTimestampUtc).ShouldBe(Series.Select(s => s.SourceTimestampUtc));
result.Samples.Select(s => s.StatusCode).ShouldBe([0u, 0u, Uncertain, 0u, 0u]);
result.ContinuationPoint.ShouldBeNull("the whole window fit in one page");
lock (fake.Calls) fake.Calls.ShouldContain($"raw:{HistTag}", "the read must reach the historian under the node's tagname");
}
[Fact]
public async Task ReadRaw_honours_the_window_and_pages_with_a_continuation_point_when_the_cap_is_hit()
{
var ct = TestContext.Current.CancellationToken;
var (rig, _, drv) = await StartAsync("hist-raw-page", ct);
await using var _r = rig;
await using var _d = drv;
// Window [1s, 4s) holds samples 2,3,4 (end exclusive).
var window = await drv.ReadRawAsync(HistRef, At(1), At(4), 0, ct);
window.Samples.Select(s => Convert.ToDouble(s.Value)).ShouldBe([2.0, 3.0, 4.0]);
// A cap of 3 against 5 available: first three samples and a continuation point.
var paged = await drv.ReadRawAsync(HistRef, At(0), At(10), 3, ct);
paged.Samples.Select(s => Convert.ToDouble(s.Value)).ShouldBe([1.0, 2.0, 3.0]);
paged.ContinuationPoint.ShouldNotBeNull("a full page must carry a continuation point");
}
[Fact]
public async Task ReadAtTime_returns_one_sample_per_requested_instant_with_the_prior_value()
{
var ct = TestContext.Current.CancellationToken;
var (rig, fake, drv) = await StartAsync("hist-attime", ct);
await using var _r = rig;
await using var _d = drv;
var result = await drv.ReadAtTimeAsync(HistRef, [At(-1), At(1), At(2), At(3).AddMilliseconds(500)], ct);
result.Samples.Count.ShouldBe(4);
result.Samples[0].StatusCode.ShouldNotBe(0u, "before the first sample there is no value");
Convert.ToDouble(result.Samples[1].Value).ShouldBe(2.0);
result.Samples[1].StatusCode.ShouldBe(0u);
Convert.ToDouble(result.Samples[2].Value).ShouldBe(3.0);
result.Samples[2].StatusCode.ShouldBe(Uncertain);
Convert.ToDouble(result.Samples[3].Value).ShouldBe(4.0);
result.Samples.Skip(1).Select(s => s.SourceTimestampUtc)
.ShouldBe([At(1), At(2), At(3).AddMilliseconds(500)]);
lock (fake.Calls) fake.Calls.ShouldContain($"attime:{HistTag}");
}
[Fact]
public async Task ReadProcessed_serves_a_supported_aggregate_and_surfaces_BadAggregateNotSupported_for_an_unsupported_one()
{
var ct = TestContext.Current.CancellationToken;
var (rig, fake, drv) = await StartAsync("hist-processed", ct);
await using var _r = rig;
await using var _d = drv;
// Average over 2s buckets across [0s,6s): {1,2}=1.5, {3,4}=3.5, {5}=5.
var avg = await drv.ReadProcessedAsync(HistRef, At(0), At(6), TimeSpan.FromSeconds(2), HistoryAggregateType.Average, ct);
avg.Samples.Select(s => Convert.ToDouble(s.Value)).ShouldBe([1.5, 3.5, 5.0]);
avg.Samples.Select(s => s.SourceTimestampUtc).ShouldBe([At(0), At(2), At(4)]);
// The backend refuses Count: the server maps that to BadAggregateNotSupported for the node, and the
// driver must raise it (not return empty Good data) so its own server surfaces it per node (#649).
// The Average above is the positive control that the same path serves data, and the call must have
// REACHED the backend.
var ex = await Should.ThrowAsync<HistoryAggregateNotSupportedException>(() =>
drv.ReadProcessedAsync(HistRef, At(0), At(6), TimeSpan.FromSeconds(2), HistoryAggregateType.Count, ct));
ex.Aggregate.ShouldBe(HistoryAggregateType.Count);
lock (fake.Calls) fake.Calls.ShouldContain($"processed:{HistTag}:Count");
}
[Fact]
public async Task ReadProcessed_surfaces_any_other_Bad_node_status_instead_of_empty_data()
{
var ct = TestContext.Current.CancellationToken;
var (rig, fake, drv) = await StartAsync("hist-badstatus", ct);
await using var _r = rig;
await using var _d = drv;
var ex = await Should.ThrowAsync<HistoryReadStatusException>(() =>
drv.ReadProcessedAsync(HistRef, At(0), At(6), TimeSpan.FromSeconds(2), HistoryAggregateType.Minimum, ct));
ex.StatusCode.ShouldBe(BadUserAccessDenied);
lock (fake.Calls) fake.Calls.ShouldContain($"processed:{HistTag}:Minimum");
}
[Fact]
public async Task A_node_level_BadNoData_stays_an_empty_result_not_an_error()
{
var ct = TestContext.Current.CancellationToken;
var (rig, fake, drv) = await StartAsync("hist-badnodata", ct);
await using var _r = rig;
await using var _d = drv;
var result = await drv.ReadProcessedAsync(
HistRef, At(0), At(6), TimeSpan.FromSeconds(2), HistoryAggregateType.Maximum, ct);
result.Samples.ShouldBeEmpty();
lock (fake.Calls) fake.Calls.ShouldContain($"processed:{HistTag}:Maximum", "the call must have reached the backend");
}
[Fact]
public async Task A_Good_read_with_no_samples_in_the_window_is_still_empty_data_not_an_error()
{
var ct = TestContext.Current.CancellationToken;
var (rig, _, drv) = await StartAsync("hist-goodnodata", ct);
await using var _r = rig;
await using var _d = drv;
// Historized node, window past the series: the server answers GoodNoData, which must stay a normal empty result.
var result = await drv.ReadRawAsync(HistRef, At(100), At(110), 0, ct);
result.Samples.ShouldBeEmpty();
result.ContinuationPoint.ShouldBeNull();
}
[Fact]
public async Task A_read_of_a_non_historized_node_surfaces_its_Bad_status_and_never_reaches_the_historian()
{
var ct = TestContext.Current.CancellationToken;
var (rig, fake, drv) = await StartAsync("hist-none", ct);
await using var _r = rig;
await using var _d = drv;
// The server answers BadHistoryOperationUnsupported for a node with no historian tagname; that Bad
// node status is surfaced (#649), no longer read as empty Good data.
var ex = await Should.ThrowAsync<HistoryReadStatusException>(() =>
drv.ReadRawAsync(OpcUaClientDriverWireTests.SpeedRef, At(0), At(10), 0, ct));
ex.StatusCode.ShouldBe(BadHistoryOperationUnsupported);
// Positive control: the historized sibling does reach the historian on the same session.
(await drv.ReadRawAsync(HistRef, At(0), At(10), 0, ct)).Samples.Count.ShouldBe(5);
lock (fake.Calls) fake.Calls.Count.ShouldBe(1, "only the historized read dispatches");
}
}
@@ -312,61 +312,6 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
await host.DisposeAsync();
}
/// <summary>A passthrough backend's Bad status on a RESUME read surfaces verbatim for that node only (#649):
/// no outbound continuation point, the cursor is consumed (no stray cursor), and a sibling node in the same
/// batch is unaffected.</summary>
[Fact]
public async Task Raw_resume_read_that_throws_HistoryReadStatusException_surfaces_the_code_and_isolates_the_sibling()
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
nm.HistoryContinuationStore = new InMemoryHistoryContinuationStore();
var fake = new SeriesHistorianDataSource(MakeSeries(count: 250, stepSeconds: 1));
nm.HistorianDataSource = fake;
nm.EnsureVariable("eq-1/bad", parentFolderNodeId: null, displayName: "Bad", dataType: "Double",
writable: false, historianTagname: "WW.Bad", realm: AddressSpaceRealm.Uns);
nm.EnsureVariable("eq-1/ok", parentFolderNodeId: null, displayName: "Ok", dataType: "Double",
writable: false, historianTagname: "WW.Ok", realm: AddressSpaceRealm.Uns);
var badId = nm.TryGetVariable("eq-1/bad")!.NodeId;
var okId = nm.TryGetVariable("eq-1/ok")!.NodeId;
// Page 1 succeeds and emits a continuation point.
var (_, _, cp1) = ReadRaw(nm, badId, Epoch, Epoch.AddHours(1), max: 100, inboundCp: null);
cp1.ShouldNotBeNull();
// The backend now refuses the bad node; one batch resumes it and freshly reads the sibling.
fake.FailTagname = "WW.Bad";
var context = new OperationContext(
new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity: null);
var details = new ReadRawModifiedDetails
{
StartTime = Epoch, EndTime = Epoch.AddHours(1), NumValuesPerNode = 100, IsReadModified = false,
};
var nodesToRead = new List<HistoryReadValueId>
{
new() { NodeId = badId, ContinuationPoint = cp1 },
new() { NodeId = okId },
};
var results = new List<SdkHistoryReadResult> { null!, null! };
var errors = new List<ServiceResult> { null!, null! };
nm.HistoryRead(context, details, TimestampsToReturn.Both,
releaseContinuationPoints: false, nodesToRead, results, errors);
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
results[0].ContinuationPoint.ShouldBeNull();
errors[1].StatusCode.Code.ShouldBe(StatusCodes.Good);
((HistoryData)ExtensionObject.ToEncodeable(results[1].HistoryData)).DataValues.Count.ShouldBe(100);
results[1].ContinuationPoint.ShouldNotBeNull("the sibling is full-page and pages normally");
// The consumed cursor is gone: nothing stray to resume.
fake.FailTagname = null;
var (_, e, _) = ReadRaw(nm, badId, Epoch, Epoch.AddHours(1), max: 100, inboundCp: cp1);
e.StatusCode.Code.ShouldBe(StatusCodes.BadContinuationPointInvalid);
await host.DisposeAsync();
}
// --- helpers --------------------------------------------------------------------------------
/// <summary>Issue a single-node Raw HistoryRead and return that node's decoded sample values, error,
@@ -468,8 +413,6 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
private int _readCount;
public int ReadCount => _readCount;
/// <summary>When set, a Raw read of this tagname throws <see cref="HistoryReadStatusException"/> (#649).</summary>
public volatile string? FailTagname;
public void ResetReadCount() => _readCount = 0;
public Task<HistorianRead> ReadRawAsync(
@@ -477,8 +420,6 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
CancellationToken cancellationToken)
{
Interlocked.Increment(ref _readCount);
if (FailTagname is { } bad && bad == fullReference)
throw new HistoryReadStatusException(StatusCodes.BadUserAccessDenied);
var window = series
.Where(s => (s.SourceTimestampUtc ?? DateTime.MinValue) >= startUtc
&& (s.SourceTimestampUtc ?? DateTime.MinValue) <= endUtc)
@@ -50,16 +50,6 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions {
public interface IHostConnectivityProbe {
IReadOnlyList<HostConnectivityStatus> GetHostStatuses();
}
public class HistoryReadResult { }
public class HistoricalEventsResult { }
public interface IHistoryProvider {
Task<HistoryReadResult> ReadRawAsync(string fullRef, DateTime start, DateTime end, uint max, CancellationToken ct);
Task<HistoryReadResult> ReadProcessedAsync(string fullRef, DateTime start, DateTime end, TimeSpan interval, CancellationToken ct);
Task<HistoryReadResult> ReadAtTimeAsync(string fullRef, IReadOnlyList<DateTime> timestamps, CancellationToken ct)
=> throw new NotSupportedException();
Task<HistoricalEventsResult> ReadEventsAsync(string? sourceName, DateTime start, DateTime end, int maxEvents, CancellationToken ct)
=> throw new NotSupportedException();
}
public enum DriverCapability { Read, Write, Discover, AlarmSubscribe, AlarmAcknowledge }
public interface IDriverCapabilityInvoker {
ValueTask<T> ExecuteAsync<T>(DriverCapability c, string host, Func<CancellationToken, ValueTask<T>> call, CancellationToken ct);
@@ -346,141 +336,6 @@ namespace ZB.MOM.WW.OtOpcUa.Server {
diags.ShouldBeEmpty();
}
// -----------------------------------------------------------------------
// IHistoryProvider — interface-typed receiver
// -----------------------------------------------------------------------
/// <summary>Verifies that a direct ReadRawAsync call on an interface trips the diagnostic.</summary>
[Fact]
public async Task Direct_ReadRawAsync_OnInterface_TripsDiagnostic()
{
const string userSrc = """
using System;
using System.Threading;
using System.Threading.Tasks;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Server {
public sealed class BadHistoryRead {
public async Task DoIt(IHistoryProvider provider) {
_ = await provider.ReadRawAsync("tag", DateTime.MinValue, DateTime.MaxValue, 100, CancellationToken.None);
}
}
}
""";
var diags = await Compile(userSrc);
diags.Length.ShouldBe(1);
diags[0].GetMessage().ShouldContain("ReadRawAsync");
}
/// <summary>Verifies that a direct ReadProcessedAsync call on an interface trips the diagnostic.</summary>
[Fact]
public async Task Direct_ReadProcessedAsync_OnInterface_TripsDiagnostic()
{
const string userSrc = """
using System;
using System.Threading;
using System.Threading.Tasks;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Server {
public sealed class BadHistoryProcessed {
public async Task DoIt(IHistoryProvider provider) {
_ = await provider.ReadProcessedAsync("tag", DateTime.MinValue, DateTime.MaxValue, TimeSpan.FromMinutes(1), CancellationToken.None);
}
}
}
""";
var diags = await Compile(userSrc);
diags.Length.ShouldBe(1);
diags[0].GetMessage().ShouldContain("ReadProcessedAsync");
}
/// <summary>Verifies that a direct ReadAtTimeAsync call on an interface trips the diagnostic.</summary>
[Fact]
public async Task Direct_ReadAtTimeAsync_OnInterface_TripsDiagnostic()
{
// ReadAtTimeAsync is a default interface method — the analyzer must still flag it when
// called through an interface-typed receiver.
const string userSrc = """
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Server {
public sealed class BadHistoryAtTime {
public async Task DoIt(IHistoryProvider provider) {
_ = await provider.ReadAtTimeAsync("tag", new List<DateTime>(), CancellationToken.None);
}
}
}
""";
var diags = await Compile(userSrc);
diags.Length.ShouldBe(1);
diags[0].GetMessage().ShouldContain("ReadAtTimeAsync");
}
/// <summary>Verifies that a wrapped ReadRawAsync call inside a CapabilityInvoker lambda passes cleanly.</summary>
[Fact]
public async Task Wrapped_ReadRawAsync_InsideCapabilityInvokerLambda_PassesCleanly()
{
const string userSrc = """
using System;
using System.Threading;
using System.Threading.Tasks;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
namespace ZB.MOM.WW.OtOpcUa.Server {
public sealed class GoodHistoryRead {
public async Task DoIt(IHistoryProvider provider, CapabilityInvoker invoker) {
_ = await invoker.ExecuteAsync(DriverCapability.Read, "h1",
async ct => await provider.ReadRawAsync("tag", DateTime.MinValue, DateTime.MaxValue, 100, ct),
CancellationToken.None);
}
}
}
""";
var diags = await Compile(userSrc);
diags.ShouldBeEmpty();
}
// -----------------------------------------------------------------------
// IHistoryProvider — concrete driver receiver (FindImplementationForInterfaceMember branch)
// -----------------------------------------------------------------------
/// <summary>Verifies that a direct HistoryRead call on a concrete driver trips the diagnostic.</summary>
[Fact]
public async Task Direct_HistoryRead_OnConcreteDriver_TripsDiagnostic()
{
// ConcreteHistoryDriver implements IHistoryProvider with explicitly-named methods
// (same names here, but the test exercises the FindImplementationForInterfaceMember
// branch because the receiver type is the concrete class, not the interface).
const string userSrc = """
using System;
using System.Threading;
using System.Threading.Tasks;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Server {
public sealed class ConcreteHistoryDriver : IHistoryProvider {
public Task<HistoryReadResult> ReadRawAsync(string r, DateTime s, DateTime e, uint m, CancellationToken ct) => throw null!;
public Task<HistoryReadResult> ReadProcessedAsync(string r, DateTime s, DateTime e, TimeSpan i, CancellationToken ct) => throw null!;
}
public sealed class BadConcreteHistoryRead {
public async Task DoIt(ConcreteHistoryDriver driver) {
_ = await driver.ReadRawAsync("tag", DateTime.MinValue, DateTime.MaxValue, 100, CancellationToken.None);
}
}
}
""";
var diags = await Compile(userSrc);
diags.Length.ShouldBe(1);
diags[0].GetMessage().ShouldContain("ReadRawAsync");
}
// -----------------------------------------------------------------------
// IHostConnectivityProbe — synchronous member must NOT be flagged
// -----------------------------------------------------------------------
@@ -739,139 +594,9 @@ namespace SomeOther {
diags.ShouldBeEmpty();
}
// =======================================================================
// Analyzers-005 — IHistoryProvider default-interface-method asymmetry
// =======================================================================
/// <summary>Verifies that a direct ReadAtTimeAsync call on a concrete driver inheriting the DIM trips the diagnostic.</summary>
[Fact]
public async Task Direct_ReadAtTimeAsync_OnConcreteDriverInheritingDIM_TripsDiagnostic()
{
// ConcreteHistoryDriver does NOT override ReadAtTimeAsync (it inherits the DIM).
// The call site has a concrete-receiver type — the analyzer must still flag the call
// because the bound method (ReadAtTimeAsync) is the interface's own default impl.
const string userSrc = """
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Server {
public sealed class PartialHistoryDriver : IHistoryProvider {
public Task<HistoryReadResult> ReadRawAsync(string r, DateTime s, DateTime e, uint m, CancellationToken ct) => throw null!;
public Task<HistoryReadResult> ReadProcessedAsync(string r, DateTime s, DateTime e, TimeSpan i, CancellationToken ct) => throw null!;
// ReadAtTimeAsync + ReadEventsAsync NOT overridden — driver inherits the DIM throws.
}
public sealed class BadPartialHistoryCaller {
public async Task DoIt(PartialHistoryDriver driver) {
// Cast through the interface so the binder resolves to IHistoryProvider.ReadAtTimeAsync
// (the DIM). FindImplementationForInterfaceMember returns the interface method itself
// when nothing overrides it.
var iface = (IHistoryProvider)driver;
_ = await iface.ReadAtTimeAsync("tag", new List<DateTime>(), CancellationToken.None);
}
}
}
""";
var diags = await Compile(userSrc);
diags.Length.ShouldBe(1);
diags[0].GetMessage().ShouldContain("ReadAtTimeAsync");
}
/// <summary>Verifies that a direct ReadEventsAsync call on a concrete driver inheriting the DIM trips the diagnostic.</summary>
[Fact]
public async Task Direct_ReadEventsAsync_OnConcreteDriverInheritingDIM_TripsDiagnostic()
{
// Same as above but for ReadEventsAsync — confirms both DIMs are caught when the
// driver doesn't override them.
const string userSrc = """
using System;
using System.Threading;
using System.Threading.Tasks;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Server {
public sealed class PartialHistoryDriver2 : IHistoryProvider {
public Task<HistoryReadResult> ReadRawAsync(string r, DateTime s, DateTime e, uint m, CancellationToken ct) => throw null!;
public Task<HistoryReadResult> ReadProcessedAsync(string r, DateTime s, DateTime e, TimeSpan i, CancellationToken ct) => throw null!;
}
public sealed class BadPartialHistoryEventsCaller {
public async Task DoIt(PartialHistoryDriver2 driver) {
var iface = (IHistoryProvider)driver;
_ = await iface.ReadEventsAsync(null, DateTime.MinValue, DateTime.MaxValue, 100, CancellationToken.None);
}
}
}
""";
var diags = await Compile(userSrc);
diags.Length.ShouldBe(1);
diags[0].GetMessage().ShouldContain("ReadEventsAsync");
}
/// <summary>Verifies that a direct ReadAtTimeAsync call on a concrete driver overriding the DIM trips the diagnostic.</summary>
[Fact]
public async Task Direct_ReadAtTimeAsync_OnConcreteDriverOverridingDIM_TripsDiagnostic()
{
// Driver explicitly overrides ReadAtTimeAsync — the concrete-receiver call goes through
// the override; FindImplementationForInterfaceMember returns the override method, which
// SymbolEqualityComparer matches against method.
const string userSrc = """
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Server {
public sealed class FullHistoryDriver : IHistoryProvider {
public Task<HistoryReadResult> ReadRawAsync(string r, DateTime s, DateTime e, uint m, CancellationToken ct) => throw null!;
public Task<HistoryReadResult> ReadProcessedAsync(string r, DateTime s, DateTime e, TimeSpan i, CancellationToken ct) => throw null!;
public Task<HistoryReadResult> ReadAtTimeAsync(string r, IReadOnlyList<DateTime> ts, CancellationToken ct) => throw null!;
}
public sealed class BadFullHistoryCaller {
public async Task DoIt(FullHistoryDriver driver) {
_ = await driver.ReadAtTimeAsync("tag", new List<DateTime>(), CancellationToken.None);
}
}
}
""";
var diags = await Compile(userSrc);
diags.Length.ShouldBe(1);
diags[0].GetMessage().ShouldContain("ReadAtTimeAsync");
}
/// <summary>Verifies that a wrapped ReadAtTimeAsync DIM call inside a CapabilityInvoker lambda passes cleanly.</summary>
[Fact]
public async Task Wrapped_ReadAtTimeAsync_DIM_InsideCapabilityInvokerLambda_PassesCleanly()
{
// DIM wrapped properly — must not trip.
const string userSrc = """
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
namespace ZB.MOM.WW.OtOpcUa.Server {
public sealed class GoodHistoryAtTime {
public async Task DoIt(IHistoryProvider provider, CapabilityInvoker invoker) {
_ = await invoker.ExecuteAsync(DriverCapability.Read, "h1",
async ct => await provider.ReadAtTimeAsync("tag", new List<DateTime>(), ct),
CancellationToken.None);
}
}
}
""";
var diags = await Compile(userSrc);
diags.ShouldBeEmpty();
}
// =======================================================================
// Analyzers-008 — missing trip + pass-clean tests for ISubscribable.UnsubscribeAsync,
// IAlarmSource.UnsubscribeAlarmsAsync, IAlarmSource.AcknowledgeAsync, and the wrapped
// (pass-clean) path for IHistoryProvider.ReadEventsAsync (DIM).
// IAlarmSource.UnsubscribeAlarmsAsync, IAlarmSource.AcknowledgeAsync.
// =======================================================================
/// <summary>Verifies that a direct UnsubscribeAsync call trips the diagnostic.</summary>
@@ -965,32 +690,6 @@ namespace ZB.MOM.WW.OtOpcUa.Server {
diags[0].GetMessage().ShouldContain("AcknowledgeAsync");
}
/// <summary>Verifies that a wrapped ReadEventsAsync DIM call inside a CapabilityInvoker lambda passes cleanly.</summary>
[Fact]
public async Task Wrapped_ReadEventsAsync_DIM_InsideCapabilityInvokerLambda_PassesCleanly()
{
// ReadEventsAsync is a DIM — the wrapped path must not trip the diagnostic.
const string userSrc = """
using System;
using System.Threading;
using System.Threading.Tasks;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
namespace ZB.MOM.WW.OtOpcUa.Server {
public sealed class GoodHistoryEvents {
public async Task DoIt(IHistoryProvider provider, CapabilityInvoker invoker) {
_ = await invoker.ExecuteAsync(DriverCapability.Read, "h1",
async ct => await provider.ReadEventsAsync(null, DateTime.MinValue, DateTime.MaxValue, 0, ct),
CancellationToken.None);
}
}
}
""";
var diags = await Compile(userSrc);
diags.ShouldBeEmpty();
}
private static async Task<ImmutableArray<Diagnostic>> CompileWithoutStubs(string userSource)
{
var syntaxTrees = new[] { CSharpSyntaxTree.ParseText(userSource) };