Merge hw3-history (#655 delete dead driver-side history capability + client surfaces Bad HistoryRead)

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-09 00:14:47 -04:00
47 changed files with 289 additions and 1709 deletions
+4
View File
@@ -1113,6 +1113,10 @@ host logger factory (wired in `DriverFactoryBootstrap`; the FOCAS and TwinCAT fa
- **MQTT:** Plain `writeTopic` writes publish at QoS 1 (Sparkplug NCMD/DCMD stay QoS 0); the Sparkplug
primary-host claim backs off 2 s → 30 s; writes encode the birth's own datatype (unsupported birth types
`BadNotSupported`).
- **No driver-side history (#655):** `IHistoryProvider`, `DriverCapability.HistoryRead`, `HistoryReadStatusException` and
the OpcUaClient history passthrough are deleted — nothing ever dispatched HistoryRead to a driver; it is served only by the
server-wide `IHistorianDataSource`. `isHistorized` on an OpcUaClient tag means OtOpcUa records it, not that upstream
history is proxied. A stored `ResilienceConfig` still carrying a `HistoryRead` key parses (diagnostic only).
- **FOCAS / AbLegacy:** background loops are joined (≤ 5 s) on shutdown.
- **FOCAS (#257/#266):** `cnc_statinfo` flags are a `Status/` fixed-tree subtree (`FIXED:Status/Run`…, capability-probed);
adjacent PMC tags in one poll batch coalesce into one `pmc_rdpmcrng` (`PmcCoalesce` config, default on) — a refused range
+4
View File
@@ -195,6 +195,10 @@ Reads historical data from a node. Supports raw history reads and aggregate (pro
`--start` and `--end` are parsed with `CultureInfo.InvariantCulture` and treated as UTC; supply
them in ISO 8601 UTC form (`YYYY-MM-DDTHH:MM:SSZ`) for unambiguous behaviour across hosts.
A Bad per-node status from the server (for example `BadHistoryOperationUnsupported` for a node that is not
historized, or `BadUserAccessDenied`) fails the command with that status named. It is never printed as
`0 values returned`; only `BadNoData` and Good/GoodNoData read as an empty result.
```bash
# Raw history
otopcua-cli historyread -u opc.tcp://localhost:4840/OtOpcUa \
+7 -11
View File
@@ -10,7 +10,7 @@ projects, the Runtime, and the ControlPlane. Each subsection below gives the
purpose, the key members, and where it is implemented/used.
The capability interfaces a driver opts into (`IReadable`, `IWritable`,
`ITagDiscovery`, `ISubscribable`, `IAlarmSource`, `IHistoryProvider`,
`ITagDiscovery`, `ISubscribable`, `IAlarmSource`,
`IHostConnectivityProbe`, `IPerCallHostResolver`, `IRediscoverable`) are
covered elsewhere and discovered by the server via `is`-checks on the
`IDriver` instance. The interfaces here are the *plumbing* the server uses to
@@ -560,18 +560,14 @@ the interface currently has the contract defined but no implementations.
The server-side historian read surface. Registered with the server's history
router and resolved **per OPC UA namespace**, independent of any driver's
lifecycle. This is distinct from the driver capability `IHistoryProvider`:
- `IHistoryProvider` is a *driver capability* — the server dispatches to it via
the driver instance.
- `IHistorianDataSource` is a *server registration* — the server resolves it by
namespace and calls it directly, so one historian (the HistorianGateway) can
serve many drivers' nodes, and drivers can restart without dropping history
availability.
lifecycle. It is a *server registration*, not a driver capability: drivers carry
no history surface (there is no `IHistoryProvider`; deleted in Gitea #655). The
server resolves the data source by namespace and calls it directly, so one
historian (the HistorianGateway) can serve many drivers' nodes, and drivers can
restart without dropping history availability.
The interface is `: IDisposable` and declares the full read surface as
**required** members (unlike `IHistoryProvider`, where at-time/event reads are
optional default-impl methods so legacy drivers can stay raw-only):
**required** members:
- `ReadRawAsync(fullReference, startUtc, endUtc, maxValuesPerNode, ct)` — raw
historical samples over a time range.
+9 -11
View File
@@ -364,18 +364,16 @@ HistorianGateway using that source. (Alarm-history `ReadEvents` requires the gat
`BaseEventType` select clauses — `EventId`, `SourceName`, `Time`, `ReceiveTime`, `Message`,
and `Severity`; an unsupported select operand returns a null field (spec-conformant).
### OpcUaClient driver — upstream passthrough for all four variants
### No driver-side history (Gitea #655)
The OpcUaClient driver's `IHistoryProvider` implementation forwards **all four** history-read
variants (Raw, Processed, AtTime, and Events) to its upstream OPC UA server. For the Events
variant it sends a fixed canonical `BaseEventType` `EventFilter` selecting the standard six
fields (`EventId`, `SourceName`, `Time`, `ReceiveTime`, `Message`, `Severity`) and maps the
upstream `HistoryEvent` onto `HistoricalEvent` — the same six-field projection the OtOpcUa
node-manager itself projects when serving event history. This is a **driver-level capability**:
the OpcUaClient driver acts as a passthrough to whatever historian the upstream server exposes,
and is independent of the single server-side `IHistorianDataSource` backend
(`GatewayHistorianDataSource` / `NullHistorianDataSource`) that the OtOpcUa node-manager
dispatches HistoryRead to for tags on other drivers (Galaxy, Modbus, S7, etc.).
History is served **only** by the server-wide `IHistorianDataSource`
(`GatewayHistorianDataSource` / `NullHistorianDataSource`). No driver contributes
history: the OpcUaClient driver does **not** proxy its upstream server's
HistoryRead (the former `IHistoryProvider` passthrough was never dispatched to and
was deleted). On an OpcUaClient tag, `isHistorized` means "OtOpcUa records this
value into the historian from now on", not "forward history reads upstream". A
node without `isHistorized` has no HistoryRead access bit and answers
`BadHistoryOperationUnsupported`.
### Graceful degradation
+1 -2
View File
@@ -27,7 +27,7 @@ The lifecycle facade `OpcUaApplicationHost` (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUa
## Resilience and capability dispatch
Driver-capability calls (`IReadable.ReadAsync`, `IWritable.WriteAsync`, `ITagDiscovery.DiscoverAsync`, `ISubscribable.SubscribeAsync/UnsubscribeAsync`, the `IHostConnectivityProbe` probe loop, `IAlarmSource` surfaces, and the four `IHistoryProvider` reads) are routed through a `CapabilityInvoker` (`src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/CapabilityInvoker.cs`) so the Polly resilience pipeline (retry / timeout / breaker) applies. There is one invoker per `(DriverInstance, IDriver)` pair; all invokers share the process-singleton `DriverResiliencePipelineBuilder`, which keys pipelines on `(DriverInstanceId, hostName, DriverCapability)`. Per-instance resilience options come from the driver's tier — the `DriverTier` passed at factory registration, resolved via `DriverFactoryRegistry.GetTier` (every driver is Tier A today, since no factory passes one) — plus per-instance JSON overrides parsed from `DriverInstance.ResilienceConfig` by `DriverResilienceOptionsParser`. (It was never `DriverTypeRegistry`; that class was vestigial and was deleted in Gitea #522.)
Driver-capability calls (`IReadable.ReadAsync`, `IWritable.WriteAsync`, `ITagDiscovery.DiscoverAsync`, `ISubscribable.SubscribeAsync/UnsubscribeAsync`, the `IHostConnectivityProbe` probe loop, `IAlarmSource` surfaces) are routed through a `CapabilityInvoker` (`src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/CapabilityInvoker.cs`) so the Polly resilience pipeline (retry / timeout / breaker) applies. There is one invoker per `(DriverInstance, IDriver)` pair; all invokers share the process-singleton `DriverResiliencePipelineBuilder`, which keys pipelines on `(DriverInstanceId, hostName, DriverCapability)`. Per-instance resilience options come from the driver's tier — the `DriverTier` passed at factory registration, resolved via `DriverFactoryRegistry.GetTier` (every driver is Tier A today, since no factory passes one) — plus per-instance JSON overrides parsed from `DriverInstance.ResilienceConfig` by `DriverResilienceOptionsParser`. (It was never `DriverTypeRegistry`; that class was vestigial and was deleted in Gitea #522.)
The `OTOPCUA0001` Roslyn analyzer (`src/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers/UnwrappedCapabilityCallAnalyzer.cs`, category `OtOpcUa.Resilience`, severity Warning) flags direct driver-capability calls that bypass the invoker.
@@ -37,7 +37,6 @@ The `OTOPCUA0001` Roslyn analyzer (`src/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers/Unwr
| Write | `IWritable.WriteAsync` | `ExecuteWriteAsync(host, isIdempotent, …)` — disables retries for non-idempotent writes per `WriteIdempotentAttribute` / decisions #44-45, #143 |
| Discovery | `ITagDiscovery.DiscoverAsync` | `ExecuteAsync(DriverCapability.Discover, host, …)` |
| Subscribe / Unsubscribe | `ISubscribable.SubscribeAsync/UnsubscribeAsync` | `ExecuteAsync(DriverCapability.Subscribe, host, …)` |
| HistoryRead (raw / processed / at-time / events) | `IHistoryProvider.*Async` | `ExecuteAsync(DriverCapability.HistoryRead, host, …)` |
| Alarm subscribe / unsubscribe / acknowledge | `IAlarmSource.SubscribeAlarmsAsync/UnsubscribeAlarmsAsync/AcknowledgeAsync` | via `AlarmSurfaceInvoker` (`src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/AlarmSurfaceInvoker.cs`), which fans out per host |
The host name fed to the invoker comes from `IPerCallHostResolver.ResolveHost(fullReference)` when the driver implements it (multi-host drivers: AB CIP, Modbus, FOCAS, TwinCAT, AB Legacy resolve per device). Single-host drivers fall back to `DriverInstanceId`, preserving the per-instance pipeline-key semantics (decision #144).
+2 -2
View File
@@ -12,7 +12,7 @@ The project was originally called **LmxOpcUa** (a single-driver Galaxy/MXAccess
> **v2 (2026-05-26):** the separate `OtOpcUa.Server` + `OtOpcUa.Admin` services fused into a single role-gated `OtOpcUa.Host` binary, joined by an Akka.NET cluster. See [v2 design](plans/2026-05-26-akka-hosting-alignment-design.md) for the architectural decision.
- **Core** owns shared abstractions (driver capability contracts, scripting, virtual tags, alarm historian).
- **Drivers** plug in via capability interfaces in `ZB.MOM.WW.OtOpcUa.Core.Abstractions`: `IDriver`, `IReadable`, `IWritable`, `ITagDiscovery`, `ISubscribable`, `IHostConnectivityProbe`, `IAlarmSource`, `IHistoryProvider`, `IPerCallHostResolver`. Each driver opts into whichever it supports.
- **Drivers** plug in via capability interfaces in `ZB.MOM.WW.OtOpcUa.Core.Abstractions`: `IDriver`, `IReadable`, `IWritable`, `ITagDiscovery`, `ISubscribable`, `IHostConnectivityProbe`, `IAlarmSource`, `IPerCallHostResolver`. Each driver opts into whichever it supports.
- **Host** (`src/Server/ZB.MOM.WW.OtOpcUa.Host`) is the single fused binary (.NET 10, AnyCPU). `OTOPCUA_ROLES` env decides what to mount: `admin` (Blazor + control-plane singletons), `driver` (OPC UA endpoint + per-node actors), or both. See [ServiceHosting.md](ServiceHosting.md).
- **Cluster + ControlPlane + Runtime + AdminUI + Security** sit between Core and Host. The cluster glues per-node actors into one logical fleet; the control-plane singletons (deploy coordinator, audit writer, redundancy state) live on the admin role-leader. See [Redundancy.md](Redundancy.md).
- The Galaxy driver still reaches MXAccess via gRPC to a separately-installed **mxaccessgw** sidecar (sibling repo).
@@ -33,7 +33,7 @@ The project was originally called **LmxOpcUa** (a single-driver Galaxy/MXAccess
| [AlarmHistorian.md](AlarmHistorian.md) | `Core.AlarmHistorian` store-and-forward sink — `LocalDbStoreAndForwardSink`, the replicated `alarm_sf_events` buffer + Primary-only drain, `IAlarmHistorianWriter`, dead-letter/retry/eviction |
| [DataTypeMapping.md](v1/DataTypeMapping.md) | Per-driver `DriverAttributeInfo` → OPC UA variable types (v1 archive — live mapping is in `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/DataTypeMap.cs`) |
| [IncrementalSync.md](IncrementalSync.md) | Address-space rebuild on redeploy + `sp_ComputeGenerationDiff` |
| [HistoricalDataAccess.md](v1/HistoricalDataAccess.md) | `IHistoryProvider` as a per-driver optional capability (v1 archive) |
| [HistoricalDataAccess.md](v1/HistoricalDataAccess.md) | Historical v1 design: history as a per-driver capability (retired; history is now server-side, see [Historian.md](Historian.md)) |
| [VirtualTags.md](VirtualTags.md) | `Core.Scripting` + `Core.VirtualTags` — Roslyn script sandbox, engine, dispatch alongside driver tags |
| [ScriptEditor.md](ScriptEditor.md) | Monaco script editor — Roslyn-backed IntelliSense (completions, diagnostics, hover, tag-path) for C# virtual-tag scripts; `AdminUI/ScriptAnalysis/` backend |
| [ScriptedAlarms.md](ScriptedAlarms.md) | `Core.ScriptedAlarms` — script-predicate `IAlarmSource` + Part 9 state machine |
+1 -1
View File
@@ -95,7 +95,7 @@ with `BadUserAccessDenied`.
Four overrides — `HistoryReadRawModified`, `HistoryReadProcessed`, `HistoryReadAtTime`,
`HistoryReadEvents` — route to the **server-wide** `IHistorianDataSource` (the HistorianGateway-backed
reader, or `NullHistorianDataSource` when `ServerHistorian:Enabled=false`, which returns `GoodNoData`).
This is not a per-driver `IHistoryProvider` dispatch.
Drivers carry no history capability.
**No authorization check runs on any of the four.** Unlike `OnWriteValue`, the SDK does *not* hold the
node-manager `Lock` while invoking them, so these paths may await.
+1 -1
View File
@@ -84,7 +84,7 @@ multiple gateways is not.
### 4. Alarms / history
PCCC has no alarm object + no history object. Driver doesn't implement
`IAlarmSource` or `IHistoryProvider` — no test coverage is the correct shape.
`IAlarmSource` (history is server-side for every driver) — no test coverage is the correct shape.
### 5. File-type coverage
+1 -1
View File
@@ -34,7 +34,7 @@ For the driver spec (capability surface, config shape, addressing), see [docs/v2
+---------------------------------------+
```
History reads moved server-side in PR 7.2 (`IHistoryRouter`). Galaxy no longer implements `IHistoryProvider` of its own.
History reads are served server-side by `IHistorianDataSource` (HistorianGateway); Galaxy has no history capability of its own.
`IAlarmSource` was retired with PR 7.2 and **restored in PR B.2** of the
alarms-over-gateway epic ([docs/plans/alarms-over-gateway.md](../plans/alarms-over-gateway.md)).
+2 -2
View File
@@ -87,8 +87,8 @@ boundary splits, etc.).
### 5. Alarms / history
Not a Modbus concept. Driver doesn't implement `IAlarmSource` or
`IHistoryProvider`; no test coverage is the correct shape.
Not a Modbus concept. Driver doesn't implement `IAlarmSource` (no driver has a history
capability; history is server-side); no test coverage is the correct shape.
## When to trust the Modbus fixture, when to reach for a rig
+3 -3
View File
@@ -23,9 +23,9 @@ coverage map, see [Modbus-Test-Fixture.md](Modbus-Test-Fixture.md).
## Capability Surface
`ModbusDriver : IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver, IDisposable, IAsyncDisposable`
(`Driver.Modbus/ModbusDriver.cs`). There is **no `IAlarmSource`** and no
`IHistoryProvider` — the Modbus protocol expresses neither, so those capabilities
are out of scope by design.
(`Driver.Modbus/ModbusDriver.cs`). There is **no `IAlarmSource`** — the Modbus protocol has no
alarm object, so that capability is out of scope by design. (No driver carries a
history capability; history is server-side.)
| Capability | Implementation entry point | Notes |
|------------|---------------------------|-------|
+1 -1
View File
@@ -55,7 +55,7 @@ public sealed class MqttDriver
| `IHostConnectivityProbe` | `GetHostStatuses` | 1-second poll of `MqttConnection.State`; `HostName` is `"{host}:{port}"` |
| `IRediscoverable` | `OnRediscoveryNeeded` | Nothing raises it in Plain mode. **Sparkplug raises it** when a birth for an *authored* scope carries a metric-name set different from the last one seen for that scope — both conditions are anti-storm, see [Rediscovery](#rediscovery-sparkplug) |
**Not implemented:** `IPerCallHostResolver`, `IAlarmSource`, `IHistoryProvider`.
**Not implemented:** `IPerCallHostResolver`, `IAlarmSource`.
Materialized nodes are `IsAlarm: false`, `IsHistorized: false`. A node is
`SecurityClassification.Operate` when its tag declares a write destination and
`ViewOnly` when it does not — the browse-time answer comes from the same encoder the write path uses,
+5 -14
View File
@@ -58,7 +58,7 @@ Wire-level surfaces verified: `IDriver` + `IReadable` + `ISubscribable` +
against the in-process OtOpcUa server (`ServerEventRig`, authenticating with the rig's API-key credential):
read (+ missing node), write through the node-write gateway (+ read-only refusal, + failed-write revert),
subscribe/unsubscribe, host-status Running -> not-Running when the server stops, and discovery. These need
no Docker. The HistoryRead wire leg is deferred (needs an `IHistorianDataSource` stub on the server).
no Docker. There is no HistoryRead leg: the driver has no history capability (#655).
### Unit
@@ -82,11 +82,10 @@ mirrors the full capability matrix):
driver config
- `OpcUaClientAlarmTests` — A&E severity bucket (1–1000 → Low / Medium /
High / Critical), subscribe / unsubscribe / ack contract
- `OpcUaClientHistoryTests` — historical data read + interpolation contract
Capability surfaces whose contract is verified: `IDriver`, `ITagDiscovery`,
`IReadable`, `IWritable`, `ISubscribable`, `IHostConnectivityProbe`,
`IAlarmSource`, `IHistoryProvider`.
`IAlarmSource`.
## What it does NOT cover
@@ -113,15 +112,7 @@ The lossy window is not characterized.
session — the shape that breaks publish-queue-size tuning in the wild — is
not exercised.
### 4. Real historian mappings
`IHistoryProvider.ReadRawAsync` + `ReadProcessedAsync` +
`ReadAtTimeAsync` + `ReadEventsAsync` are contract-mocked. Against a real
historian (AVEVA Historian, Prosys historian, Kepware LocalHistorian) each
has specific interpolation + bad-quality-handling quirks the contract test
doesn't see.
### 5. Real A&E events
### 4. Real A&E events
Alarm subscription is mocked via filtered monitored items; the actual
`EventFilter` select-clause behavior against a server that exposes typed
@@ -187,8 +178,8 @@ Beyond that:
- `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/Docker/docker-compose.yml`
— `mcr.microsoft.com/iotedge/opc-plc:2.14.10` with `--ut --aa --alm --pn=50000`
- `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs` — ctor +
session-factory seam tests mock through; implements `IAlarmSource` +
`IHistoryProvider` (unique among drivers); does NOT implement `IRediscoverable`
session-factory seam tests mock through; implements `IAlarmSource`;
does NOT implement `IRediscoverable`
- `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/DualEndpointTests.cs` —
the v2 dual-endpoint integration harness a future loopback client test could
piggyback on (v1 `OpcUaServerIntegrationTests.cs` retired with the v1 server project)
+11 -29
View File
@@ -79,7 +79,7 @@ initialise (the AdminUI form drops them on the next save). Pinned by
## Capability surface
`OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IAlarmSource, IHistoryProvider`
`OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IAlarmSource`
(`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs:31`).
| Capability | Path | Notes |
@@ -90,7 +90,6 @@ initialise (the AdminUI form drops them on the next save). Pinned by
| `ISubscribable` | native OPC UA subscriptions / monitored items | The remote server pushes data changes |
| `IHostConnectivityProbe` | session keep-alive | Host key is the endpoint URL actually connected to after the failover sweep |
| `IAlarmSource` | `SubscribeAlarmsAsync` (EventFilter) + `AcknowledgeAsync` | Subscribes to upstream alarm/condition events and forwards acks |
| `IHistoryProvider` | `ReadRawAsync` / `ReadProcessedAsync` / `ReadAtTimeAsync` → `Session.HistoryReadAsync` | **Unique to this driver** — passthrough history read against the upstream server |
> **Native alarms (#619).** An alarm raw tag is `TagConfig` `{ "nodeId": "<upstream SourceNode>", "alarm": {...} }`.
> The host subscribes with RawPaths and routes on (driver, RawPath), so the driver matches each
@@ -112,35 +111,18 @@ initialise (the AdminUI form drops them on the next save). Pinned by
> This driver does **not** implement `IRediscoverable` — there is no
> push-driven rediscovery signal from a remote OPC UA server in this driver.
> `IHistoryProvider` is implemented by no other driver; history reads for every
> other source route server-side through `IHistoryRouter`.
### History passthrough
### History: not supported (Gitea #655)
`IHistoryProvider` forwards `HistoryRead` to the upstream server's own historian.
Raw, processed (the full `HistoryAggregateType` set — Average / Minimum / Maximum / Total / Count plus
Interpolative, TimeAverage, Start, End, Delta, Range, Min/Max ActualTime, Start/EndBound, DeltaBounds,
DurationGood/Bad, PercentGood/Bad, WorstQuality, NumberOfTransitions, AnnotationCount and the
population StandardDeviation/Variance — each mapped to its OPC UA Part 13 standard aggregate NodeId; #285),
and at-time reads are supported; each
returned `DataValue` keeps its upstream `StatusCode` and timestamps verbatim.
**The per-node `HistoryReadResult.StatusCode` is honoured (#649).** The driver never reads a Bad node status as
empty data: `BadAggregateNotSupported` on a Processed read throws `HistoryAggregateNotSupportedException`, and any
other Bad status (`BadUserAccessDenied`, `BadNodeIdUnknown`, `BadHistoryOperationUnsupported`, ...) throws
`HistoryReadStatusException` carrying the code (the constructor rejects non-Bad codes). `Good`, `GoodNoData`,
`GoodMoreData` (continuation point) and `Uncertain` are unchanged, and a node-level `BadNoData` is still an empty
result. The status is matched on its code bits, so an info/overflow sub-status does not dodge the match.
⚠️ **This is currently latent.** The OtOpcUa server does not dispatch HistoryRead to a driver's `IHistoryProvider`
(it serves only the server-wide `IHistorianDataSource`, see `docs/Historian.md`), so no OPC UA client of OtOpcUa
sees this driver's history results or its refusals today. The server's HistoryRead arms (`ServeNodeAsync`,
`ServeRawPagedAsync`) do map a `HistoryReadStatusException` thrown by an `IHistorianDataSource` to that node's
status verbatim, and the driver/wire tests exercise that path; if driver history is ever routed to the server,
refusals will reach clients correctly instead of as empty Good data. A routed deterministic refusal would also
need excluding from the HistoryRead pipeline's retry/breaker handling.
Event-history (`ReadEventsAsync`) is left at the interface default — the
interface doesn't yet carry the EventFilter surface needed to forward it.
This driver does **not** proxy the upstream server's HistoryRead. The server serves
HistoryRead only through the server-wide `IHistorianDataSource` (see `docs/Historian.md`);
drivers carry no history capability, and the former `IHistoryProvider` passthrough
(Raw / Processed / AtTime / Events) was never dispatched to and has been deleted.
`isHistorized` on an OpcUaClient tag means OtOpcUa records the value into the historian
from now on (numeric only), not that upstream history is forwarded. A tag without
`isHistorized` has no HistoryRead access bit and answers `BadHistoryOperationUnsupported`.
If upstream-history proxying is wanted later, it needs a new intent key, a dispatch path
through `DriverHostActor`, and a continuation-point rework — see #655.
### Certificate trust
+4 -5
View File
@@ -9,10 +9,9 @@ OtOpcUa is a multi-driver OPC UA server. The Core (`ZB.MOM.WW.OtOpcUa.Core` + `C
- `IHostConnectivityProbe` — per-host reachability events
- `IPerCallHostResolver` — multi-host drivers that route each call to a target endpoint at dispatch time
- `IAlarmSource` — driver-emitted OPC UA A&C events
- `IHistoryProvider` — driver-side raw / processed / at-time / events HistoryRead (see [HistoricalDataAccess.md](../v1/HistoricalDataAccess.md))
- `IRediscoverable` — driver-initiated address-space rebuild notifications
- `ISkippedTagReporter` — authored tags the driver could not build into its poll table (see [Skipped tags](#skipped-tags) below)
- `IHistorianDataSource` — server-side historian read backend registration (the HistorianGateway backend), distinct from the driver-side `IHistoryProvider` HistoryRead path
- `IHistorianDataSource` — server-side historian read backend registration (the HistorianGateway backend) (drivers carry no history capability; Gitea #655)
Each driver opts into only the capabilities it supports. Every async capability call at the Server dispatch layer goes through `CapabilityInvoker` (`Core/Resilience/CapabilityInvoker.cs`), which wraps it in a Polly pipeline keyed on `(DriverInstanceId, HostName, DriverCapability)`. The `OTOPCUA0001` analyzer enforces the wrap at build time. Drivers themselves never depend on Polly; they just implement the capability interface and let the Core wrap it.
@@ -71,7 +70,7 @@ Three properties worth knowing:
| [AB Legacy](AbLegacy.md) | `Driver.AbLegacy` | A | libplctag PCCC | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver | SLC 500 / MicroLogix. File-based addressing (`N7:0`, `F8:0`) — no symbol table, tag list is user-authored in the config DB |
| [TwinCAT](TwinCAT.md) | `Driver.TwinCAT` | B | Beckhoff `TwinCAT.Ads` (`TcAdsClient`) | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver, IRediscoverable | The only native-notification driver outside Galaxy — ADS delivers `ValueChangedCallback` events the driver forwards straight to `ISubscribable.OnDataChange` without polling. Symbol tree uploaded via `SymbolLoaderFactory` |
| [FOCAS](FOCAS.md) | `Driver.FOCAS` | A | Pure-managed `FocasWireClient` — FOCAS/2 Ethernet binary protocol on TCP:8193, inlined into the driver assembly | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource | `IWritable` is implemented but read-only by design — `WriteAsync` returns `BadNotWritable` for every point. CNC-shaped data model (axes, spindle, PMC, macros, alarms) not a flat tag map. Previously Tier-C (Host + P/Invoke + shim DLL); retired in the 2026-04-24 migration when the managed wire client landed |
| [OPC UA Client](OpcUaClient.md) | `Driver.OpcUaClient` | B | OPCFoundation `Opc.Ua.Client` | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IAlarmSource, IHistoryProvider, IHostConnectivityProbe | Gateway/aggregation driver — the only driver implementing driver-side `IHistoryProvider` (forwards HistoryRead to the upstream server). Opens a single `Session` against a remote OPC UA server and re-exposes its address space. Owns its own `ApplicationConfiguration` (distinct from `Client.Shared`) because it's always-on with keep-alive + `TransferSubscriptions` across SDK reconnect, not an interactive CLI |
| [OPC UA Client](OpcUaClient.md) | `Driver.OpcUaClient` | B | OPCFoundation `Opc.Ua.Client` | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IAlarmSource, IHostConnectivityProbe | Gateway/aggregation driver — does not proxy upstream history (no driver has a history capability). Opens a single `Session` against a remote OPC UA server and re-exposes its address space. Owns its own `ApplicationConfiguration` (distinct from `Client.Shared`) because it's always-on with keep-alive + `TransferSubscriptions` across SDK reconnect, not an interactive CLI |
| [MQTT](Mqtt.md) | `Driver.Mqtt` (+ `.Browser`, `.Contracts`) | A | [MQTTnet 5](https://github.com/dotnet/MQTTnet) | IDriver, ITagDiscovery, IReadable, ISubscribable, IHostConnectivityProbe, IRediscoverable | **Push, not poll, and read-only** — the broker delivers PUBLISH frames the driver forwards straight to `ISubscribable.OnDataChange`; `IReadable` serves the last retained/received value from cache, and there is **no `IWritable`** (nothing publishes back). Subscriptions are indexed **by topic filter, not by topic**, so wildcard tags survive a reconnect. A rejected CONNACK throws `MqttConnectRejectedException`: unrecoverable codes → `Faulted` + supervisor stop, transient → retry. **Two modes:** `Plain` binds a tag to a concrete topic + JSON path; `SparkplugB` decodes vendored Eclipse-Tahu protobuf under one `spBv1.0/{groupId}/#` subscription and binds by the `group/edgeNode/device?/metric` tuple (birth/alias/seq-gap state machine, death→STALE, scoped rebirth NCMD). `IRediscoverable` fires on a DBIRTH but is **inert platform-side** — see [#507](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/507) |
| [MTConnect](MTConnect.md) | `Driver.MTConnect` (+ `.Contracts`) | — | Hand-rolled `System.Xml.Linq` HTTP/XML client against a vendor-neutral MTConnect Agent (`/probe`, `/current`, `/sample`) — no TrakHound dependency (dropped; see the driver doc) | IDriver, ITagDiscovery, IReadable, ISubscribable, IHostConnectivityProbe, IRediscoverable | Read-only by design (no `IWritable`). Production data plane is entirely `ISubscribable`/`OnDataChange` — `IReadable` is CLI/test-only. Browse is free via the Wave-0 universal discovery browser, no bespoke browser project. `IRediscoverable`/`IHostConnectivityProbe` have no server-side consumer yet — a pre-existing fleet-wide gap, not MTConnect-specific |
| [Sql](Sql.md) | `Driver.Sql` (+ `.Browser`, `.Contracts`) | A | `Microsoft.Data.SqlClient` behind an `ISqlDialect` seam (`SqlServerDialect` shipped; Postgres/ODBC deferred) | IDriver, ITagDiscovery, IReadable, ISubscribable, IHostConnectivityProbe | Read-only DB-poll driver with a bespoke schema browser. **Credentials are never persisted** — the deploy gate rejects a stored `connectionString` (#498) and catalog identifiers are validated against the live catalog's own spelling (#496). `SupportsOnlineDiscovery => false` by design (the bespoke browser wins). `SqlTagModel.Query` is deferred to P3 and rejected end-to-end by parser, validator, planner and editor. Merged `4ad54037` |
@@ -93,7 +92,7 @@ Three properties worth knowing:
- [AbLegacy.md](AbLegacy.md) — AB Legacy PCCC driver (SLC 500 / MicroLogix / PLC-5): file-based addressing, user-authored tag list
- [S7.md](S7.md) — Siemens S7 driver (S7-300/400/1200/1500 + S7-200): getting started, config, data-block addressing, serialized single-connection model
- [TwinCAT.md](TwinCAT.md) — Beckhoff TwinCAT (ADS) driver: getting started, native-notification subscription, symbol-tree upload
- [OpcUaClient.md](OpcUaClient.md) — OPC UA Client (gateway/aggregation) driver: remote-server session, driver-side HistoryRead forwarding, reconnect behaviour
- [OpcUaClient.md](OpcUaClient.md) — OPC UA Client (gateway/aggregation) driver: remote-server session, reconnect behaviour
- [Mqtt.md](Mqtt.md) — MQTT broker-subscribe driver: broker/TLS/auth config, topic + JSON-path tag binding, retained-value seeding, `#`-observation browser, CONNACK-rejection semantics, **Sparkplug B** (birth/alias/seq/rebirth state machine, birth-driven browse picker, death→STALE), and the **Known gaps** table
- **MTConnect** has a short getting-started doc because the hand-rolled-vs-TrakHound divergence, the
@@ -122,7 +121,7 @@ Each driver has a dedicated fixture doc that lays out what the integration / uni
## Related cross-driver docs
- [HistoricalDataAccess.md](../v1/HistoricalDataAccess.md) — `IHistoryProvider` dispatch, aggregate mapping, continuation points. The OPC UA Client driver is the only driver that implements driver-side `IHistoryProvider` (it forwards HistoryRead to the upstream server); the AVEVA Historian path is served server-side by the HistorianGateway-backed `IHistorianDataSource` instead. Other drivers do not implement the interface and return `BadHistoryOperationUnsupported`.
- [HistoricalDataAccess.md](../v1/HistoricalDataAccess.md) — v1 archive of the retired per-driver history design (aggregate mapping, continuation points). History is now served only by the server-side `IHistorianDataSource` (HistorianGateway); no driver implements a history capability, and nodes without `isHistorized` return `BadHistoryOperationUnsupported`.
- [AlarmTracking.md](../AlarmTracking.md) — `IAlarmSource` event model and filtering. Implemented by Galaxy (native MxAccess alarms, working end-to-end), OPC UA Client, AB CIP, and FOCAS; AB Legacy, Modbus, S7, TwinCAT, and MQTT have no alarm source.
- [Subscriptions.md](../v1/Subscriptions.md) — how the Server multiplexes subscriptions onto `ISubscribable.OnDataChange`.
- [docs/v2/driver-stability.md](../v2/driver-stability.md) — historical design record of the A / B / C tier model. Only `DriverTier` + the `CapabilityPolicy` defaults per tier × capability (`DriverResilienceOptions.GetTierDefaults`) are live; its `MemoryTracking` / recycle / out-of-process isolation machinery was deleted (#522) and every driver runs Tier A.
+1 -1
View File
@@ -155,7 +155,7 @@ TC3; TC2 compatibility is not exercised.
### 5. Alarms / history
Driver doesn't implement `IAlarmSource` or `IHistoryProvider` — not in scope
Driver doesn't implement `IAlarmSource` (history is server-side for every driver) — not in scope
for this driver family. TwinCAT 3's TcEventLogger could theoretically back
an `IAlarmSource`, but shipping that is a separate feature.
+3 -3
View File
@@ -8,7 +8,7 @@ The system shall expose an OPC UA server endpoint that OPC UA clients can connec
## HLR-002: Multi-Driver Plug-In Model
The system shall support pluggable driver modules that bind to specific data sources. v2.0 ships seven drivers: Galaxy (AVEVA System Platform via MXAccess), Modbus TCP (including DL205 via `AddressFormat=DL205`), Allen-Bradley CIP (ControlLogix/CompactLogix), Allen-Bradley Legacy (SLC/MicroLogix via PCCC), Siemens S7, Beckhoff TwinCAT (ADS), FANUC FOCAS, and OPC UA Client (aggregation/gateway). Drivers implement only the capability interfaces (`IDriver`, `ITagDiscovery`, `IReadable`, `IWritable`, `ISubscribable`, `IAlarmSource`, `IHistoryProvider`, `IHostConnectivityProbe`, `IPerCallHostResolver`, `IRediscoverable`) defined in `ZB.MOM.WW.OtOpcUa.Core.Abstractions` that apply to their protocol. Multiple instances of the same driver type are supported; each instance binds to its own OPC UA namespace index.
The system shall support pluggable driver modules that bind to specific data sources. v2.0 ships seven drivers: Galaxy (AVEVA System Platform via MXAccess), Modbus TCP (including DL205 via `AddressFormat=DL205`), Allen-Bradley CIP (ControlLogix/CompactLogix), Allen-Bradley Legacy (SLC/MicroLogix via PCCC), Siemens S7, Beckhoff TwinCAT (ADS), FANUC FOCAS, and OPC UA Client (aggregation/gateway). Drivers implement only the capability interfaces (`IDriver`, `ITagDiscovery`, `IReadable`, `IWritable`, `ISubscribable`, `IAlarmSource`, `IHostConnectivityProbe`, `IPerCallHostResolver`, `IRediscoverable`) defined in `ZB.MOM.WW.OtOpcUa.Core.Abstractions` that apply to their protocol. Multiple instances of the same driver type are supported; each instance binds to its own OPC UA namespace index.
## HLR-003: Address Space Composition per Namespace
@@ -20,7 +20,7 @@ Each driver shall map its native data types to OPC UA built-in types via `Driver
## HLR-005: Live Data Access
For every data-path operation (read, write, subscribe notification, alarm event, history read, tag rediscovery, host connectivity probe), the system shall route the call through the capability interface owned by the target driver instance. Reads and subscriptions shall deliver a `DataValueSnapshot` carrying value, OPC UA `StatusCode`, and source timestamp regardless of the underlying protocol. Every async capability invocation at dispatch shall pass through `Core.Resilience.CapabilityInvoker`.
For every data-path operation (read, write, subscribe notification, alarm event, tag rediscovery, host connectivity probe), the system shall route the call through the capability interface owned by the target driver instance. Reads and subscriptions shall deliver a `DataValueSnapshot` carrying value, OPC UA `StatusCode`, and source timestamp regardless of the underlying protocol. Every async capability invocation at dispatch shall pass through `Core.Resilience.CapabilityInvoker`. History reads are not a driver capability: OPC UA HistoryRead is served by the server-wide `IHistorianDataSource`.
## HLR-006: Change Detection and Rediscovery
@@ -75,7 +75,7 @@ The Admin service shall expose a `/metrics` endpoint using OpenTelemetry → Pro
## HLR-018: Roslyn Analyzer OTOPCUA0001
All direct call sites to capability-interface methods (`IReadable.ReadAsync`, `IWritable.WriteAsync`, `ITagDiscovery.DiscoverAsync`, `ISubscribable.SubscribeAsync`, `IAlarmSource.SubscribeAlarmsAsync` / `AcknowledgeAsync`, `IHistoryProvider.*`, `IHostConnectivityProbe.*`) made outside `Core.Resilience.CapabilityInvoker` shall produce Roslyn diagnostic **OTOPCUA0001** at build time. The analyzer is shipped in `ZB.MOM.WW.OtOpcUa.Analyzers` and referenced by every project that could host a capability call, guaranteeing that resilience cannot be accidentally bypassed.
All direct call sites to capability-interface methods (`IReadable.ReadAsync`, `IWritable.WriteAsync`, `ITagDiscovery.DiscoverAsync`, `ISubscribable.SubscribeAsync`, `IAlarmSource.SubscribeAlarmsAsync` / `AcknowledgeAsync`, `IHostConnectivityProbe.*`) made outside `Core.Resilience.CapabilityInvoker` shall produce Roslyn diagnostic **OTOPCUA0001** at build time. The analyzer is shipped in `ZB.MOM.WW.OtOpcUa.Analyzers` and referenced by every project that could host a capability call, guaranteeing that resilience cannot be accidentally bypassed.
## Retired HLRs
+8 -6
View File
@@ -125,14 +125,16 @@ The server shall expose the OPC UA alarm and condition model backed by each driv
## OPC-009: Historical Access
The server shall surface OPC UA Historical Access (HA) via each driver's `IHistoryProvider` (where implemented).
The server shall surface OPC UA Historical Access (HA) through a single server-wide historian data source (`IHistorianDataSource`, backed by the HistorianGateway). Drivers carry no history capability (Gitea #655).
### Acceptance Criteria
- `HistoryRead` for `Raw`, `Processed`, `AtTime`, and `Events` dispatches through `CapabilityInvoker.InvokeAsync(DriverCapability.HistoryRead, …)`.
- Drivers implementing `IHistoryProvider` today: Galaxy (Wonderware Historian), OPC UA Client (proxy to remote historian).
- Drivers not implementing `IHistoryProvider` return `Bad_HistoryOperationUnsupported` for history requests on their nodes.
- History reads require the `Read` permission bit on the target node.
- `HistoryRead` for `Raw`, `Processed` (the Part 13 aggregates the backend serves), and `AtTime` over historized variable nodes, and for `Events` over alarm-owning equipment-folder event notifiers, is served by the registered `IHistorianDataSource`. It is not dispatched to a driver and does not pass through `CapabilityInvoker`.
- A variable is historized by `isHistorized` in its `TagConfig` (optionally `historianTagname`); such nodes carry the `HistoryRead` access bit and `Historizing = true`. A node without it answers `Bad_HistoryOperationUnsupported`.
- With `ServerHistorian:Enabled = false` (the `NullHistorianDataSource`), historized nodes return `GoodNoData`, never an error.
- A per-node backend failure surfaces as a Bad status for that node only (`Bad_Timeout` past the request deadline, `Bad_AggregateNotSupported` for an aggregate the backend cannot serve); it does not fail the batch.
- When `Security:Acl:Enabled` is true, each node requires the `HistoryRead` permission (`Bad_UserAccessDenied` otherwise). With the flag off, history reads are ungated for any admitted session.
- An OPC UA Client driver does not proxy its upstream server's history; `isHistorized` on its tags records the value into the historian.
---
@@ -250,7 +252,7 @@ The server shall expose standard OPC UA `Server` object nodes required by the sp
### Acceptance Criteria
- `ServerStatus` / `ServerState` / `CurrentTime` / `StartTime` populated and compliant with the OPC UA 1.05 spec.
- `ServerCapabilities` declares historical access capabilities for namespaces that have an `IHistoryProvider`-backed driver.
- `ServerCapabilities` declares historical access capabilities consistent with the server-wide historian data source.
- `ServerRedundancy.RedundancySupport` reflects the cluster's redundancy mode (`None` / `Warm` / `Hot`).
- `ServerRedundancy.ServerUriArray` lists both cluster members' `ApplicationUri` values.
+2 -2
View File
@@ -30,8 +30,8 @@ IReadable, ISubscribable, IHostConnectivityProbe` living in
equipment-tag parser, and a `.Browser` project for the AdminUI address picker. It has **no native
push model** — subscriptions overlay a polling loop via the shared `PollGroupEngine`
(`src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/PollGroupEngine.cs`), exactly as Modbus/S7/AB-CIP do.
`IWritable` and `IAlarmSource`/`IHistoryProvider` are **out of scope for v1** (writes deferred;
alarms/history aren't expressible from a poll query).
`IWritable` and `IAlarmSource` are **out of scope for v1** (writes deferred;
alarms aren't expressible from a poll query; drivers carry no history capability).
Points are ordinary equipment `Tag`s bound to the driver via `TagConfig.FullName`; reads/subscribes
resolve `FullName` to a tag definition through the shared `EquipmentTagRefResolver<TDef>`
+1 -1
View File
@@ -1104,7 +1104,7 @@ for the affected listener ports.
Per-capability resilience (retry, timeout, circuit-breaker) is applied by `CapabilityInvoker` in `src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/`. A driver-capability call made **outside** the invoker bypasses resilience entirely — which in production looks like inconsistent timeouts, un-wrapped retries, and unbounded blocking.
`OTOPCUA0001` (Roslyn analyzer at `src/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers/UnwrappedCapabilityCallAnalyzer.cs`) fires with category `OtOpcUa.Resilience` and default severity **Warning** (per `AnalyzerReleases.Shipped.md`) when a method on one of the seven guarded capability interfaces (`IReadable`, `IWritable`, `ITagDiscovery`, `ISubscribable`, `IHostConnectivityProbe`, `IAlarmSource`, `IHistoryProvider` — all in `ZB.MOM.WW.OtOpcUa.Core.Abstractions`) is invoked **outside** a lambda passed to `CapabilityInvoker.ExecuteAsync` / `ExecuteWriteAsync`. `AlarmSurfaceInvoker` is **not** a wrapper home — its own implementation is covered transitively because it routes through the inner `CapabilityInvoker.ExecuteAsync`. The analyzer walks up the syntax tree from the call site, finds any enclosing invoker invocation, and verifies the call lives transitively inside that invocation's anonymous-function argument — a sibling pattern (do the call, then invoke `ExecuteAsync` on something unrelated nearby) does not satisfy the rule.
`OTOPCUA0001` (Roslyn analyzer at `src/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers/UnwrappedCapabilityCallAnalyzer.cs`) fires with category `OtOpcUa.Resilience` and default severity **Warning** (per `AnalyzerReleases.Shipped.md`) when a method on one of the six guarded capability interfaces (`IReadable`, `IWritable`, `ITagDiscovery`, `ISubscribable`, `IHostConnectivityProbe`, `IAlarmSource` — all in `ZB.MOM.WW.OtOpcUa.Core.Abstractions`) is invoked **outside** a lambda passed to `CapabilityInvoker.ExecuteAsync` / `ExecuteWriteAsync`. `AlarmSurfaceInvoker` is **not** a wrapper home — its own implementation is covered transitively because it routes through the inner `CapabilityInvoker.ExecuteAsync`. The analyzer walks up the syntax tree from the call site, finds any enclosing invoker invocation, and verifies the call lives transitively inside that invocation's anonymous-function argument — a sibling pattern (do the call, then invoke `ExecuteAsync` on something unrelated nearby) does not satisfy the rule.
The xunit.v3 + Shouldly suite at `tests/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers.Tests/UnwrappedCapabilityCallAnalyzerTests.cs` covers the common fail/pass shapes + the sibling-pattern regression guard.
+1 -1
View File
@@ -41,7 +41,7 @@ Galaxy (MXAccess) is a **Tier-A in-process driver** that runs in the OtOpcUa ser
| `ISubscribable` | `Runtime/GatewayGalaxySubscriber.cs` (+ `TracedGalaxySubscriber.cs`), `Runtime/EventPump.cs`, `Runtime/SubscriptionRegistry.cs`, `Runtime/ReconnectSupervisor.cs` |
| `IHostConnectivityProbe` | `Health/HostStatusAggregator.cs`, `Health/HostConnectivityForwarder.cs`, `Health/PerPlatformProbeWatcher.cs` |
History reads + alarm condition tracking now live in the server-layer `IHistoryRouter` and `AlarmConditionService` (PR 7.2). Galaxy no longer carries `IHistoryProvider` or `IAlarmSource` of its own.
History reads + alarm condition tracking are now server-side: history is served by `IHistorianDataSource` (HistorianGateway), alarm condition tracking by `AlarmConditionService` (PR 7.2). Galaxy carries neither a history capability nor `IAlarmSource` of its own.
### DriverConfig JSON shape
@@ -1,6 +1,7 @@
using Opc.Ua;
using Opc.Ua.Client;
using Serilog;
using ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Adapters;
@@ -172,8 +173,9 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
break;
var result = results[0];
if (StatusCode.IsBad(result.StatusCode))
break;
// A refused read (BadHistoryOperationUnsupported, BadUserAccessDenied, ...) must surface; only
// BadNoData is an empty result. Swallowing it printed "0 values returned" for a refusal.
HistoryReadStatus.ThrowIfRefused(result.StatusCode, nodeId);
if (result.HistoryData is ExtensionObject ext && ext.Body is HistoryData historyData)
allValues.AddRange(historyData.DataValues);
@@ -218,8 +220,8 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
if (results != null && results.Count > 0)
{
var result = results[0];
if (!StatusCode.IsBad(result.StatusCode) &&
result.HistoryData is ExtensionObject ext &&
HistoryReadStatus.ThrowIfRefused(result.StatusCode, nodeId);
if (result.HistoryData is ExtensionObject ext &&
ext.Body is HistoryData historyData)
allValues.AddRange(historyData.DataValues);
}
@@ -0,0 +1,24 @@
using Opc.Ua;
namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
/// <summary>
/// Classifies the per-node <c>HistoryReadResult.StatusCode</c> a server returns for a HistoryRead.
/// A Bad per-node status (<c>BadHistoryOperationUnsupported</c>, <c>BadUserAccessDenied</c>,
/// <c>BadNodeIdUnknown</c>, ...) is a refused read, not an empty one, so it must reach the caller
/// instead of collapsing to "0 values returned". <c>BadNoData</c> is the one Bad code that genuinely
/// means "the window held nothing" and stays an empty result.
/// </summary>
internal static class HistoryReadStatus
{
/// <summary>Throws a <see cref="ServiceResultException"/> naming the node and status when the read was refused.</summary>
/// <param name="status">The per-node result status.</param>
/// <param name="nodeId">The node that was read, for the message.</param>
/// <exception cref="ServiceResultException"><paramref name="status"/> is Bad and not <c>BadNoData</c>.</exception>
public static void ThrowIfRefused(StatusCode status, NodeId nodeId)
{
if (!StatusCode.IsBad(status) || status.Code == StatusCodes.BadNoData) return;
throw new ServiceResultException(status.Code,
$"HistoryRead of {nodeId} failed: {StatusCodes.GetBrowseName(status.Code)} (0x{status.Code:X8}).");
}
}
@@ -5,10 +5,10 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// Each value corresponds to one method (or tightly-related method group) on the
/// <c>Core.Abstractions</c> capability interfaces (<see cref="IReadable"/>, <see cref="IWritable"/>,
/// <see cref="ITagDiscovery"/>, <see cref="ISubscribable"/>, <see cref="IHostConnectivityProbe"/>,
/// <see cref="IAlarmSource"/>, <see cref="IHistoryProvider"/>).
/// <see cref="IAlarmSource"/>).
/// </summary>
/// <remarks>
/// Per the per-capability retry policy in <c>docs/v2/plan.md</c>: Read / HistoryRead /
/// Per the per-capability retry policy in <c>docs/v2/plan.md</c>: Read /
/// Discover / Probe / AlarmSubscribe auto-retry; <see cref="Write"/> does NOT retry unless the
/// tag-definition carries <see cref="WriteIdempotentAttribute"/>. Alarm-acknowledge is treated
/// as a write for retry semantics (an alarm-ack is not idempotent at the plant-floor acknowledgement
@@ -37,9 +37,6 @@ public enum DriverCapability
/// <summary><see cref="IAlarmSource.AcknowledgeAsync"/>. Does NOT retry — ack is a write-shaped operation.</summary>
AlarmAcknowledge,
/// <summary><see cref="IHistoryProvider"/> reads (Raw/Processed/AtTime/Events). Retries by default.</summary>
HistoryRead,
/// <summary>
/// <c>IDriver.InitializeAsync</c> — connection establishment (R2-10 T10 closure: without this,
/// a driver that cannot connect contributes zero breaker input and the resilience panel stays
@@ -0,0 +1,92 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
// Shared history result contracts used by IHistorianDataSource and the historian gateway.
// Drivers carry no history capability (Gitea #655): HistoryRead is served server-side.
/// <summary>Result of a HistoryRead call.</summary>
/// <param name="Samples">Returned samples in chronological order.</param>
/// <param name="ContinuationPoint">Opaque token for the next call when more samples are available; null when complete.</param>
public sealed record HistoryReadResult(
IReadOnlyList<DataValueSnapshot> Samples,
byte[]? ContinuationPoint);
/// <summary>Aggregate function for processed history reads. Mirrors OPC UA Part 13 standard aggregates.</summary>
/// <remarks>
/// The first five members are the original set every historian backend serves. The remainder (#285) are
/// the further standard Part 13 aggregates a server-side HistoryRead can forward to a backend that
/// supports them (a gateway backend maps them to its own aggregate modes). A backend that cannot serve a
/// member must throw <see cref="HistoryAggregateNotSupportedException"/> — the server maps that to
/// <c>BadAggregateNotSupported</c> rather than a generic failure. <b>Append only</b>: the numeric values
/// are not persisted, but every <c>switch</c> over this enum must keep a total default.
/// </remarks>
public enum HistoryAggregateType
{
Average,
Minimum,
Maximum,
Total,
Count,
Interpolative,
TimeAverage,
Start,
End,
Delta,
Range,
MinimumActualTime,
MaximumActualTime,
StartBound,
EndBound,
DeltaBounds,
DurationGood,
DurationBad,
PercentGood,
PercentBad,
WorstQuality,
NumberOfTransitions,
AnnotationCount,
StandardDeviationPopulation,
VariancePopulation,
}
/// <summary>
/// Thrown by a history backend when it cannot serve the requested <see cref="HistoryAggregateType"/>.
/// The Server HistoryRead path translates it to <c>BadAggregateNotSupported</c> for that node only.
/// </summary>
public sealed class HistoryAggregateNotSupportedException : NotSupportedException
{
/// <summary>The aggregate the backend could not serve.</summary>
public HistoryAggregateType Aggregate { get; }
/// <summary>Creates the exception for <paramref name="aggregate"/>.</summary>
/// <param name="aggregate">The unsupported aggregate.</param>
public HistoryAggregateNotSupportedException(HistoryAggregateType aggregate)
: base($"The historian backend does not support the {aggregate} aggregate.")
=> Aggregate = aggregate;
}
/// <summary>
/// One row returned by <see cref="IHistorianDataSource.ReadEventsAsync"/> — a historical
/// alarm/event record, not the OPC UA live-event stream. Fields match the minimum set the
/// Server needs to populate a <c>HistoryEventFieldList</c> for HistoryReadEvents responses.
/// </summary>
/// <param name="EventId">Stable unique id for the event — driver-specific format.</param>
/// <param name="SourceName">Source object that emitted the event. May differ from the <c>sourceName</c> filter the caller passed (fuzzy matches).</param>
/// <param name="EventTimeUtc">Process-side timestamp — when the event actually occurred.</param>
/// <param name="ReceivedTimeUtc">Historian-side timestamp — when the historian persisted the row; may lag <paramref name="EventTimeUtc"/> by the historian's buffer flush cadence.</param>
/// <param name="Message">Human-readable message text.</param>
/// <param name="Severity">OPC UA severity (1-1000). Backends map their native priority scale onto this range.</param>
public sealed record HistoricalEvent(
string EventId,
string? SourceName,
DateTime EventTimeUtc,
DateTime ReceivedTimeUtc,
string? Message,
ushort Severity);
/// <summary>Result of a <see cref="IHistorianDataSource.ReadEventsAsync"/> call.</summary>
/// <param name="Events">Events in chronological order by <c>EventTimeUtc</c>.</param>
/// <param name="ContinuationPoint">Opaque token for the next call when more events are available; null when complete.</param>
public sealed record HistoricalEventsResult(
IReadOnlyList<HistoricalEvent> Events,
byte[]? ContinuationPoint);
@@ -5,15 +5,9 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// and resolved per OPC UA namespace, independent of any driver's lifecycle.
/// </summary>
/// <remarks>
/// Distinct from <see cref="IHistoryProvider"/>:
/// <list type="bullet">
/// <item><see cref="IHistoryProvider"/> is a *driver capability* — the server
/// dispatches to it via the driver instance.</item>
/// <item><see cref="IHistorianDataSource"/> is a *server registration* — the
/// server resolves it via namespace and calls it directly, so a single
/// historian (e.g. Wonderware) can serve many drivers' nodes, and drivers can
/// restart without dropping history availability.</item>
/// </list>
/// This is a *server registration*, not a driver capability: drivers carry no history
/// surface (Gitea #655). The server calls it directly, so a single historian can serve
/// many drivers' nodes, and drivers can restart without dropping history availability.
/// All values returned use the shared <see cref="DataValueSnapshot"/> /
/// <see cref="HistoricalEvent"/> shapes; backend-specific quality / type encodings
/// are translated to OPC UA <c>StatusCode</c> uints inside the data source.
@@ -86,9 +80,7 @@ public interface IHistorianDataSource : IDisposable
/// equivalent "use default" sentinel.
///
/// This surface declares <see cref="ReadAtTimeAsync"/> and <see cref="ReadEventsAsync"/>
/// as required members — a server-side historian owns the full read surface, unlike
/// <see cref="IHistoryProvider"/> where the same two methods are optional default-impl
/// methods so legacy drivers can stay raw-only. The asymmetry is intentional.
/// as required members — a server-side historian owns the full read surface.
/// </remarks>
/// <param name="sourceName">The source name to filter events, or null to return events from all sources.</param>
/// <param name="startUtc">The start of the time range in UTC.</param>
@@ -4,7 +4,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// Required capability for every driver instance. Owns lifecycle, metadata, health.
/// Other capabilities (<see cref="ITagDiscovery"/>, <see cref="IReadable"/>,
/// <see cref="IWritable"/>, <see cref="ISubscribable"/>, <see cref="IAlarmSource"/>,
/// <see cref="IHistoryProvider"/>, <see cref="IRediscoverable"/>,
/// <see cref="IRediscoverable"/>,
/// <see cref="IHostConnectivityProbe"/>) are composable — a driver implements only what its
/// backend actually supports.
/// </summary>
@@ -1,237 +0,0 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// Driver capability for historical-data reads (OPC UA HistoryRead). Optional —
/// only drivers whose backends carry historian data implement this. Currently:
/// Galaxy (Wonderware Historian via the optional plugin), OPC UA Client (forward
/// to upstream server).
/// </summary>
/// <remarks>
/// <see cref="ReadAtTimeAsync"/> and <see cref="ReadEventsAsync"/> are C# default interface
/// methods that throw <see cref="NotSupportedException"/> — drivers opt in by overriding so
/// a raw-only driver compiles without forcing it to provide at-time / event surfaces it
/// has no backend for. The sibling server-side surface, <see cref="IHistorianDataSource"/>,
/// declares both methods as required because a registered historian owns the full read
/// surface; the asymmetry is intentional.
/// </remarks>
public interface IHistoryProvider
{
/// <summary>
/// Read raw historical samples for a single attribute over a time range.
/// The Core wraps this with continuation-point handling.
/// </summary>
/// <param name="fullReference">The full reference path to the attribute.</param>
/// <param name="startUtc">Inclusive lower bound on the time range (UTC).</param>
/// <param name="endUtc">Exclusive upper bound on the time range (UTC).</param>
/// <param name="maxValuesPerNode">Maximum number of values to return per node.</param>
/// <param name="cancellationToken">A cancellation token to stop the operation.</param>
/// <returns>A task that returns the history read result containing samples and optional continuation point.</returns>
Task<HistoryReadResult> ReadRawAsync(
string fullReference,
DateTime startUtc,
DateTime endUtc,
uint maxValuesPerNode,
CancellationToken cancellationToken);
/// <summary>
/// Read processed (aggregated) samples — interval-bucketed average / min / max / etc.
/// Optional — drivers that only support raw history can throw <see cref="NotSupportedException"/>.
/// </summary>
/// <param name="fullReference">The full reference path to the attribute.</param>
/// <param name="startUtc">Inclusive lower bound on the time range (UTC).</param>
/// <param name="endUtc">Exclusive upper bound on the time range (UTC).</param>
/// <param name="interval">The time interval for bucketing samples.</param>
/// <param name="aggregate">The aggregate function to apply to each bucket.</param>
/// <param name="cancellationToken">A cancellation token to stop the operation.</param>
/// <returns>A task that returns the history read result containing aggregated samples and optional continuation point.</returns>
Task<HistoryReadResult> ReadProcessedAsync(
string fullReference,
DateTime startUtc,
DateTime endUtc,
TimeSpan interval,
HistoryAggregateType aggregate,
CancellationToken cancellationToken);
/// <summary>
/// Read one sample per requested timestamp — OPC UA HistoryReadAtTime service. The
/// driver interpolates (or returns the prior-boundary sample) when no exact match
/// exists. Optional; drivers that can't interpolate throw <see cref="NotSupportedException"/>.
/// </summary>
/// <remarks>
/// Default implementation throws. Drivers opt in by overriding; keeps existing
/// <c>IHistoryProvider</c> implementations compiling without forcing a ReadAtTime path
/// they may not have a backend for.
/// </remarks>
/// <param name="fullReference">The full reference path to the attribute.</param>
/// <param name="timestampsUtc">The list of timestamps at which to read values.</param>
/// <param name="cancellationToken">A cancellation token to stop the operation.</param>
/// <returns>A task that returns the history read result containing samples at the requested times.</returns>
Task<HistoryReadResult> ReadAtTimeAsync(
string fullReference,
IReadOnlyList<DateTime> timestampsUtc,
CancellationToken cancellationToken)
=> throw new NotSupportedException(
$"{GetType().Name} does not implement ReadAtTimeAsync. " +
"Drivers whose backends support at-time reads override this method.");
/// <summary>
/// Read historical alarm/event records — OPC UA HistoryReadEvents service. Distinct
/// from the live event stream — historical rows come from an event historian (Galaxy's
/// Alarm Provider history log, etc.) rather than the driver's active subscription.
/// </summary>
/// <param name="sourceName">
/// Optional filter: null means "all sources", otherwise restrict to events from that
/// source-object name. Drivers may ignore the filter if the backend doesn't support it.
/// </param>
/// <param name="startUtc">Inclusive lower bound on <c>EventTimeUtc</c>.</param>
/// <param name="endUtc">Exclusive upper bound on <c>EventTimeUtc</c>.</param>
/// <param name="maxEvents">
/// Upper cap on returned events — the driver's backend enforces this. The type is
/// <see cref="int"/> rather than <see cref="uint"/> (which the sibling raw / processed
/// reads use for <c>maxValuesPerNode</c>) because callers and downstream historian
/// adapters historically treat <c>maxEvents &lt;= 0</c> as a sentinel meaning
/// "use the backend's default cap" (see <c>WonderwareHistorianClient</c> /
/// <c>HistorianDataSource</c>). The asymmetry is intentional.
///
/// <b>Continuation contract when using the sentinel:</b> When <c>maxEvents &lt;= 0</c>
/// the backend applies its own cap. If the backend's cap truncates the result
/// (i.e. more matching events exist beyond the returned set), implementations MUST set
/// <see cref="HistoricalEventsResult.ContinuationPoint"/> to a non-null token so
/// callers can detect truncation and page. A null <c>ContinuationPoint</c> means all
/// matching events were returned — callers rely on this to decide whether to page.
/// </param>
/// <param name="cancellationToken">Request cancellation.</param>
/// <returns>A task that returns the historical events result containing matching event records and optional continuation point.</returns>
/// <remarks>
/// Default implementation throws. Drivers whose backend can serve historical events
/// override: Galaxy (Wonderware Alarm &amp; Events log) and the OPC UA Client driver
/// (forwarding HistoryReadEvents to its upstream server). Raw-only drivers such as Modbus
/// stay with the default and let callers see <c>BadHistoryOperationUnsupported</c>.
///
/// Note the type asymmetry with <see cref="ReadRawAsync"/> /
/// <see cref="ReadProcessedAsync"/> (both use <c>uint maxValuesPerNode</c>): event
/// readers accept a signed <c>int maxEvents</c> so callers can pass 0 / negative as a
/// "use default cap" sentinel without an extra parameter or overload.
/// </remarks>
Task<HistoricalEventsResult> ReadEventsAsync(
string? sourceName,
DateTime startUtc,
DateTime endUtc,
int maxEvents,
CancellationToken cancellationToken)
=> throw new NotSupportedException(
$"{GetType().Name} does not implement ReadEventsAsync. " +
"Drivers whose backends have an event historian override this method.");
}
/// <summary>Result of a HistoryRead call.</summary>
/// <param name="Samples">Returned samples in chronological order.</param>
/// <param name="ContinuationPoint">Opaque token for the next call when more samples are available; null when complete.</param>
public sealed record HistoryReadResult(
IReadOnlyList<DataValueSnapshot> Samples,
byte[]? ContinuationPoint);
/// <summary>Aggregate function for processed history reads. Mirrors OPC UA Part 13 standard aggregates.</summary>
/// <remarks>
/// The first five members are the original set every historian backend serves. The remainder (#285) are
/// the further standard Part 13 aggregates a server-side HistoryRead can forward to a backend that
/// supports them (the OPC UA Client driver forwards all of them upstream). A backend that cannot serve a
/// member must throw <see cref="HistoryAggregateNotSupportedException"/> — the server maps that to
/// <c>BadAggregateNotSupported</c> rather than a generic failure. <b>Append only</b>: the numeric values
/// are not persisted, but every <c>switch</c> over this enum must keep a total default.
/// </remarks>
public enum HistoryAggregateType
{
Average,
Minimum,
Maximum,
Total,
Count,
Interpolative,
TimeAverage,
Start,
End,
Delta,
Range,
MinimumActualTime,
MaximumActualTime,
StartBound,
EndBound,
DeltaBounds,
DurationGood,
DurationBad,
PercentGood,
PercentBad,
WorstQuality,
NumberOfTransitions,
AnnotationCount,
StandardDeviationPopulation,
VariancePopulation,
}
/// <summary>
/// Thrown by a history backend when it cannot serve the requested <see cref="HistoryAggregateType"/>.
/// The Server HistoryRead path translates it to <c>BadAggregateNotSupported</c> for that node only.
/// </summary>
public sealed class HistoryAggregateNotSupportedException : NotSupportedException
{
/// <summary>The aggregate the backend could not serve.</summary>
public HistoryAggregateType Aggregate { get; }
/// <summary>Creates the exception for <paramref name="aggregate"/>.</summary>
/// <param name="aggregate">The unsupported aggregate.</param>
public HistoryAggregateNotSupportedException(HistoryAggregateType aggregate)
: base($"The historian backend does not support the {aggregate} aggregate.")
=> Aggregate = aggregate;
}
/// <summary>
/// Thrown by a history backend that is a passthrough to another OPC UA server when that server answered
/// a node with a Bad per-node <c>HistoryReadResult.StatusCode</c> other than
/// <c>BadAggregateNotSupported</c> (which has its own <see cref="HistoryAggregateNotSupportedException"/>).
/// The Server HistoryRead path surfaces <see cref="StatusCode"/> verbatim as that node's result status
/// instead of the generic <c>BadHistoryOperationUnsupported</c>, so a refused read is never mistaken for
/// an empty Good one.
/// </summary>
public sealed class HistoryReadStatusException : Exception
{
/// <summary>The raw OPC UA status code the upstream server returned for the node.</summary>
public uint StatusCode { get; }
/// <summary>Creates the exception for the upstream <paramref name="statusCode"/>.</summary>
/// <param name="statusCode">The Bad OPC UA status code to surface for the node.</param>
/// <exception cref="ArgumentException"><paramref name="statusCode"/> is not a Bad code (severity bits <c>10</c>/<c>11</c>).</exception>
public HistoryReadStatusException(uint statusCode)
: base(IsBad(statusCode)
? $"The upstream server refused the history read with status 0x{statusCode:X8}."
: throw new ArgumentException($"0x{statusCode:X8} is not a Bad status code.", nameof(statusCode)))
=> StatusCode = statusCode;
private static bool IsBad(uint statusCode) => (statusCode & 0x80000000u) != 0;
}
/// <summary>
/// One row returned by <see cref="IHistoryProvider.ReadEventsAsync"/> — a historical
/// alarm/event record, not the OPC UA live-event stream. Fields match the minimum set the
/// Server needs to populate a <c>HistoryEventFieldList</c> for HistoryReadEvents responses.
/// </summary>
/// <param name="EventId">Stable unique id for the event — driver-specific format.</param>
/// <param name="SourceName">Source object that emitted the event. May differ from the <c>sourceName</c> filter the caller passed (fuzzy matches).</param>
/// <param name="EventTimeUtc">Process-side timestamp — when the event actually occurred.</param>
/// <param name="ReceivedTimeUtc">Historian-side timestamp — when the historian persisted the row; may lag <paramref name="EventTimeUtc"/> by the historian's buffer flush cadence.</param>
/// <param name="Message">Human-readable message text.</param>
/// <param name="Severity">OPC UA severity (1-1000). Drivers map their native priority scale onto this range.</param>
public sealed record HistoricalEvent(
string EventId,
string? SourceName,
DateTime EventTimeUtc,
DateTime ReceivedTimeUtc,
string? Message,
ushort Severity);
/// <summary>Result of a <see cref="IHistoryProvider.ReadEventsAsync"/> call.</summary>
/// <param name="Events">Events in chronological order by <c>EventTimeUtc</c>.</param>
/// <param name="ContinuationPoint">Opaque token for the next call when more events are available; null when complete.</param>
public sealed record HistoricalEventsResult(
IReadOnlyList<HistoricalEvent> Events,
byte[]? ContinuationPoint);
@@ -81,7 +81,6 @@ public sealed record DriverResilienceOptions
[DriverCapability.Probe] = new(TimeoutSeconds: 2, RetryCount: 3, BreakerFailureThreshold: 5),
[DriverCapability.AlarmSubscribe] = new(TimeoutSeconds: 5, RetryCount: 3, BreakerFailureThreshold: 5),
[DriverCapability.AlarmAcknowledge] = new(TimeoutSeconds: 5, RetryCount: 0, BreakerFailureThreshold: 5),
[DriverCapability.HistoryRead] = new(TimeoutSeconds: 30, RetryCount: 2, BreakerFailureThreshold: 5),
// Initialize: no retry (the DriverInstanceActor reconnect timer is the retry loop) and a
// threshold of 2, NOT the Read-sized 5 — the breaker samples over 30 s and the reconnect
// timer fires every 10 s, so at most three init attempts fit one window; a threshold of 5
@@ -97,7 +96,6 @@ public sealed record DriverResilienceOptions
[DriverCapability.Probe] = new(TimeoutSeconds: 4, RetryCount: 3, BreakerFailureThreshold: 5),
[DriverCapability.AlarmSubscribe] = new(TimeoutSeconds: 8, RetryCount: 3, BreakerFailureThreshold: 5),
[DriverCapability.AlarmAcknowledge] = new(TimeoutSeconds: 8, RetryCount: 0, BreakerFailureThreshold: 5),
[DriverCapability.HistoryRead] = new(TimeoutSeconds: 60, RetryCount: 2, BreakerFailureThreshold: 5),
// See the Tier A Initialize note: threshold 2 is cadence-reachability, not a tuning choice.
[DriverCapability.Initialize] = new(TimeoutSeconds: 8, RetryCount: 0, BreakerFailureThreshold: 2),
},
@@ -110,7 +108,6 @@ public sealed record DriverResilienceOptions
[DriverCapability.Probe] = new(TimeoutSeconds: 10, RetryCount: 1, BreakerFailureThreshold: 0),
[DriverCapability.AlarmSubscribe] = new(TimeoutSeconds: 15, RetryCount: 1, BreakerFailureThreshold: 0),
[DriverCapability.AlarmAcknowledge] = new(TimeoutSeconds: 15, RetryCount: 0, BreakerFailureThreshold: 0),
[DriverCapability.HistoryRead] = new(TimeoutSeconds: 120, RetryCount: 1, BreakerFailureThreshold: 0),
// Tier C stays breaker-less like every other Tier C capability (process-level supervision).
[DriverCapability.Initialize] = new(TimeoutSeconds: 15, RetryCount: 0, BreakerFailureThreshold: 0),
},
@@ -29,7 +29,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
/// monitored-item handles. That mechanic lands in PR 69.
/// </para>
/// </remarks>
public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IAlarmSource, IHistoryProvider, ISkippedTagReporter, IDisposable, IAsyncDisposable
public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IAlarmSource, ISkippedTagReporter, IDisposable, IAsyncDisposable
{
/// <summary>Authored tags whose TagConfig carried no usable <c>nodeId</c>, so there is no upstream node
/// to dial (Gitea #541). This driver's skip path was the quietest of the twelve: <c>NamespaceMap</c>
@@ -2258,335 +2258,6 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
public string DiagnosticId => $"opcua-alarm-sub-{Id}";
}
// ---- IHistoryProvider (passthrough to upstream server) ----
/// <inheritdoc />
public async Task<Core.Abstractions.HistoryReadResult> ReadRawAsync(
string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode,
CancellationToken cancellationToken)
{
var details = new ReadRawModifiedDetails
{
IsReadModified = false,
StartTime = startUtc,
EndTime = endUtc,
NumValuesPerNode = maxValuesPerNode,
ReturnBounds = false,
};
return await ExecuteHistoryReadAsync(fullReference, new ExtensionObject(details), cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<Core.Abstractions.HistoryReadResult> ReadProcessedAsync(
string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval,
HistoryAggregateType aggregate, CancellationToken cancellationToken)
{
var aggregateId = MapAggregateToNodeId(aggregate);
var details = new ReadProcessedDetails
{
StartTime = startUtc,
EndTime = endUtc,
ProcessingInterval = interval.TotalMilliseconds,
AggregateType = [aggregateId],
};
return await ExecuteHistoryReadAsync(fullReference, new ExtensionObject(details), cancellationToken, aggregate)
.ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<Core.Abstractions.HistoryReadResult> ReadAtTimeAsync(
string fullReference, IReadOnlyList<DateTime> timestampsUtc, CancellationToken cancellationToken)
{
var reqTimes = new DateTimeCollection(timestampsUtc);
var details = new ReadAtTimeDetails
{
ReqTimes = reqTimes,
UseSimpleBounds = true,
};
return await ExecuteHistoryReadAsync(fullReference, new ExtensionObject(details), cancellationToken)
.ConfigureAwait(false);
}
/// <summary>
/// Shared HistoryRead wire path — used by Raw/Processed/AtTime. Handles NodeId parse,
/// Session.HistoryReadAsync call, per-sample StatusCode passthrough (no translation per §8
/// cascading-quality rule), and HistoryData unwrap into <see cref="DataValueSnapshot"/>. A Bad
/// per-NODE <c>HistoryReadResult.StatusCode</c> is never read as empty data: <c>BadAggregateNotSupported</c>
/// throws <see cref="HistoryAggregateNotSupportedException"/>, any other Bad throws
/// <see cref="HistoryReadStatusException"/> (Gitea #649). Good / GoodNoData / GoodMoreData / Uncertain are
/// unchanged.
/// </summary>
private async Task<Core.Abstractions.HistoryReadResult> ExecuteHistoryReadAsync(
string fullReference, ExtensionObject historyReadDetails, CancellationToken ct,
HistoryAggregateType? aggregate = null)
{
// Make sure a session exists before queuing on the gate, but do NOT bind the wire call
// (or the namespace-relative NodeId parse) to this reference — a reconnect can swap
// Session while we wait on _gate. The session actually used is re-read inside the gate.
_ = RequireSession();
await _gate.WaitAsync(ct).ConfigureAwait(false);
try
{
// Re-read Session inside the critical section: if a reconnect completed while we
// were blocked on _gate, OnReconnectComplete has already swapped in the new session.
// NodeId parsing is namespace-relative, so it must also use the current session's
// namespace table.
var session = Session;
if (session is null || !TryResolveTagNode(session, fullReference, out var nodeId))
{
return new Core.Abstractions.HistoryReadResult([], null);
}
var nodesToRead = new HistoryReadValueIdCollection
{
new HistoryReadValueId { NodeId = nodeId },
};
var resp = await session.HistoryReadAsync(
requestHeader: null,
historyReadDetails: historyReadDetails,
timestampsToReturn: TimestampsToReturn.Both,
releaseContinuationPoints: false,
nodesToRead: nodesToRead,
ct: ct).ConfigureAwait(false);
if (resp.Results.Count == 0) return new Core.Abstractions.HistoryReadResult([], null);
var r = resp.Results[0];
// The per-node status decides whether the payload means anything. BadNoData is the one Bad code
// that legitimately means "nothing in the window", so it stays an empty result.
// CodeBits (not Code) so an info/overflow sub-status bit on the upstream code does not dodge the match.
if (StatusCode.IsBad(r.StatusCode) && r.StatusCode.CodeBits != StatusCodes.BadNoData)
{
if (r.StatusCode.CodeBits == StatusCodes.BadAggregateNotSupported && aggregate is { } agg)
throw new HistoryAggregateNotSupportedException(agg);
throw new HistoryReadStatusException(r.StatusCode.Code);
}
// Unwrap HistoryData from the ExtensionObject-encoded payload the SDK returns.
// Samples stay in chronological order per OPC UA Part 11; cascading-quality
// rule: preserve each DataValue's upstream StatusCode + timestamps verbatim.
var samples = new List<DataValueSnapshot>();
if (r.HistoryData?.Body is HistoryData hd)
{
var now = DateTime.UtcNow;
foreach (var dv in hd.DataValues)
{
samples.Add(new DataValueSnapshot(
Value: dv.Value,
StatusCode: dv.StatusCode.Code,
SourceTimestampUtc: dv.SourceTimestamp == DateTime.MinValue ? null : dv.SourceTimestamp,
ServerTimestampUtc: dv.ServerTimestamp == DateTime.MinValue ? now : dv.ServerTimestamp));
}
}
var contPt = r.ContinuationPoint is { Length: > 0 } ? r.ContinuationPoint : null;
return new Core.Abstractions.HistoryReadResult(samples, contPt);
}
finally { _gate.Release(); }
}
/// <summary>Map <see cref="HistoryAggregateType"/> to the OPC UA Part 13 standard aggregate NodeId.</summary>
/// <param name="aggregate">The aggregation function type to map.</param>
/// <returns>The standard aggregate function NodeId.</returns>
internal static NodeId MapAggregateToNodeId(HistoryAggregateType aggregate) => aggregate switch
{
HistoryAggregateType.Average => ObjectIds.AggregateFunction_Average,
HistoryAggregateType.Minimum => ObjectIds.AggregateFunction_Minimum,
HistoryAggregateType.Maximum => ObjectIds.AggregateFunction_Maximum,
HistoryAggregateType.Total => ObjectIds.AggregateFunction_Total,
HistoryAggregateType.Count => ObjectIds.AggregateFunction_Count,
HistoryAggregateType.Interpolative => ObjectIds.AggregateFunction_Interpolative,
HistoryAggregateType.TimeAverage => ObjectIds.AggregateFunction_TimeAverage,
HistoryAggregateType.Start => ObjectIds.AggregateFunction_Start,
HistoryAggregateType.End => ObjectIds.AggregateFunction_End,
HistoryAggregateType.Delta => ObjectIds.AggregateFunction_Delta,
HistoryAggregateType.Range => ObjectIds.AggregateFunction_Range,
HistoryAggregateType.MinimumActualTime => ObjectIds.AggregateFunction_MinimumActualTime,
HistoryAggregateType.MaximumActualTime => ObjectIds.AggregateFunction_MaximumActualTime,
HistoryAggregateType.StartBound => ObjectIds.AggregateFunction_StartBound,
HistoryAggregateType.EndBound => ObjectIds.AggregateFunction_EndBound,
HistoryAggregateType.DeltaBounds => ObjectIds.AggregateFunction_DeltaBounds,
HistoryAggregateType.DurationGood => ObjectIds.AggregateFunction_DurationGood,
HistoryAggregateType.DurationBad => ObjectIds.AggregateFunction_DurationBad,
HistoryAggregateType.PercentGood => ObjectIds.AggregateFunction_PercentGood,
HistoryAggregateType.PercentBad => ObjectIds.AggregateFunction_PercentBad,
HistoryAggregateType.WorstQuality => ObjectIds.AggregateFunction_WorstQuality,
HistoryAggregateType.NumberOfTransitions => ObjectIds.AggregateFunction_NumberOfTransitions,
HistoryAggregateType.AnnotationCount => ObjectIds.AggregateFunction_AnnotationCount,
HistoryAggregateType.StandardDeviationPopulation => ObjectIds.AggregateFunction_StandardDeviationPopulation,
HistoryAggregateType.VariancePopulation => ObjectIds.AggregateFunction_VariancePopulation,
_ => throw new ArgumentOutOfRangeException(nameof(aggregate), aggregate, null),
};
// Canonical BaseEventType select-clause order — MapHistoryEvents maps by these indices.
private static readonly string[] EventFieldBrowseNames =
[
BrowseNames.EventId, // 0
BrowseNames.SourceName, // 1
BrowseNames.Time, // 2
BrowseNames.ReceiveTime, // 3
BrowseNames.Message, // 4
BrowseNames.Severity, // 5
];
/// <summary>
/// Builds the fixed canonical EventFilter the driver sends upstream for HistoryReadEvents —
/// the six BaseEventType fields the OtOpcUa server projects (<see cref="HistoricalEvent"/>).
/// The clause order is load-bearing: <see cref="MapHistoryEvents"/> reads results by index.
/// </summary>
/// <returns>An EventFilter with six SimpleAttributeOperand value clauses.</returns>
internal static EventFilter BuildBaseEventFilter()
{
var filter = new EventFilter();
foreach (var browseName in EventFieldBrowseNames)
{
filter.SelectClauses.Add(new SimpleAttributeOperand
{
TypeDefinitionId = ObjectTypeIds.BaseEventType,
BrowsePath = new QualifiedNameCollection { new QualifiedName(browseName) },
AttributeId = Attributes.Value,
});
}
return filter;
}
/// <summary>
/// Maps an upstream <see cref="HistoryEvent"/> (field arrays ordered to match
/// <see cref="BuildBaseEventFilter"/>) onto <see cref="HistoricalEvent"/> records. Defensive:
/// short / null / wrong-typed fields degrade to null/default rather than throwing.
/// </summary>
/// <param name="historyEvent">The upstream history-event payload.</param>
/// <returns>The mapped historical events in upstream order.</returns>
internal static IReadOnlyList<Core.Abstractions.HistoricalEvent> MapHistoryEvents(HistoryEvent historyEvent)
{
if (historyEvent?.Events is not { Count: > 0 } rows) return [];
var result = new List<Core.Abstractions.HistoricalEvent>(rows.Count);
foreach (var row in rows)
{
var fields = row?.EventFields;
result.Add(new Core.Abstractions.HistoricalEvent(
EventId: CoerceEventId(FieldAt(fields, 0)),
SourceName: CoerceString(FieldAt(fields, 1)),
EventTimeUtc: CoerceDateTime(FieldAt(fields, 2)),
ReceivedTimeUtc: CoerceDateTime(FieldAt(fields, 3)),
Message: CoerceString(FieldAt(fields, 4)),
Severity: CoerceSeverity(FieldAt(fields, 5))));
}
return result;
}
private static object? FieldAt(VariantCollection? fields, int index)
=> fields is not null && index < fields.Count ? fields[index].Value : null;
private static string CoerceEventId(object? value) => value switch
{
byte[] bytes => Convert.ToBase64String(bytes),
string s => s,
null => string.Empty,
_ => value.ToString() ?? string.Empty,
};
private static string? CoerceString(object? value) => value switch
{
LocalizedText lt => lt.Text,
string s => s,
null => null,
_ => value.ToString(),
};
// Missing-field sentinel is UTC-kinded so a downstream .ToUniversalTime() can't shift it.
private static readonly DateTime MissingTimeSentinel =
DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc);
private static DateTime CoerceDateTime(object? value)
=> value is DateTime dt ? dt : MissingTimeSentinel;
private static ushort CoerceSeverity(object? value)
{
try { return value is null ? (ushort)0 : Convert.ToUInt16(value); }
catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException) { return 0; }
}
/// <inheritdoc />
// Forwards OPC UA HistoryReadEvents to the upstream server. Sends the fixed canonical
// BuildBaseEventFilter and maps the result onto HistoricalEvent (the OtOpcUa server projects
// only those six BaseEventType fields, so a richer client filter would be discarded
// server-side — the driver supplies the canonical set itself). sourceName is the upstream
// event-notifier NodeId to read from (mirrors how fullReference is the upstream NodeId for
// raw reads); null/empty resolves to the upstream Server object (i=2253), the standard
// server-wide event notifier, and an unparseable id short-circuits to an empty result,
// matching the raw path's malformed-NodeId behavior.
public async Task<Core.Abstractions.HistoricalEventsResult> ReadEventsAsync(
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents,
CancellationToken cancellationToken)
{
// Confirm a session exists before queuing; the session actually used — and the
// namespace-relative source-NodeId parse — are re-read inside the gate so a reconnect
// mid-wait can't leave us reading from a closed session.
_ = RequireSession();
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
// Re-read Session inside the critical section: OnReconnectComplete may have swapped
// in a new session while we were blocked on _gate, and the source-NodeId parse is
// namespace-relative so it must bind against the current session's namespace table.
var session = Session;
if (session is null) return new Core.Abstractions.HistoricalEventsResult([], null);
NodeId notifierNodeId;
if (string.IsNullOrEmpty(sourceName))
{
notifierNodeId = ObjectIds.Server;
}
// sourceName is an upstream event-notifier node id (not a tag RawPath), so it
// re-binds directly against the session via the static resolve.
else if (!NamespaceMap.TryResolve(session, sourceName, out var parsed))
{
return new Core.Abstractions.HistoricalEventsResult([], null);
}
else
{
notifierNodeId = parsed;
}
var details = new ReadEventDetails
{
StartTime = startUtc,
EndTime = endUtc,
NumValuesPerNode = maxEvents <= 0 ? 0u : (uint)maxEvents,
Filter = BuildBaseEventFilter(),
};
var nodesToRead = new HistoryReadValueIdCollection
{
new HistoryReadValueId { NodeId = notifierNodeId },
};
var resp = await session.HistoryReadAsync(
requestHeader: null,
historyReadDetails: new ExtensionObject(details),
timestampsToReturn: TimestampsToReturn.Both,
releaseContinuationPoints: false,
nodesToRead: nodesToRead,
ct: cancellationToken).ConfigureAwait(false);
if (resp.Results.Count == 0) return new Core.Abstractions.HistoricalEventsResult([], null);
var r = resp.Results[0];
var events = r.HistoryData?.Body is HistoryEvent he
? MapHistoryEvents(he)
: [];
var contPt = r.ContinuationPoint is { Length: > 0 } ? r.ContinuationPoint : null;
return new Core.Abstractions.HistoricalEventsResult(events, contPt);
}
finally { _gate.Release(); }
}
// ---- IHostConnectivityProbe ----
/// <summary>
@@ -21,7 +21,7 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers;
public sealed class ResilienceFormModel
{
public static readonly string[] Capabilities =
["Read", "Write", "Discover", "Subscribe", "Probe", "AlarmSubscribe", "AlarmAcknowledge", "HistoryRead", "Initialize"];
["Read", "Write", "Discover", "Subscribe", "Probe", "AlarmSubscribe", "AlarmAcknowledge", "Initialize"];
// RecycleIntervalSeconds is GONE (Gitea #522) — the scheduled-recycle machinery it authored was
// deleted, so the field configured nothing. It is deliberately NOT explicitly stripped from stored
@@ -193,7 +193,7 @@ internal static class HistoryPaging
/// <param name="boundaryT">The single SourceTimestamp every tie in the cluster shares.</param>
/// <param name="endUtc">The (inclusive) upper bound of the read window; unchanged across pages.
/// OpcUaServer-003, accepted by design: the backend contract
/// (<c>IHistoryProvider.ReadRawAsync</c>) treats <c>endUtc</c> as EXCLUSIVE, so the
/// (<c>IHistorianDataSource.ReadRawAsync</c>) treats <c>endUtc</c> as EXCLUSIVE, so the
/// <c>next &lt;= endUtc</c> test here is deliberately the conservative side — at worst one extra
/// empty resume page (<c>[endUtc, endUtc)</c> → GoodNoData, no continuation point), never a
/// dropped or duplicated sample. Tightening to <c>&lt;</c> would be correct only if the live
@@ -5419,14 +5419,6 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
errors[handle.Index] = StatusCodes.BadAggregateNotSupported;
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadAggregateNotSupported };
}
catch (HistoryReadStatusException ex)
{
// A passthrough backend (OpcUaClient) relayed the upstream server's Bad per-node status - surface
// it verbatim for THIS node rather than collapsing it to BadHistoryOperationUnsupported.
_logger.LogDebug(ex, "OtOpcUaNodeManager: backend refused history read for node {NodeId}", handle.NodeId);
errors[handle.Index] = new StatusCode(ex.StatusCode);
results[handle.Index] = new SdkHistoryReadResult { StatusCode = new StatusCode(ex.StatusCode) };
}
catch (Exception ex)
{
// One node's backend failure (throw / timeout) must not throw out of the batch — surface a
@@ -5628,13 +5620,6 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
errors[handle.Index] = StatusCodes.BadTimeout;
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadTimeout };
}
catch (HistoryReadStatusException ex)
{
// Upstream Bad per-node status relayed by a passthrough backend - surface it verbatim (see ServeNodeAsync).
_logger.LogDebug(ex, "OtOpcUaNodeManager: backend refused HistoryReadRaw for node {NodeId}", handle.NodeId);
errors[handle.Index] = new StatusCode(ex.StatusCode);
results[handle.Index] = new SdkHistoryReadResult { StatusCode = new StatusCode(ex.StatusCode) };
}
catch (Exception ex)
{
// One node's backend failure must not throw out of the batch — Bad for THIS node only.
@@ -26,8 +26,8 @@ namespace ZB.MOM.WW.OtOpcUa.Analyzers;
/// <c>IReadable.ReadAsync</c> still trips the rule.
/// </para>
/// <para>
/// <b>Default-interface-method handling:</b> <c>IHistoryProvider.ReadAtTimeAsync</c> and
/// <c>IHistoryProvider.ReadEventsAsync</c> ship as DIM bodies. When a driver inherits the
/// <b>Default-interface-method handling:</b> a guarded interface member may ship as a DIM
/// body. When a driver inherits the
/// DIM (no override), <c>FindImplementationForInterfaceMember</c> returns the interface's
/// own method symbol, which still equals <paramref name="method"/> for an interface-typed
/// receiver. When a driver overrides the DIM, the override symbol equals
@@ -75,7 +75,6 @@ public sealed class UnwrappedCapabilityCallAnalyzer : DiagnosticAnalyzer
"ZB.MOM.WW.OtOpcUa.Core.Abstractions.ISubscribable",
"ZB.MOM.WW.OtOpcUa.Core.Abstractions.IHostConnectivityProbe",
"ZB.MOM.WW.OtOpcUa.Core.Abstractions.IAlarmSource",
"ZB.MOM.WW.OtOpcUa.Core.Abstractions.IHistoryProvider",
];
/// <summary>
@@ -106,7 +105,7 @@ public sealed class UnwrappedCapabilityCallAnalyzer : DiagnosticAnalyzer
category: "OtOpcUa.Resilience",
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true,
description: "Phase 6.1 Stream A requires every IReadable/IWritable/ITagDiscovery/ISubscribable/IHostConnectivityProbe/IAlarmSource/IHistoryProvider call to route through the shared Polly pipeline. Direct calls skip the pipeline + lose per-host isolation, retry semantics, and telemetry. If the caller is Core/Server/Driver dispatch code, wrap the call in CapabilityInvoker.ExecuteAsync. If the caller is a unit test invoking the driver directly to test its wire-level behavior, either suppress with a pragma or move the suppression into a NoWarn for the test project.");
description: "Phase 6.1 Stream A requires every IReadable/IWritable/ITagDiscovery/ISubscribable/IHostConnectivityProbe/IAlarmSource call to route through the shared Polly pipeline. Direct calls skip the pipeline + lose per-host isolation, retry semantics, and telemetry. If the caller is Core/Server/Driver dispatch code, wrap the call in CapabilityInvoker.ExecuteAsync. If the caller is a unit test invoking the driver directly to test its wire-level behavior, either suppress with a pragma or move the suppression into a NoWarn for the test project.");
/// <inheritdoc />
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get; } = ImmutableArray.Create(Rule);
@@ -0,0 +1,38 @@
using Opc.Ua;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.Helpers;
public class HistoryReadStatusTests
{
/// <summary>A refused read must surface, carrying the status code, rather than read as an empty result.</summary>
/// <param name="code">The Bad per-node status code.</param>
[Theory]
[InlineData(StatusCodes.BadHistoryOperationUnsupported)]
[InlineData(StatusCodes.BadUserAccessDenied)]
[InlineData(StatusCodes.BadNodeIdUnknown)]
[InlineData(StatusCodes.BadTimeout)]
public void Bad_status_throws_ServiceResultException_with_the_code(uint code)
{
var ex = Should.Throw<ServiceResultException>(
() => HistoryReadStatus.ThrowIfRefused(new StatusCode(code), new NodeId("n", 2)));
ex.StatusCode.ShouldBe(code);
ex.Message.ShouldContain("ns=2;s=n");
}
/// <summary>Good, GoodNoData, Uncertain and BadNoData are all non-refusals (the window may simply be empty).</summary>
/// <param name="code">The non-refusal status code.</param>
[Theory]
[InlineData(StatusCodes.Good)]
[InlineData(StatusCodes.GoodNoData)]
[InlineData(StatusCodes.GoodMoreData)]
[InlineData(StatusCodes.UncertainNoCommunicationLastUsableValue)]
[InlineData(StatusCodes.BadNoData)]
public void Non_refusal_status_does_not_throw(uint code)
{
Should.NotThrow(() => HistoryReadStatus.ThrowIfRefused(new StatusCode(code), new NodeId("n", 2)));
}
}
@@ -1,21 +0,0 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.Historian;
public sealed class HistoryReadStatusExceptionTests
{
[Theory]
[InlineData(0x801F0000u)] // BadUserAccessDenied
[InlineData(0xC0000000u)] // severity 11 (reserved Bad)
public void A_Bad_code_is_carried_verbatim(uint code) =>
new HistoryReadStatusException(code).StatusCode.ShouldBe(code);
[Theory]
[InlineData(0x00000000u)] // Good
[InlineData(0x00A70000u)] // GoodNoData
[InlineData(0x40000000u)] // Uncertain
public void A_non_Bad_code_is_rejected(uint code) =>
Should.Throw<ArgumentException>(() => new HistoryReadStatusException(code));
}
@@ -153,22 +153,6 @@ public sealed class IHistorianDataSourceContractTests
parameter!.ParameterType.ShouldBe(expectedType);
}
/// <summary>Verifies that history provider max parameters have the expected type.</summary>
/// <param name="methodName">The name of the method to test.</param>
/// <param name="parameterName">The name of the parameter to verify.</param>
/// <param name="expectedType">The expected parameter type.</param>
[Theory]
[InlineData("ReadRawAsync", "maxValuesPerNode", typeof(uint))]
[InlineData("ReadEventsAsync", "maxEvents", typeof(int))]
public void HistoryProvider_MaxParameter_TypePinned(string methodName, string parameterName, Type expectedType)
{
var method = typeof(IHistoryProvider).GetMethod(methodName);
method.ShouldNotBeNull();
var parameter = method!.GetParameters().FirstOrDefault(p => p.Name == parameterName);
parameter.ShouldNotBeNull($"Method {methodName} should expose a '{parameterName}' parameter.");
parameter!.ParameterType.ShouldBe(expectedType);
}
/// <summary>
/// Core.Abstractions-009 (OpcUaServer-002): when <c>maxEvents &lt;= 0</c> the contract
/// requires implementations to set <see cref="HistoricalEventsResult.ContinuationPoint"/>
@@ -195,30 +179,4 @@ public sealed class IHistorianDataSourceContractTests
continuationProp!.PropertyType.ShouldBe(typeof(byte[]),
"ContinuationPoint is byte[]? — null means no more pages, non-null means caller must page");
}
/// <summary>
/// Core.Abstractions-008: <see cref="IHistoryProvider.ReadAtTimeAsync"/> and
/// <see cref="IHistoryProvider.ReadEventsAsync"/> are C# default interface methods
/// (drivers opt in), whereas <see cref="IHistorianDataSource.ReadAtTimeAsync"/> and
/// <see cref="IHistorianDataSource.ReadEventsAsync"/> are required (the server-side
/// historian must implement them). This test pins the asymmetry so an implementer cannot
/// accidentally collapse the two surfaces and so the documented rationale stays load-bearing.
/// </summary>
/// <param name="methodName">The name of the optional method to check.</param>
[Theory]
[InlineData("ReadAtTimeAsync")]
[InlineData("ReadEventsAsync")]
public void HistoryProvider_OptionalMethods_HaveDefaultImplementation(string methodName)
{
var providerMethod = typeof(IHistoryProvider).GetMethod(methodName);
providerMethod.ShouldNotBeNull();
// Default interface methods carry a method body — IsAbstract is false.
providerMethod!.IsAbstract.ShouldBeFalse(
$"IHistoryProvider.{methodName} must remain a default-impl-throws method so legacy drivers continue to compile.");
var dataSourceMethod = typeof(IHistorianDataSource).GetMethod(methodName);
dataSourceMethod.ShouldNotBeNull();
dataSourceMethod!.IsAbstract.ShouldBeTrue(
$"IHistorianDataSource.{methodName} must remain required — server-side historians own the full surface.");
}
}
@@ -62,7 +62,6 @@ public sealed class InterfaceIndependenceTests
[InlineData(typeof(IWritable))]
[InlineData(typeof(ISubscribable))]
[InlineData(typeof(IAlarmSource))]
[InlineData(typeof(IHistoryProvider))]
[InlineData(typeof(IRediscoverable))]
[InlineData(typeof(IHostConnectivityProbe))]
[InlineData(typeof(ISkippedTagReporter))]
@@ -73,4 +72,15 @@ public sealed class InterfaceIndependenceTests
type.IsPublic.ShouldBeTrue($"{type.Name} must be public — drivers in separate assemblies implement it.");
type.IsInterface.ShouldBeTrue($"{type.Name} must be an interface, not a class.");
}
/// <summary>
/// Gitea #655: HistoryRead is served server-side by <c>IHistorianDataSource</c>; no driver-side history
/// capability exists. Re-adding one without also adding a dispatch path would recreate dead surface.
/// </summary>
[Fact]
public void NoDriverSideHistoryCapability_Exists()
{
Assembly.GetType("ZB.MOM.WW.OtOpcUa.Core.Abstractions.IHistoryProvider").ShouldBeNull();
Enum.GetNames<DriverCapability>().ShouldNotContain("HistoryRead");
}
}
@@ -138,6 +138,33 @@ public sealed class DriverResilienceOptionsParserTests
DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Read]);
}
/// <summary>
/// Gitea #655: the retired <c>HistoryRead</c> capability may still sit in stored
/// <c>DriverInstance.ResilienceConfig</c> rows. It must parse (diagnostic only, never a throw), be skipped,
/// and leave every other override in the same blob applied.
/// </summary>
[Fact]
public void StoredConfig_WithRetiredHistoryReadKey_StillParses_AndOtherOverridesApply()
{
var json = """
{
"capabilityPolicies": {
"HistoryRead": { "timeoutSeconds": 45, "retryCount": 1, "breakerFailureThreshold": 3 },
"Read": { "timeoutSeconds": 7 }
}
}
""";
var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.A, json, out var diag);
diag.ShouldNotBeNull();
diag.ShouldContain("HistoryRead");
diag.ShouldContain("skipped");
options.Resolve(DriverCapability.Read).TimeoutSeconds.ShouldBe(7);
options.Resolve(DriverCapability.Write).ShouldBe(
DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Write]);
}
/// <summary>Verifies that top-level property names are case insensitive.</summary>
[Fact]
public void PropertyNames_AreCaseInsensitive()
@@ -116,7 +116,6 @@ public sealed class DriverResilienceOptionsTests
/// <param name="capability">The driver capability to test.</param>
[Theory]
[InlineData(DriverTier.A, DriverCapability.Read)]
[InlineData(DriverTier.A, DriverCapability.HistoryRead)]
[InlineData(DriverTier.B, DriverCapability.Discover)]
[InlineData(DriverTier.B, DriverCapability.Probe)]
[InlineData(DriverTier.C, DriverCapability.AlarmSubscribe)]
@@ -16,9 +16,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests;
/// <para>
/// <b>Why opc-plc over loopback against our own server</b> — (1) independent
/// cert chain + user-token handling catches interop bugs loopback can't;
/// (2) built-in alarm ConditionType + history simulation gives
/// <see cref="Core.Abstractions.IAlarmSource"/> +
/// <see cref="Core.Abstractions.IHistoryProvider"/> coverage without a custom
/// (2) built-in alarm ConditionType simulation gives
/// <see cref="Core.Abstractions.IAlarmSource"/> coverage without a custom
/// driver fake; (3) pinned image tag fixes the test surface in a way our own
/// evolving server wouldn't. Follow-up: add <c>open62541/open62541</c> as a
/// second image once this lands, for fully-independent-stack interop.
@@ -92,32 +92,4 @@ public sealed class OpcUaClientSmokeTests(OpcPlcFixture sim)
await drv.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
}
/// <summary>
/// Verifies HistoryReadEvents passthrough issues a well-formed request and returns a
/// result without throwing. opc-plc exposes live alarm conditions (--alm) but is NOT a
/// historian, so the upstream may return zero historical events or reject the service —
/// either way the driver must produce a HistoricalEventsResult, never throw. This proves
/// the wire request + unwrap path; a non-empty event list is infra-gated on an upstream
/// that historizes events.
/// </summary>
[Fact]
public async Task Client_reads_events_returns_result_without_throwing()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
var options = OpcPlcProfile.BuildOptions(sim.EndpointUrl);
await using var drv = new OpcUaClientDriver(options, driverInstanceId: "opcua-smoke-events");
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var result = await drv.ReadEventsAsync(
sourceName: null, // null → upstream Server object (i=2253)
startUtc: DateTime.UtcNow.AddHours(-1),
endUtc: DateTime.UtcNow,
maxEvents: 100,
cancellationToken: TestContext.Current.CancellationToken);
result.ShouldNotBeNull();
result.Events.ShouldNotBeNull();
}
}
@@ -1,178 +0,0 @@
using Opc.Ua;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
[Trait("Category", "Unit")]
public sealed class OpcUaClientHistoryTests
{
/// <summary>Every <see cref="HistoryAggregateType"/> member (#285: the full Part 13 set, not just five).</summary>
public static IEnumerable<object[]> AllAggregates() =>
Enum.GetValues<HistoryAggregateType>().Select(a => new object[] { a });
/// <summary>Every member maps to a DISTINCT aggregate NodeId (a copy/paste mapping would collide).</summary>
[Fact]
public void MapAggregateToNodeId_is_injective_over_the_whole_enum()
{
var ids = Enum.GetValues<HistoryAggregateType>().Select(OpcUaClientDriver.MapAggregateToNodeId).ToList();
ids.Distinct().Count().ShouldBe(ids.Count);
Enum.GetValues<HistoryAggregateType>().Length.ShouldBeGreaterThan(5);
}
/// <summary>Verifies MapAggregateToNodeId returns standard Part 13 aggregate for every enum.</summary>
/// <param name="agg">The history aggregate type to test.</param>
[Theory]
[MemberData(nameof(AllAggregates))]
public void MapAggregateToNodeId_returns_standard_Part13_aggregate_for_every_enum(HistoryAggregateType agg)
{
var nodeId = OpcUaClientDriver.MapAggregateToNodeId(agg);
NodeId.IsNull(nodeId).ShouldBeFalse();
// Every mapping should resolve to an AggregateFunction_* NodeId (namespace 0, numeric id).
nodeId.NamespaceIndex.ShouldBe((ushort)0);
}
/// <summary>Verifies MapAggregateToNodeId rejects invalid enum values.</summary>
[Fact]
public void MapAggregateToNodeId_rejects_invalid_enum_value()
{
// Defense-in-depth: a future HistoryAggregateType addition mustn't silently fall through.
Should.Throw<ArgumentOutOfRangeException>(() =>
OpcUaClientDriver.MapAggregateToNodeId((HistoryAggregateType)99));
}
/// <summary>Verifies ReadRawAsync throws without initialization.</summary>
[Fact]
public async Task ReadRawAsync_without_initialize_throws_InvalidOperationException()
{
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-hist-uninit");
await Should.ThrowAsync<InvalidOperationException>(async () =>
await drv.ReadRawAsync("ns=2;s=Counter",
DateTime.UtcNow.AddMinutes(-5), DateTime.UtcNow, 1000,
TestContext.Current.CancellationToken));
}
/// <summary>Verifies ReadRawAsync with malformed NodeId returns empty result.</summary>
[Fact]
public async Task ReadRawAsync_with_malformed_NodeId_returns_empty_result_not_throw()
{
// Same defensive pattern as ReadAsync / WriteAsync — malformed NodeId short-circuits
// to an empty result rather than crashing a batch history call. Needs init via the
// throw path first, then we pass "" to trigger the parse-fail branch inside
// ExecuteHistoryReadAsync. The init itself fails against 127.0.0.1:1 so we stop there.
// Not runnable without init — keep as placeholder for when the in-process fixture
// PR lands.
await Task.CompletedTask;
}
/// <summary>Verifies ReadProcessedAsync throws without initialization.</summary>
[Fact]
public async Task ReadProcessedAsync_without_initialize_throws_InvalidOperationException()
{
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-hist-uninit");
await Should.ThrowAsync<InvalidOperationException>(async () =>
await drv.ReadProcessedAsync("ns=2;s=Counter",
DateTime.UtcNow.AddMinutes(-5), DateTime.UtcNow,
TimeSpan.FromSeconds(10), HistoryAggregateType.Average,
TestContext.Current.CancellationToken));
}
/// <summary>Verifies ReadAtTimeAsync throws without initialization.</summary>
[Fact]
public async Task ReadAtTimeAsync_without_initialize_throws_InvalidOperationException()
{
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-hist-uninit");
await Should.ThrowAsync<InvalidOperationException>(async () =>
await drv.ReadAtTimeAsync("ns=2;s=Counter",
[DateTime.UtcNow.AddMinutes(-5), DateTime.UtcNow],
TestContext.Current.CancellationToken));
}
/// <summary>ReadEventsAsync requires an initialized session like the sibling history reads.</summary>
[Fact]
public async Task ReadEventsAsync_without_initialize_throws_InvalidOperationException()
{
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-events-uninit");
await Should.ThrowAsync<InvalidOperationException>(async () =>
await drv.ReadEventsAsync(
sourceName: null,
startUtc: DateTime.UtcNow.AddMinutes(-5),
endUtc: DateTime.UtcNow,
maxEvents: 100,
cancellationToken: TestContext.Current.CancellationToken));
}
/// <summary>BuildBaseEventFilter emits the six canonical BaseEventType select clauses in order.</summary>
[Fact]
public void BuildBaseEventFilter_has_six_canonical_BaseEventType_value_clauses()
{
var filter = OpcUaClientDriver.BuildBaseEventFilter();
filter.SelectClauses.Count.ShouldBe(6);
string[] expected = ["EventId", "SourceName", "Time", "ReceiveTime", "Message", "Severity"];
for (var i = 0; i < expected.Length; i++)
{
var clause = filter.SelectClauses[i];
clause.TypeDefinitionId.ShouldBe(ObjectTypeIds.BaseEventType);
clause.AttributeId.ShouldBe(Attributes.Value);
clause.BrowsePath.Count.ShouldBe(1);
clause.BrowsePath[0].Name.ShouldBe(expected[i]);
clause.BrowsePath[0].NamespaceIndex.ShouldBe((ushort)0); // BaseEventType fields live in ns 0
}
}
/// <summary>MapHistoryEvents maps every field by its canonical select-clause index.</summary>
[Fact]
public void MapHistoryEvents_maps_all_six_fields_by_canonical_index()
{
var eventTime = new DateTime(2026, 6, 18, 10, 0, 0, DateTimeKind.Utc);
var recvTime = eventTime.AddSeconds(1);
var he = new HistoryEvent();
var fields = new HistoryEventFieldList();
fields.EventFields.Add(new Variant(new byte[] { 1, 2, 3 })); // 0 EventId
fields.EventFields.Add(new Variant("Pump17")); // 1 SourceName
fields.EventFields.Add(new Variant(eventTime)); // 2 Time
fields.EventFields.Add(new Variant(recvTime)); // 3 ReceiveTime
fields.EventFields.Add(new Variant(new LocalizedText("High temp"))); // 4 Message
fields.EventFields.Add(new Variant((ushort)700)); // 5 Severity
he.Events.Add(fields);
var mapped = OpcUaClientDriver.MapHistoryEvents(he);
mapped.Count.ShouldBe(1);
var e = mapped[0];
e.EventId.ShouldBe(Convert.ToBase64String(new byte[] { 1, 2, 3 }));
e.SourceName.ShouldBe("Pump17");
e.EventTimeUtc.ShouldBe(eventTime);
e.ReceivedTimeUtc.ShouldBe(recvTime);
e.Message.ShouldBe("High temp");
e.Severity.ShouldBe((ushort)700);
}
/// <summary>MapHistoryEvents returns empty for a HistoryEvent with no rows.</summary>
[Fact]
public void MapHistoryEvents_with_no_events_returns_empty()
{
OpcUaClientDriver.MapHistoryEvents(new HistoryEvent()).ShouldBeEmpty();
}
/// <summary>MapHistoryEvents tolerates a field list shorter than six without throwing.</summary>
[Fact]
public void MapHistoryEvents_tolerates_short_field_list_without_throwing()
{
var he = new HistoryEvent();
var fields = new HistoryEventFieldList();
fields.EventFields.Add(new Variant(new byte[] { 9 })); // only EventId present
he.Events.Add(fields);
var mapped = OpcUaClientDriver.MapHistoryEvents(he);
mapped.Count.ShouldBe(1);
mapped[0].EventId.ShouldBe(Convert.ToBase64String(new byte[] { 9 }));
mapped[0].SourceName.ShouldBeNull();
mapped[0].EventTimeUtc.ShouldBe(DateTime.MinValue);
mapped[0].EventTimeUtc.Kind.ShouldBe(DateTimeKind.Utc); // sentinel is UTC-kinded, not Unspecified
mapped[0].Severity.ShouldBe((ushort)0);
}
}
@@ -8,13 +8,13 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
/// <summary>
/// Regression coverage for the stale-session concurrency race in the history/read wire
/// paths. A background reconnect handler can swap <see cref="OpcUaClientDriver.Session"/>
/// Regression coverage for the stale-session concurrency race in the alarm-acknowledge wire
/// path. A background reconnect handler can swap <see cref="OpcUaClientDriver.Session"/>
/// while a caller is parked on <c>_gate.WaitAsync</c>. If a method captures the session
/// reference <i>before</i> the gate and uses it afterwards, it issues the wire call against
/// the closed/stale session. The correct idiom — already used by ReadAsync/WriteAsync — is
/// a fast-fail guard outside the gate plus an authoritative re-read of <c>Session</c>
/// <i>inside</i> the gate. These tests prove the history/read funnels follow it.
/// <i>inside</i> the gate. This test proves the acknowledge path follows it.
/// </summary>
/// <remarks>
/// The swap is staged so the race is exercised, not raced against:
@@ -41,13 +41,6 @@ public sealed class OpcUaClientStaleSessionRaceTests
{
private static Mock<ISession> NewSessionMock(TaskCompletionSource? prologueReached = null)
{
var resp = new HistoryReadResponse
{
ResponseHeader = new ResponseHeader(),
Results = new HistoryReadResultCollection(),
DiagnosticInfos = new DiagnosticInfoCollection(),
};
var mock = new Mock<ISession>(MockBehavior.Loose);
// NodeId parsing inside the driver resolves the reference against the session's
// MessageContext. This getter is read in the driver's synchronous prologue (before the
@@ -56,14 +49,6 @@ public sealed class OpcUaClientStaleSessionRaceTests
.Returns(new ServiceMessageContext(ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.SdkTelemetry.Context))
.Callback(() => prologueReached?.TrySetResult());
mock.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
mock.Setup(s => s.HistoryReadAsync(
It.IsAny<RequestHeader>(),
It.IsAny<ExtensionObject>(),
It.IsAny<TimestampsToReturn>(),
It.IsAny<bool>(),
It.IsAny<HistoryReadValueIdCollection>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(resp);
// Acknowledge issues its wire call via CallAsync; stub it so AcknowledgeAsync can run to
// completion against whichever session it ends up binding to.
mock.Setup(s => s.CallAsync(
@@ -79,18 +64,6 @@ public sealed class OpcUaClientStaleSessionRaceTests
return mock;
}
private static void VerifyWireHitNewSessionNotOld(Mock<ISession> newSession, Mock<ISession> oldSession)
{
newSession.Verify(s => s.HistoryReadAsync(
It.IsAny<RequestHeader>(), It.IsAny<ExtensionObject>(), It.IsAny<TimestampsToReturn>(),
It.IsAny<bool>(), It.IsAny<HistoryReadValueIdCollection>(), It.IsAny<CancellationToken>()),
Times.Once);
oldSession.Verify(s => s.HistoryReadAsync(
It.IsAny<RequestHeader>(), It.IsAny<ExtensionObject>(), It.IsAny<TimestampsToReturn>(),
It.IsAny<bool>(), It.IsAny<HistoryReadValueIdCollection>(), It.IsAny<CancellationToken>()),
Times.Never);
}
private static void VerifyAckWireHitNewSessionNotOld(Mock<ISession> newSession, Mock<ISession> oldSession)
{
newSession.Verify(s => s.CallAsync(
@@ -101,82 +74,6 @@ public sealed class OpcUaClientStaleSessionRaceTests
Times.Never);
}
/// <summary>
/// ExecuteHistoryReadAsync (the ReadRaw/ReadProcessed/ReadAtTime funnel) must issue the
/// HistoryRead against the session current <i>after</i> the gate, not a reference
/// captured before WaitAsync.
/// </summary>
[Fact]
public async Task ReadRawAsync_uses_session_swapped_in_across_the_gate_not_the_captured_one()
{
var ct = TestContext.Current.CancellationToken;
// v3: a HistoryRead over a variable is keyed by the tag's RawPath, resolved through the
// authored RawTags table to its upstream node id. Author "ns=2;s=Counter" as both the
// RawPath and the TagConfig.nodeId so the two-stage resolve reaches the wire.
var options = new OpcUaClientDriverOptions
{
RawTags = [new RawTagEntry("ns=2;s=Counter", """{"nodeId":"ns=2;s=Counter"}""", WriteIdempotent: false)],
};
using var drv = new OpcUaClientDriver(options, "opcua-stale-raw");
drv.SetNamespaceMapForTest(NamespaceMap.FromTable(new NamespaceTable(), options.RawTags));
var prologueReached = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var oldSession = NewSessionMock(prologueReached);
var newSession = NewSessionMock();
drv.SetSessionForTest(oldSession.Object);
// Hold the gate so the method parks on WaitAsync after running its synchronous prologue.
await drv.Gate.WaitAsync(ct);
var call = drv.ReadRawAsync(
"ns=2;s=Counter", DateTime.UtcNow.AddMinutes(-5), DateTime.UtcNow, 1000, ct);
// Wait until the buggy capture-before-gate has grabbed the OLD session (signalled via
// its MessageContext read), then stage the reconnect swap and release the gate. The
// fixed path reads no pre-gate session member, so the signal never fires and the
// bounded fallback elapses — harmless, because the fixed read happens only after the
// gate is released and therefore always sees the swapped session.
await WaitForPreGateCaptureOrFallbackAsync(prologueReached.Task, ct);
drv.SetSessionForTest(newSession.Object);
drv.Gate.Release();
await call;
VerifyWireHitNewSessionNotOld(newSession, oldSession);
}
/// <summary>
/// ReadEventsAsync is a separate HistoryRead site with the same hazard — it must also
/// re-read Session inside the gate rather than use the pre-gate capture.
/// </summary>
[Fact]
public async Task ReadEventsAsync_uses_session_swapped_in_across_the_gate_not_the_captured_one()
{
var ct = TestContext.Current.CancellationToken;
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-stale-events");
var prologueReached = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
// ReadEventsAsync only parses the source NodeId when sourceName is non-empty, so pass a
// concrete source id to force the prologue's MessageContext read (and its signal).
var oldSession = NewSessionMock(prologueReached);
var newSession = NewSessionMock();
drv.SetSessionForTest(oldSession.Object);
await drv.Gate.WaitAsync(ct);
var call = drv.ReadEventsAsync(
sourceName: "ns=2;s=Pump17", startUtc: DateTime.UtcNow.AddMinutes(-5),
endUtc: DateTime.UtcNow, maxEvents: 100, cancellationToken: ct);
await WaitForPreGateCaptureOrFallbackAsync(prologueReached.Task, ct);
drv.SetSessionForTest(newSession.Object);
drv.Gate.Release();
await call;
VerifyWireHitNewSessionNotOld(newSession, oldSession);
}
/// <summary>
/// AcknowledgeAsync issues its Acknowledge method <c>CallAsync</c> against the session
/// current after the gate — and parses the namespace-relative ConditionId against that
@@ -1,258 +0,0 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
/// <summary>
/// HistoryRead leg of the OpcUaClient driver's wire coverage (Gitea #622): the real
/// <see cref="OpcUaClientDriver"/> issues Raw / Processed / AtTime reads over loopback to the in-process
/// OtOpcUa server, whose node manager dispatches to an injected deterministic
/// <see cref="IHistorianDataSource"/> (<see cref="FakeHistorian"/>) behind a historized raw variable.
/// </summary>
[Trait("Category", "Integration")]
public sealed class OpcUaClientDriverHistoryWireTests
{
private const string HistRef = "Sim/dev1/Hist";
private const string HistTag = "Plant.Hist.Series";
private const uint Uncertain = 0x40000000u;
private const uint BadNoData = 0x809B0000u;
private const uint BadUserAccessDenied = 0x801F0000u;
private const uint BadHistoryOperationUnsupported = 0x80720000u;
private static readonly DateTime T0 = new(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc);
private static DateTime At(int seconds) => T0.AddSeconds(seconds);
/// <summary>Five samples, one per second, value = 1..5; the third is Uncertain.</summary>
private static readonly DataValueSnapshot[] Series =
[
new(1.0, 0u, At(0), At(0)),
new(2.0, 0u, At(1), At(1)),
new(3.0, Uncertain, At(2), At(2)),
new(4.0, 0u, At(3), At(3)),
new(5.0, 0u, At(4), At(4)),
];
private sealed class FakeHistorian : IHistorianDataSource
{
public List<string> Calls { get; } = [];
public Task<HistoryReadResult> ReadRawAsync(
string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode, CancellationToken ct)
{
lock (Calls) Calls.Add($"raw:{fullReference}");
var hits = Series.Where(s => s.SourceTimestampUtc >= startUtc && s.SourceTimestampUtc < endUtc);
if (maxValuesPerNode > 0) hits = hits.Take((int)maxValuesPerNode);
return Task.FromResult(new HistoryReadResult([.. hits], null));
}
public Task<HistoryReadResult> ReadProcessedAsync(
string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval,
HistoryAggregateType aggregate, CancellationToken ct)
{
lock (Calls) Calls.Add($"processed:{fullReference}:{aggregate}");
// Minimum stands in for "the backend refuses with some OTHER Bad status" (#649).
if (aggregate == HistoryAggregateType.Minimum)
throw new HistoryReadStatusException(BadUserAccessDenied);
// Maximum stands in for a node-level BadNoData ("nothing in the window"), which must stay empty.
if (aggregate == HistoryAggregateType.Maximum)
throw new HistoryReadStatusException(BadNoData);
if (aggregate != HistoryAggregateType.Average)
throw new HistoryAggregateNotSupportedException(aggregate);
var buckets = new List<DataValueSnapshot>();
for (var b = startUtc; b < endUtc; b += interval)
{
var inBucket = Series.Where(s => s.SourceTimestampUtc >= b && s.SourceTimestampUtc < b + interval).ToList();
if (inBucket.Count == 0) continue;
buckets.Add(new DataValueSnapshot(inBucket.Average(s => (double)s.Value!), 0u, b, b));
}
return Task.FromResult(new HistoryReadResult(buckets, null));
}
public Task<HistoryReadResult> ReadAtTimeAsync(
string fullReference, IReadOnlyList<DateTime> timestampsUtc, CancellationToken ct)
{
lock (Calls) Calls.Add($"attime:{fullReference}");
// Step semantics: the last sample at or before the requested instant; BadNoData before the first.
var result = timestampsUtc.Select(t =>
{
var prior = Series.LastOrDefault(s => s.SourceTimestampUtc <= t);
return prior is null
? new DataValueSnapshot(null, 0x809B0000u /* BadNoData */, t, t)
: new DataValueSnapshot(prior.Value, prior.StatusCode, t, t);
}).ToList();
return Task.FromResult(new HistoryReadResult(result, null));
}
public Task<HistoricalEventsResult> ReadEventsAsync(
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, CancellationToken ct) =>
Task.FromResult(new HistoricalEventsResult([], null));
public HistorianHealthSnapshot GetHealthSnapshot() =>
new(0, 0, 0, 0, null, null, null, false, false, null, null, []);
public void Dispose() { }
}
private static async Task<(ServerEventRig Rig, FakeHistorian Fake, OpcUaClientDriver Drv)> StartAsync(
string name, CancellationToken ct)
{
var rig = await OpcUaClientDriverWireTests.StartRigAsync(name, ct);
var fake = new FakeHistorian();
rig.Nm.HistorianDataSource = fake;
var id = rig.EnsureRawVariable("Hist", "Double", writable: false, historianTagname: HistTag);
OpcUaClientDriverWireTests.Publish(rig, id, 5.0);
var drv = OpcUaClientDriverWireTests.NewDriver(
rig, name,
extraTags: [OpcUaClientDriverWireTests.Tag(HistRef, ServerEventRig.RawNsu(id))]);
await drv.InitializeAsync("{}", ct);
return (rig, fake, drv);
}
[Fact]
public async Task ReadRaw_returns_the_historians_samples_with_values_timestamps_and_statuses()
{
var ct = TestContext.Current.CancellationToken;
var (rig, fake, drv) = await StartAsync("hist-raw", ct);
await using var _r = rig;
await using var _d = drv;
var result = await drv.ReadRawAsync(HistRef, At(0), At(10), 0, ct);
result.Samples.Select(s => Convert.ToDouble(s.Value)).ShouldBe([1.0, 2.0, 3.0, 4.0, 5.0]);
result.Samples.Select(s => s.SourceTimestampUtc).ShouldBe(Series.Select(s => s.SourceTimestampUtc));
result.Samples.Select(s => s.StatusCode).ShouldBe([0u, 0u, Uncertain, 0u, 0u]);
result.ContinuationPoint.ShouldBeNull("the whole window fit in one page");
lock (fake.Calls) fake.Calls.ShouldContain($"raw:{HistTag}", "the read must reach the historian under the node's tagname");
}
[Fact]
public async Task ReadRaw_honours_the_window_and_pages_with_a_continuation_point_when_the_cap_is_hit()
{
var ct = TestContext.Current.CancellationToken;
var (rig, _, drv) = await StartAsync("hist-raw-page", ct);
await using var _r = rig;
await using var _d = drv;
// Window [1s, 4s) holds samples 2,3,4 (end exclusive).
var window = await drv.ReadRawAsync(HistRef, At(1), At(4), 0, ct);
window.Samples.Select(s => Convert.ToDouble(s.Value)).ShouldBe([2.0, 3.0, 4.0]);
// A cap of 3 against 5 available: first three samples and a continuation point.
var paged = await drv.ReadRawAsync(HistRef, At(0), At(10), 3, ct);
paged.Samples.Select(s => Convert.ToDouble(s.Value)).ShouldBe([1.0, 2.0, 3.0]);
paged.ContinuationPoint.ShouldNotBeNull("a full page must carry a continuation point");
}
[Fact]
public async Task ReadAtTime_returns_one_sample_per_requested_instant_with_the_prior_value()
{
var ct = TestContext.Current.CancellationToken;
var (rig, fake, drv) = await StartAsync("hist-attime", ct);
await using var _r = rig;
await using var _d = drv;
var result = await drv.ReadAtTimeAsync(HistRef, [At(-1), At(1), At(2), At(3).AddMilliseconds(500)], ct);
result.Samples.Count.ShouldBe(4);
result.Samples[0].StatusCode.ShouldNotBe(0u, "before the first sample there is no value");
Convert.ToDouble(result.Samples[1].Value).ShouldBe(2.0);
result.Samples[1].StatusCode.ShouldBe(0u);
Convert.ToDouble(result.Samples[2].Value).ShouldBe(3.0);
result.Samples[2].StatusCode.ShouldBe(Uncertain);
Convert.ToDouble(result.Samples[3].Value).ShouldBe(4.0);
result.Samples.Skip(1).Select(s => s.SourceTimestampUtc)
.ShouldBe([At(1), At(2), At(3).AddMilliseconds(500)]);
lock (fake.Calls) fake.Calls.ShouldContain($"attime:{HistTag}");
}
[Fact]
public async Task ReadProcessed_serves_a_supported_aggregate_and_surfaces_BadAggregateNotSupported_for_an_unsupported_one()
{
var ct = TestContext.Current.CancellationToken;
var (rig, fake, drv) = await StartAsync("hist-processed", ct);
await using var _r = rig;
await using var _d = drv;
// Average over 2s buckets across [0s,6s): {1,2}=1.5, {3,4}=3.5, {5}=5.
var avg = await drv.ReadProcessedAsync(HistRef, At(0), At(6), TimeSpan.FromSeconds(2), HistoryAggregateType.Average, ct);
avg.Samples.Select(s => Convert.ToDouble(s.Value)).ShouldBe([1.5, 3.5, 5.0]);
avg.Samples.Select(s => s.SourceTimestampUtc).ShouldBe([At(0), At(2), At(4)]);
// The backend refuses Count: the server maps that to BadAggregateNotSupported for the node, and the
// driver must raise it (not return empty Good data) so its own server surfaces it per node (#649).
// The Average above is the positive control that the same path serves data, and the call must have
// REACHED the backend.
var ex = await Should.ThrowAsync<HistoryAggregateNotSupportedException>(() =>
drv.ReadProcessedAsync(HistRef, At(0), At(6), TimeSpan.FromSeconds(2), HistoryAggregateType.Count, ct));
ex.Aggregate.ShouldBe(HistoryAggregateType.Count);
lock (fake.Calls) fake.Calls.ShouldContain($"processed:{HistTag}:Count");
}
[Fact]
public async Task ReadProcessed_surfaces_any_other_Bad_node_status_instead_of_empty_data()
{
var ct = TestContext.Current.CancellationToken;
var (rig, fake, drv) = await StartAsync("hist-badstatus", ct);
await using var _r = rig;
await using var _d = drv;
var ex = await Should.ThrowAsync<HistoryReadStatusException>(() =>
drv.ReadProcessedAsync(HistRef, At(0), At(6), TimeSpan.FromSeconds(2), HistoryAggregateType.Minimum, ct));
ex.StatusCode.ShouldBe(BadUserAccessDenied);
lock (fake.Calls) fake.Calls.ShouldContain($"processed:{HistTag}:Minimum");
}
[Fact]
public async Task A_node_level_BadNoData_stays_an_empty_result_not_an_error()
{
var ct = TestContext.Current.CancellationToken;
var (rig, fake, drv) = await StartAsync("hist-badnodata", ct);
await using var _r = rig;
await using var _d = drv;
var result = await drv.ReadProcessedAsync(
HistRef, At(0), At(6), TimeSpan.FromSeconds(2), HistoryAggregateType.Maximum, ct);
result.Samples.ShouldBeEmpty();
lock (fake.Calls) fake.Calls.ShouldContain($"processed:{HistTag}:Maximum", "the call must have reached the backend");
}
[Fact]
public async Task A_Good_read_with_no_samples_in_the_window_is_still_empty_data_not_an_error()
{
var ct = TestContext.Current.CancellationToken;
var (rig, _, drv) = await StartAsync("hist-goodnodata", ct);
await using var _r = rig;
await using var _d = drv;
// Historized node, window past the series: the server answers GoodNoData, which must stay a normal empty result.
var result = await drv.ReadRawAsync(HistRef, At(100), At(110), 0, ct);
result.Samples.ShouldBeEmpty();
result.ContinuationPoint.ShouldBeNull();
}
[Fact]
public async Task A_read_of_a_non_historized_node_surfaces_its_Bad_status_and_never_reaches_the_historian()
{
var ct = TestContext.Current.CancellationToken;
var (rig, fake, drv) = await StartAsync("hist-none", ct);
await using var _r = rig;
await using var _d = drv;
// The server answers BadHistoryOperationUnsupported for a node with no historian tagname; that Bad
// node status is surfaced (#649), no longer read as empty Good data.
var ex = await Should.ThrowAsync<HistoryReadStatusException>(() =>
drv.ReadRawAsync(OpcUaClientDriverWireTests.SpeedRef, At(0), At(10), 0, ct));
ex.StatusCode.ShouldBe(BadHistoryOperationUnsupported);
// Positive control: the historized sibling does reach the historian on the same session.
(await drv.ReadRawAsync(HistRef, At(0), At(10), 0, ct)).Samples.Count.ShouldBe(5);
lock (fake.Calls) fake.Calls.Count.ShouldBe(1, "only the historized read dispatches");
}
}
@@ -312,61 +312,6 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
await host.DisposeAsync();
}
/// <summary>A passthrough backend's Bad status on a RESUME read surfaces verbatim for that node only (#649):
/// no outbound continuation point, the cursor is consumed (no stray cursor), and a sibling node in the same
/// batch is unaffected.</summary>
[Fact]
public async Task Raw_resume_read_that_throws_HistoryReadStatusException_surfaces_the_code_and_isolates_the_sibling()
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
nm.HistoryContinuationStore = new InMemoryHistoryContinuationStore();
var fake = new SeriesHistorianDataSource(MakeSeries(count: 250, stepSeconds: 1));
nm.HistorianDataSource = fake;
nm.EnsureVariable("eq-1/bad", parentFolderNodeId: null, displayName: "Bad", dataType: "Double",
writable: false, historianTagname: "WW.Bad", realm: AddressSpaceRealm.Uns);
nm.EnsureVariable("eq-1/ok", parentFolderNodeId: null, displayName: "Ok", dataType: "Double",
writable: false, historianTagname: "WW.Ok", realm: AddressSpaceRealm.Uns);
var badId = nm.TryGetVariable("eq-1/bad")!.NodeId;
var okId = nm.TryGetVariable("eq-1/ok")!.NodeId;
// Page 1 succeeds and emits a continuation point.
var (_, _, cp1) = ReadRaw(nm, badId, Epoch, Epoch.AddHours(1), max: 100, inboundCp: null);
cp1.ShouldNotBeNull();
// The backend now refuses the bad node; one batch resumes it and freshly reads the sibling.
fake.FailTagname = "WW.Bad";
var context = new OperationContext(
new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity: null);
var details = new ReadRawModifiedDetails
{
StartTime = Epoch, EndTime = Epoch.AddHours(1), NumValuesPerNode = 100, IsReadModified = false,
};
var nodesToRead = new List<HistoryReadValueId>
{
new() { NodeId = badId, ContinuationPoint = cp1 },
new() { NodeId = okId },
};
var results = new List<SdkHistoryReadResult> { null!, null! };
var errors = new List<ServiceResult> { null!, null! };
nm.HistoryRead(context, details, TimestampsToReturn.Both,
releaseContinuationPoints: false, nodesToRead, results, errors);
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
results[0].ContinuationPoint.ShouldBeNull();
errors[1].StatusCode.Code.ShouldBe(StatusCodes.Good);
((HistoryData)ExtensionObject.ToEncodeable(results[1].HistoryData)).DataValues.Count.ShouldBe(100);
results[1].ContinuationPoint.ShouldNotBeNull("the sibling is full-page and pages normally");
// The consumed cursor is gone: nothing stray to resume.
fake.FailTagname = null;
var (_, e, _) = ReadRaw(nm, badId, Epoch, Epoch.AddHours(1), max: 100, inboundCp: cp1);
e.StatusCode.Code.ShouldBe(StatusCodes.BadContinuationPointInvalid);
await host.DisposeAsync();
}
// --- helpers --------------------------------------------------------------------------------
/// <summary>Issue a single-node Raw HistoryRead and return that node's decoded sample values, error,
@@ -468,8 +413,6 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
private int _readCount;
public int ReadCount => _readCount;
/// <summary>When set, a Raw read of this tagname throws <see cref="HistoryReadStatusException"/> (#649).</summary>
public volatile string? FailTagname;
public void ResetReadCount() => _readCount = 0;
public Task<HistorianRead> ReadRawAsync(
@@ -477,8 +420,6 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
CancellationToken cancellationToken)
{
Interlocked.Increment(ref _readCount);
if (FailTagname is { } bad && bad == fullReference)
throw new HistoryReadStatusException(StatusCodes.BadUserAccessDenied);
var window = series
.Where(s => (s.SourceTimestampUtc ?? DateTime.MinValue) >= startUtc
&& (s.SourceTimestampUtc ?? DateTime.MinValue) <= endUtc)
@@ -50,16 +50,6 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions {
public interface IHostConnectivityProbe {
IReadOnlyList<HostConnectivityStatus> GetHostStatuses();
}
public class HistoryReadResult { }
public class HistoricalEventsResult { }
public interface IHistoryProvider {
Task<HistoryReadResult> ReadRawAsync(string fullRef, DateTime start, DateTime end, uint max, CancellationToken ct);
Task<HistoryReadResult> ReadProcessedAsync(string fullRef, DateTime start, DateTime end, TimeSpan interval, CancellationToken ct);
Task<HistoryReadResult> ReadAtTimeAsync(string fullRef, IReadOnlyList<DateTime> timestamps, CancellationToken ct)
=> throw new NotSupportedException();
Task<HistoricalEventsResult> ReadEventsAsync(string? sourceName, DateTime start, DateTime end, int maxEvents, CancellationToken ct)
=> throw new NotSupportedException();
}
public enum DriverCapability { Read, Write, Discover, AlarmSubscribe, AlarmAcknowledge }
public interface IDriverCapabilityInvoker {
ValueTask<T> ExecuteAsync<T>(DriverCapability c, string host, Func<CancellationToken, ValueTask<T>> call, CancellationToken ct);
@@ -346,141 +336,6 @@ namespace ZB.MOM.WW.OtOpcUa.Server {
diags.ShouldBeEmpty();
}
// -----------------------------------------------------------------------
// IHistoryProvider — interface-typed receiver
// -----------------------------------------------------------------------
/// <summary>Verifies that a direct ReadRawAsync call on an interface trips the diagnostic.</summary>
[Fact]
public async Task Direct_ReadRawAsync_OnInterface_TripsDiagnostic()
{
const string userSrc = """
using System;
using System.Threading;
using System.Threading.Tasks;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Server {
public sealed class BadHistoryRead {
public async Task DoIt(IHistoryProvider provider) {
_ = await provider.ReadRawAsync("tag", DateTime.MinValue, DateTime.MaxValue, 100, CancellationToken.None);
}
}
}
""";
var diags = await Compile(userSrc);
diags.Length.ShouldBe(1);
diags[0].GetMessage().ShouldContain("ReadRawAsync");
}
/// <summary>Verifies that a direct ReadProcessedAsync call on an interface trips the diagnostic.</summary>
[Fact]
public async Task Direct_ReadProcessedAsync_OnInterface_TripsDiagnostic()
{
const string userSrc = """
using System;
using System.Threading;
using System.Threading.Tasks;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Server {
public sealed class BadHistoryProcessed {
public async Task DoIt(IHistoryProvider provider) {
_ = await provider.ReadProcessedAsync("tag", DateTime.MinValue, DateTime.MaxValue, TimeSpan.FromMinutes(1), CancellationToken.None);
}
}
}
""";
var diags = await Compile(userSrc);
diags.Length.ShouldBe(1);
diags[0].GetMessage().ShouldContain("ReadProcessedAsync");
}
/// <summary>Verifies that a direct ReadAtTimeAsync call on an interface trips the diagnostic.</summary>
[Fact]
public async Task Direct_ReadAtTimeAsync_OnInterface_TripsDiagnostic()
{
// ReadAtTimeAsync is a default interface method — the analyzer must still flag it when
// called through an interface-typed receiver.
const string userSrc = """
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Server {
public sealed class BadHistoryAtTime {
public async Task DoIt(IHistoryProvider provider) {
_ = await provider.ReadAtTimeAsync("tag", new List<DateTime>(), CancellationToken.None);
}
}
}
""";
var diags = await Compile(userSrc);
diags.Length.ShouldBe(1);
diags[0].GetMessage().ShouldContain("ReadAtTimeAsync");
}
/// <summary>Verifies that a wrapped ReadRawAsync call inside a CapabilityInvoker lambda passes cleanly.</summary>
[Fact]
public async Task Wrapped_ReadRawAsync_InsideCapabilityInvokerLambda_PassesCleanly()
{
const string userSrc = """
using System;
using System.Threading;
using System.Threading.Tasks;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
namespace ZB.MOM.WW.OtOpcUa.Server {
public sealed class GoodHistoryRead {
public async Task DoIt(IHistoryProvider provider, CapabilityInvoker invoker) {
_ = await invoker.ExecuteAsync(DriverCapability.Read, "h1",
async ct => await provider.ReadRawAsync("tag", DateTime.MinValue, DateTime.MaxValue, 100, ct),
CancellationToken.None);
}
}
}
""";
var diags = await Compile(userSrc);
diags.ShouldBeEmpty();
}
// -----------------------------------------------------------------------
// IHistoryProvider — concrete driver receiver (FindImplementationForInterfaceMember branch)
// -----------------------------------------------------------------------
/// <summary>Verifies that a direct HistoryRead call on a concrete driver trips the diagnostic.</summary>
[Fact]
public async Task Direct_HistoryRead_OnConcreteDriver_TripsDiagnostic()
{
// ConcreteHistoryDriver implements IHistoryProvider with explicitly-named methods
// (same names here, but the test exercises the FindImplementationForInterfaceMember
// branch because the receiver type is the concrete class, not the interface).
const string userSrc = """
using System;
using System.Threading;
using System.Threading.Tasks;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Server {
public sealed class ConcreteHistoryDriver : IHistoryProvider {
public Task<HistoryReadResult> ReadRawAsync(string r, DateTime s, DateTime e, uint m, CancellationToken ct) => throw null!;
public Task<HistoryReadResult> ReadProcessedAsync(string r, DateTime s, DateTime e, TimeSpan i, CancellationToken ct) => throw null!;
}
public sealed class BadConcreteHistoryRead {
public async Task DoIt(ConcreteHistoryDriver driver) {
_ = await driver.ReadRawAsync("tag", DateTime.MinValue, DateTime.MaxValue, 100, CancellationToken.None);
}
}
}
""";
var diags = await Compile(userSrc);
diags.Length.ShouldBe(1);
diags[0].GetMessage().ShouldContain("ReadRawAsync");
}
// -----------------------------------------------------------------------
// IHostConnectivityProbe — synchronous member must NOT be flagged
// -----------------------------------------------------------------------
@@ -739,139 +594,9 @@ namespace SomeOther {
diags.ShouldBeEmpty();
}
// =======================================================================
// Analyzers-005 — IHistoryProvider default-interface-method asymmetry
// =======================================================================
/// <summary>Verifies that a direct ReadAtTimeAsync call on a concrete driver inheriting the DIM trips the diagnostic.</summary>
[Fact]
public async Task Direct_ReadAtTimeAsync_OnConcreteDriverInheritingDIM_TripsDiagnostic()
{
// ConcreteHistoryDriver does NOT override ReadAtTimeAsync (it inherits the DIM).
// The call site has a concrete-receiver type — the analyzer must still flag the call
// because the bound method (ReadAtTimeAsync) is the interface's own default impl.
const string userSrc = """
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Server {
public sealed class PartialHistoryDriver : IHistoryProvider {
public Task<HistoryReadResult> ReadRawAsync(string r, DateTime s, DateTime e, uint m, CancellationToken ct) => throw null!;
public Task<HistoryReadResult> ReadProcessedAsync(string r, DateTime s, DateTime e, TimeSpan i, CancellationToken ct) => throw null!;
// ReadAtTimeAsync + ReadEventsAsync NOT overridden — driver inherits the DIM throws.
}
public sealed class BadPartialHistoryCaller {
public async Task DoIt(PartialHistoryDriver driver) {
// Cast through the interface so the binder resolves to IHistoryProvider.ReadAtTimeAsync
// (the DIM). FindImplementationForInterfaceMember returns the interface method itself
// when nothing overrides it.
var iface = (IHistoryProvider)driver;
_ = await iface.ReadAtTimeAsync("tag", new List<DateTime>(), CancellationToken.None);
}
}
}
""";
var diags = await Compile(userSrc);
diags.Length.ShouldBe(1);
diags[0].GetMessage().ShouldContain("ReadAtTimeAsync");
}
/// <summary>Verifies that a direct ReadEventsAsync call on a concrete driver inheriting the DIM trips the diagnostic.</summary>
[Fact]
public async Task Direct_ReadEventsAsync_OnConcreteDriverInheritingDIM_TripsDiagnostic()
{
// Same as above but for ReadEventsAsync — confirms both DIMs are caught when the
// driver doesn't override them.
const string userSrc = """
using System;
using System.Threading;
using System.Threading.Tasks;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Server {
public sealed class PartialHistoryDriver2 : IHistoryProvider {
public Task<HistoryReadResult> ReadRawAsync(string r, DateTime s, DateTime e, uint m, CancellationToken ct) => throw null!;
public Task<HistoryReadResult> ReadProcessedAsync(string r, DateTime s, DateTime e, TimeSpan i, CancellationToken ct) => throw null!;
}
public sealed class BadPartialHistoryEventsCaller {
public async Task DoIt(PartialHistoryDriver2 driver) {
var iface = (IHistoryProvider)driver;
_ = await iface.ReadEventsAsync(null, DateTime.MinValue, DateTime.MaxValue, 100, CancellationToken.None);
}
}
}
""";
var diags = await Compile(userSrc);
diags.Length.ShouldBe(1);
diags[0].GetMessage().ShouldContain("ReadEventsAsync");
}
/// <summary>Verifies that a direct ReadAtTimeAsync call on a concrete driver overriding the DIM trips the diagnostic.</summary>
[Fact]
public async Task Direct_ReadAtTimeAsync_OnConcreteDriverOverridingDIM_TripsDiagnostic()
{
// Driver explicitly overrides ReadAtTimeAsync — the concrete-receiver call goes through
// the override; FindImplementationForInterfaceMember returns the override method, which
// SymbolEqualityComparer matches against method.
const string userSrc = """
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Server {
public sealed class FullHistoryDriver : IHistoryProvider {
public Task<HistoryReadResult> ReadRawAsync(string r, DateTime s, DateTime e, uint m, CancellationToken ct) => throw null!;
public Task<HistoryReadResult> ReadProcessedAsync(string r, DateTime s, DateTime e, TimeSpan i, CancellationToken ct) => throw null!;
public Task<HistoryReadResult> ReadAtTimeAsync(string r, IReadOnlyList<DateTime> ts, CancellationToken ct) => throw null!;
}
public sealed class BadFullHistoryCaller {
public async Task DoIt(FullHistoryDriver driver) {
_ = await driver.ReadAtTimeAsync("tag", new List<DateTime>(), CancellationToken.None);
}
}
}
""";
var diags = await Compile(userSrc);
diags.Length.ShouldBe(1);
diags[0].GetMessage().ShouldContain("ReadAtTimeAsync");
}
/// <summary>Verifies that a wrapped ReadAtTimeAsync DIM call inside a CapabilityInvoker lambda passes cleanly.</summary>
[Fact]
public async Task Wrapped_ReadAtTimeAsync_DIM_InsideCapabilityInvokerLambda_PassesCleanly()
{
// DIM wrapped properly — must not trip.
const string userSrc = """
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
namespace ZB.MOM.WW.OtOpcUa.Server {
public sealed class GoodHistoryAtTime {
public async Task DoIt(IHistoryProvider provider, CapabilityInvoker invoker) {
_ = await invoker.ExecuteAsync(DriverCapability.Read, "h1",
async ct => await provider.ReadAtTimeAsync("tag", new List<DateTime>(), ct),
CancellationToken.None);
}
}
}
""";
var diags = await Compile(userSrc);
diags.ShouldBeEmpty();
}
// =======================================================================
// Analyzers-008 — missing trip + pass-clean tests for ISubscribable.UnsubscribeAsync,
// IAlarmSource.UnsubscribeAlarmsAsync, IAlarmSource.AcknowledgeAsync, and the wrapped
// (pass-clean) path for IHistoryProvider.ReadEventsAsync (DIM).
// IAlarmSource.UnsubscribeAlarmsAsync, IAlarmSource.AcknowledgeAsync.
// =======================================================================
/// <summary>Verifies that a direct UnsubscribeAsync call trips the diagnostic.</summary>
@@ -965,32 +690,6 @@ namespace ZB.MOM.WW.OtOpcUa.Server {
diags[0].GetMessage().ShouldContain("AcknowledgeAsync");
}
/// <summary>Verifies that a wrapped ReadEventsAsync DIM call inside a CapabilityInvoker lambda passes cleanly.</summary>
[Fact]
public async Task Wrapped_ReadEventsAsync_DIM_InsideCapabilityInvokerLambda_PassesCleanly()
{
// ReadEventsAsync is a DIM — the wrapped path must not trip the diagnostic.
const string userSrc = """
using System;
using System.Threading;
using System.Threading.Tasks;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
namespace ZB.MOM.WW.OtOpcUa.Server {
public sealed class GoodHistoryEvents {
public async Task DoIt(IHistoryProvider provider, CapabilityInvoker invoker) {
_ = await invoker.ExecuteAsync(DriverCapability.Read, "h1",
async ct => await provider.ReadEventsAsync(null, DateTime.MinValue, DateTime.MaxValue, 0, ct),
CancellationToken.None);
}
}
}
""";
var diags = await Compile(userSrc);
diags.ShouldBeEmpty();
}
private static async Task<ImmutableArray<Diagnostic>> CompileWithoutStubs(string userSource)
{
var syntaxTrees = new[] { CSharpSyntaxTree.ParseText(userSource) };