diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverCapability.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverCapability.cs
index d9a38d49..a9ed7eca 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverCapability.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverCapability.cs
@@ -5,10 +5,10 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// Each value corresponds to one method (or tightly-related method group) on the
/// Core.Abstractions capability interfaces (, ,
/// , , ,
-/// , ).
+/// ).
///
///
-/// Per the per-capability retry policy in docs/v2/plan.md: Read / HistoryRead /
+/// Per the per-capability retry policy in docs/v2/plan.md: Read /
/// Discover / Probe / AlarmSubscribe auto-retry; does NOT retry unless the
/// tag-definition carries . 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
/// . Does NOT retry — ack is a write-shaped operation.
AlarmAcknowledge,
- /// reads (Raw/Processed/AtTime/Events). Retries by default.
- HistoryRead,
-
///
/// IDriver.InitializeAsync — connection establishment (R2-10 T10 closure: without this,
/// a driver that cannot connect contributes zero breaker input and the resilience panel stays
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/HistoryReadContracts.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/HistoryReadContracts.cs
new file mode 100644
index 00000000..81096ec3
--- /dev/null
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/HistoryReadContracts.cs
@@ -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.
+
+
+/// Result of a HistoryRead call.
+/// Returned samples in chronological order.
+/// Opaque token for the next call when more samples are available; null when complete.
+public sealed record HistoryReadResult(
+ IReadOnlyList Samples,
+ byte[]? ContinuationPoint);
+
+/// Aggregate function for processed history reads. Mirrors OPC UA Part 13 standard aggregates.
+///
+/// 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 — the server maps that to
+/// BadAggregateNotSupported rather than a generic failure. Append only: the numeric values
+/// are not persisted, but every switch over this enum must keep a total default.
+///
+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,
+}
+
+///
+/// Thrown by a history backend when it cannot serve the requested .
+/// The Server HistoryRead path translates it to BadAggregateNotSupported for that node only.
+///
+public sealed class HistoryAggregateNotSupportedException : NotSupportedException
+{
+ /// The aggregate the backend could not serve.
+ public HistoryAggregateType Aggregate { get; }
+
+ /// Creates the exception for .
+ /// The unsupported aggregate.
+ public HistoryAggregateNotSupportedException(HistoryAggregateType aggregate)
+ : base($"The historian backend does not support the {aggregate} aggregate.")
+ => Aggregate = aggregate;
+}
+
+///
+/// One row returned by — a historical
+/// alarm/event record, not the OPC UA live-event stream. Fields match the minimum set the
+/// Server needs to populate a HistoryEventFieldList for HistoryReadEvents responses.
+///
+/// Stable unique id for the event — driver-specific format.
+/// Source object that emitted the event. May differ from the sourceName filter the caller passed (fuzzy matches).
+/// Process-side timestamp — when the event actually occurred.
+/// Historian-side timestamp — when the historian persisted the row; may lag by the historian's buffer flush cadence.
+/// Human-readable message text.
+/// OPC UA severity (1-1000). Backends map their native priority scale onto this range.
+public sealed record HistoricalEvent(
+ string EventId,
+ string? SourceName,
+ DateTime EventTimeUtc,
+ DateTime ReceivedTimeUtc,
+ string? Message,
+ ushort Severity);
+
+/// Result of a call.
+/// Events in chronological order by EventTimeUtc.
+/// Opaque token for the next call when more events are available; null when complete.
+public sealed record HistoricalEventsResult(
+ IReadOnlyList Events,
+ byte[]? ContinuationPoint);
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorianDataSource.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorianDataSource.cs
index 7443f6be..9942954e 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorianDataSource.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorianDataSource.cs
@@ -5,15 +5,9 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// and resolved per OPC UA namespace, independent of any driver's lifecycle.
///
///
-/// Distinct from :
-///
-/// is a *driver capability* — the server
-/// dispatches to it via the driver instance.
-/// 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.
-///
+/// 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 /
/// shapes; backend-specific quality / type encodings
/// are translated to OPC UA StatusCode uints inside the data source.
@@ -86,9 +80,7 @@ public interface IHistorianDataSource : IDisposable
/// equivalent "use default" sentinel.
///
/// This surface declares and
- /// as required members — a server-side historian owns the full read surface, unlike
- /// 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.
///
/// The source name to filter events, or null to return events from all sources.
/// The start of the time range in UTC.
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriver.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriver.cs
index 02c353fc..f735d0f4 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriver.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriver.cs
@@ -4,7 +4,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// Required capability for every driver instance. Owns lifecycle, metadata, health.
/// Other capabilities (, ,
/// , , ,
-/// , ,
+/// ,
/// ) are composable — a driver implements only what its
/// backend actually supports.
///
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IHistoryProvider.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IHistoryProvider.cs
deleted file mode 100644
index 310ac626..00000000
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IHistoryProvider.cs
+++ /dev/null
@@ -1,237 +0,0 @@
-namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
-
-///
-/// 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).
-///
-///
-/// and are C# default interface
-/// methods that throw — 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, ,
-/// declares both methods as required because a registered historian owns the full read
-/// surface; the asymmetry is intentional.
-///
-public interface IHistoryProvider
-{
- ///
- /// Read raw historical samples for a single attribute over a time range.
- /// The Core wraps this with continuation-point handling.
- ///
- /// The full reference path to the attribute.
- /// Inclusive lower bound on the time range (UTC).
- /// Exclusive upper bound on the time range (UTC).
- /// Maximum number of values to return per node.
- /// A cancellation token to stop the operation.
- /// A task that returns the history read result containing samples and optional continuation point.
- Task ReadRawAsync(
- string fullReference,
- DateTime startUtc,
- DateTime endUtc,
- uint maxValuesPerNode,
- CancellationToken cancellationToken);
-
- ///
- /// Read processed (aggregated) samples — interval-bucketed average / min / max / etc.
- /// Optional — drivers that only support raw history can throw .
- ///
- /// The full reference path to the attribute.
- /// Inclusive lower bound on the time range (UTC).
- /// Exclusive upper bound on the time range (UTC).
- /// The time interval for bucketing samples.
- /// The aggregate function to apply to each bucket.
- /// A cancellation token to stop the operation.
- /// A task that returns the history read result containing aggregated samples and optional continuation point.
- Task ReadProcessedAsync(
- string fullReference,
- DateTime startUtc,
- DateTime endUtc,
- TimeSpan interval,
- HistoryAggregateType aggregate,
- CancellationToken cancellationToken);
-
- ///
- /// 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 .
- ///
- ///
- /// Default implementation throws. Drivers opt in by overriding; keeps existing
- /// IHistoryProvider implementations compiling without forcing a ReadAtTime path
- /// they may not have a backend for.
- ///
- /// The full reference path to the attribute.
- /// The list of timestamps at which to read values.
- /// A cancellation token to stop the operation.
- /// A task that returns the history read result containing samples at the requested times.
- Task ReadAtTimeAsync(
- string fullReference,
- IReadOnlyList timestampsUtc,
- CancellationToken cancellationToken)
- => throw new NotSupportedException(
- $"{GetType().Name} does not implement ReadAtTimeAsync. " +
- "Drivers whose backends support at-time reads override this method.");
-
- ///
- /// 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.
- ///
- ///
- /// 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.
- ///
- /// Inclusive lower bound on EventTimeUtc.
- /// Exclusive upper bound on EventTimeUtc.
- ///
- /// Upper cap on returned events — the driver's backend enforces this. The type is
- /// rather than (which the sibling raw / processed
- /// reads use for maxValuesPerNode) because callers and downstream historian
- /// adapters historically treat maxEvents <= 0 as a sentinel meaning
- /// "use the backend's default cap" (see WonderwareHistorianClient /
- /// HistorianDataSource). The asymmetry is intentional.
- ///
- /// Continuation contract when using the sentinel: When maxEvents <= 0
- /// 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
- /// to a non-null token so
- /// callers can detect truncation and page. A null ContinuationPoint means all
- /// matching events were returned — callers rely on this to decide whether to page.
- ///
- /// Request cancellation.
- /// A task that returns the historical events result containing matching event records and optional continuation point.
- ///
- /// 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 BadHistoryOperationUnsupported.
- ///
- /// Note the type asymmetry with /
- /// (both use uint maxValuesPerNode): event
- /// readers accept a signed int maxEvents so callers can pass 0 / negative as a
- /// "use default cap" sentinel without an extra parameter or overload.
- ///
- Task 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.");
-}
-
-/// Result of a HistoryRead call.
-/// Returned samples in chronological order.
-/// Opaque token for the next call when more samples are available; null when complete.
-public sealed record HistoryReadResult(
- IReadOnlyList Samples,
- byte[]? ContinuationPoint);
-
-/// Aggregate function for processed history reads. Mirrors OPC UA Part 13 standard aggregates.
-///
-/// 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 — the server maps that to
-/// BadAggregateNotSupported rather than a generic failure. Append only: the numeric values
-/// are not persisted, but every switch over this enum must keep a total default.
-///
-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,
-}
-
-///
-/// Thrown by a history backend when it cannot serve the requested .
-/// The Server HistoryRead path translates it to BadAggregateNotSupported for that node only.
-///
-public sealed class HistoryAggregateNotSupportedException : NotSupportedException
-{
- /// The aggregate the backend could not serve.
- public HistoryAggregateType Aggregate { get; }
-
- /// Creates the exception for .
- /// The unsupported aggregate.
- public HistoryAggregateNotSupportedException(HistoryAggregateType aggregate)
- : base($"The historian backend does not support the {aggregate} aggregate.")
- => Aggregate = aggregate;
-}
-
-///
-/// 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 HistoryReadResult.StatusCode other than
-/// BadAggregateNotSupported (which has its own ).
-/// The Server HistoryRead path surfaces verbatim as that node's result status
-/// instead of the generic BadHistoryOperationUnsupported, so a refused read is never mistaken for
-/// an empty Good one.
-///
-public sealed class HistoryReadStatusException : Exception
-{
- /// The raw OPC UA status code the upstream server returned for the node.
- public uint StatusCode { get; }
-
- /// Creates the exception for the upstream .
- /// The Bad OPC UA status code to surface for the node.
- /// is not a Bad code (severity bits 10/11).
- 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;
-}
-
-///
-/// One row returned by — a historical
-/// alarm/event record, not the OPC UA live-event stream. Fields match the minimum set the
-/// Server needs to populate a HistoryEventFieldList for HistoryReadEvents responses.
-///
-/// Stable unique id for the event — driver-specific format.
-/// Source object that emitted the event. May differ from the sourceName filter the caller passed (fuzzy matches).
-/// Process-side timestamp — when the event actually occurred.
-/// Historian-side timestamp — when the historian persisted the row; may lag by the historian's buffer flush cadence.
-/// Human-readable message text.
-/// OPC UA severity (1-1000). Drivers map their native priority scale onto this range.
-public sealed record HistoricalEvent(
- string EventId,
- string? SourceName,
- DateTime EventTimeUtc,
- DateTime ReceivedTimeUtc,
- string? Message,
- ushort Severity);
-
-/// Result of a call.
-/// Events in chronological order by EventTimeUtc.
-/// Opaque token for the next call when more events are available; null when complete.
-public sealed record HistoricalEventsResult(
- IReadOnlyList Events,
- byte[]? ContinuationPoint);
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResilienceOptions.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResilienceOptions.cs
index 4481bb66..aca30418 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResilienceOptions.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResilienceOptions.cs
@@ -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),
},
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs
index 764b9a37..cf34709b 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs
@@ -29,7 +29,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
/// monitored-item handles. That mechanic lands in PR 69.
///
///
-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
{
/// Authored tags whose TagConfig carried no usable nodeId, so there is no upstream node
/// to dial (Gitea #541). This driver's skip path was the quietest of the twelve: NamespaceMap
@@ -2258,335 +2258,6 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
public string DiagnosticId => $"opcua-alarm-sub-{Id}";
}
- // ---- IHistoryProvider (passthrough to upstream server) ----
-
- ///
- public async Task 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);
- }
-
- ///
- public async Task 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);
- }
-
- ///
- public async Task ReadAtTimeAsync(
- string fullReference, IReadOnlyList 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);
- }
-
- ///
- /// 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 . A Bad
- /// per-NODE HistoryReadResult.StatusCode is never read as empty data: BadAggregateNotSupported
- /// throws , any other Bad throws
- /// (Gitea #649). Good / GoodNoData / GoodMoreData / Uncertain are
- /// unchanged.
- ///
- private async Task 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();
- 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(); }
- }
-
- /// Map to the OPC UA Part 13 standard aggregate NodeId.
- /// The aggregation function type to map.
- /// The standard aggregate function NodeId.
- 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
- ];
-
- ///
- /// Builds the fixed canonical EventFilter the driver sends upstream for HistoryReadEvents —
- /// the six BaseEventType fields the OtOpcUa server projects ().
- /// The clause order is load-bearing: reads results by index.
- ///
- /// An EventFilter with six SimpleAttributeOperand value clauses.
- 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;
- }
-
- ///
- /// Maps an upstream (field arrays ordered to match
- /// ) onto records. Defensive:
- /// short / null / wrong-typed fields degrade to null/default rather than throwing.
- ///
- /// The upstream history-event payload.
- /// The mapped historical events in upstream order.
- internal static IReadOnlyList MapHistoryEvents(HistoryEvent historyEvent)
- {
- if (historyEvent?.Events is not { Count: > 0 } rows) return [];
- var result = new List(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; }
- }
-
- ///
- // 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 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 ----
///
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/ResilienceFormModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/ResilienceFormModel.cs
index e0f64f0a..83b72980 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/ResilienceFormModel.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/ResilienceFormModel.cs
@@ -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
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryPaging.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryPaging.cs
index effb0e99..61008a9d 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryPaging.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryPaging.cs
@@ -193,7 +193,7 @@ internal static class HistoryPaging
/// The single SourceTimestamp every tie in the cluster shares.
/// The (inclusive) upper bound of the read window; unchanged across pages.
/// OpcUaServer-003, accepted by design: the backend contract
- /// (IHistoryProvider.ReadRawAsync) treats endUtc as EXCLUSIVE, so the
+ /// (IHistorianDataSource.ReadRawAsync) treats endUtc as EXCLUSIVE, so the
/// next <= endUtc test here is deliberately the conservative side — at worst one extra
/// empty resume page ([endUtc, endUtc) → GoodNoData, no continuation point), never a
/// dropped or duplicated sample. Tightening to < would be correct only if the live
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
index 01f81a8f..3584a5f3 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
@@ -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.
diff --git a/src/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers/UnwrappedCapabilityCallAnalyzer.cs b/src/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers/UnwrappedCapabilityCallAnalyzer.cs
index 5c5bdc21..10506ad4 100644
--- a/src/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers/UnwrappedCapabilityCallAnalyzer.cs
+++ b/src/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers/UnwrappedCapabilityCallAnalyzer.cs
@@ -26,8 +26,8 @@ namespace ZB.MOM.WW.OtOpcUa.Analyzers;
/// IReadable.ReadAsync still trips the rule.
///
///
-/// Default-interface-method handling:IHistoryProvider.ReadAtTimeAsync and
-/// IHistoryProvider.ReadEventsAsync ship as DIM bodies. When a driver inherits the
+/// Default-interface-method handling: a guarded interface member may ship as a DIM
+/// body. When a driver inherits the
/// DIM (no override), FindImplementationForInterfaceMember returns the interface's
/// own method symbol, which still equals 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",
];
///
@@ -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.");
///
public override ImmutableArray SupportedDiagnostics { get; } = ImmutableArray.Create(Rule);
diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/Historian/HistoryReadStatusExceptionTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/Historian/HistoryReadStatusExceptionTests.cs
deleted file mode 100644
index c943a8c1..00000000
--- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/Historian/HistoryReadStatusExceptionTests.cs
+++ /dev/null
@@ -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(() => new HistoryReadStatusException(code));
-}
diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/Historian/IHistorianDataSourceContractTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/Historian/IHistorianDataSourceContractTests.cs
index 562dab11..458e6796 100644
--- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/Historian/IHistorianDataSourceContractTests.cs
+++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/Historian/IHistorianDataSourceContractTests.cs
@@ -153,22 +153,6 @@ public sealed class IHistorianDataSourceContractTests
parameter!.ParameterType.ShouldBe(expectedType);
}
- /// Verifies that history provider max parameters have the expected type.
- /// The name of the method to test.
- /// The name of the parameter to verify.
- /// The expected parameter type.
- [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);
- }
-
///
/// Core.Abstractions-009 (OpcUaServer-002): when maxEvents <= 0 the contract
/// requires implementations to set
@@ -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");
}
-
- ///
- /// Core.Abstractions-008: and
- /// are C# default interface methods
- /// (drivers opt in), whereas and
- /// 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.
- ///
- /// The name of the optional method to check.
- [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.");
- }
}
diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/InterfaceIndependenceTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/InterfaceIndependenceTests.cs
index 23971b1a..cd58029a 100644
--- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/InterfaceIndependenceTests.cs
+++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/InterfaceIndependenceTests.cs
@@ -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.");
}
+
+ ///
+ /// Gitea #655: HistoryRead is served server-side by IHistorianDataSource; no driver-side history
+ /// capability exists. Re-adding one without also adding a dispatch path would recreate dead surface.
+ ///
+ [Fact]
+ public void NoDriverSideHistoryCapability_Exists()
+ {
+ Assembly.GetType("ZB.MOM.WW.OtOpcUa.Core.Abstractions.IHistoryProvider").ShouldBeNull();
+ Enum.GetNames().ShouldNotContain("HistoryRead");
+ }
}
diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/DriverResilienceOptionsParserTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/DriverResilienceOptionsParserTests.cs
index bdf0e519..89a5a4d4 100644
--- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/DriverResilienceOptionsParserTests.cs
+++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/DriverResilienceOptionsParserTests.cs
@@ -138,6 +138,33 @@ public sealed class DriverResilienceOptionsParserTests
DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Read]);
}
+ ///
+ /// Gitea #655: the retired HistoryRead capability may still sit in stored
+ /// DriverInstance.ResilienceConfig rows. It must parse (diagnostic only, never a throw), be skipped,
+ /// and leave every other override in the same blob applied.
+ ///
+ [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]);
+ }
+
/// Verifies that top-level property names are case insensitive.
[Fact]
public void PropertyNames_AreCaseInsensitive()
diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/DriverResilienceOptionsTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/DriverResilienceOptionsTests.cs
index 1bf1a56c..94403874 100644
--- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/DriverResilienceOptionsTests.cs
+++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/DriverResilienceOptionsTests.cs
@@ -116,7 +116,6 @@ public sealed class DriverResilienceOptionsTests
/// The driver capability to test.
[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)]
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcPlcFixture.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcPlcFixture.cs
index 037343c1..400abfd3 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcPlcFixture.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcPlcFixture.cs
@@ -16,9 +16,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests;
///
/// Why opc-plc over loopback against our own server — (1) independent
/// cert chain + user-token handling catches interop bugs loopback can't;
-/// (2) built-in alarm ConditionType + history simulation gives
-/// +
-/// coverage without a custom
+/// (2) built-in alarm ConditionType simulation gives
+/// 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 open62541/open62541 as a
/// second image once this lands, for fully-independent-stack interop.
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcUaClientSmokeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcUaClientSmokeTests.cs
index 96deeaf1..34276308 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcUaClientSmokeTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcUaClientSmokeTests.cs
@@ -92,32 +92,4 @@ public sealed class OpcUaClientSmokeTests(OpcPlcFixture sim)
await drv.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
}
-
- ///
- /// 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.
- ///
- [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();
- }
}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientHistoryTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientHistoryTests.cs
deleted file mode 100644
index 7fe81eca..00000000
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientHistoryTests.cs
+++ /dev/null
@@ -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
-{
- /// Every member (#285: the full Part 13 set, not just five).
- public static IEnumerable