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:
@@ -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 <= 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 <= 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 & 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>
|
||||
|
||||
+1
-1
@@ -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 <= 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><</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);
|
||||
|
||||
-21
@@ -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));
|
||||
}
|
||||
-42
@@ -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 <= 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");
|
||||
}
|
||||
}
|
||||
|
||||
+27
@@ -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)]
|
||||
|
||||
+2
-3
@@ -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.
|
||||
|
||||
-28
@@ -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);
|
||||
}
|
||||
}
|
||||
+3
-106
@@ -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
|
||||
|
||||
-258
@@ -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)
|
||||
|
||||
+1
-302
@@ -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) };
|
||||
|
||||
Reference in New Issue
Block a user