# OpcUaClient `ReadEventsAsync` event-history passthrough — Design **Date:** 2026-06-18 **Branch:** `feat/opcuaclient-read-events` (off master) **Backlog item:** `stillpending.md` §A / `project_stillpending_backlog` item 2 — "OpcUaClient `ReadEventsAsync` event-history passthrough." ## Goal Implement the optional `IHistoryProvider.ReadEventsAsync` on `OpcUaClientDriver` so the driver forwards OPC UA **HistoryReadEvents** to its upstream server — completing the driver's history-provider surface (Raw / Processed / AtTime already passthrough; Events was the lone unimplemented method, inheriting the throwing default-interface body). ## Key finding (scope correction) The backlog framed this as "needs an `IHistoryProvider.ReadEventsAsync` filter-spec/EventFilter param extension." That is **overcautious and not required.** The interface returns a **fixed 6-field `HistoricalEvent`** record (`EventId, SourceName, EventTimeUtc, ReceivedTimeUtc, Message, Severity`), and the OtOpcUa server's `OtOpcUaNodeManager.HistoryReadEvents` override **re-projects only those 6 `BaseEventType` fields** (`ProjectEventField`) — a richer SelectClause set would be discarded server-side. So the driver builds its **own canonical `EventFilter` internally** and maps the upstream result onto the existing `HistoricalEvent` record, exactly as `WonderwareHistorianClient.ReadEventsAsync` already does. **Decision (user-approved):** driver-internal implementation, **NO** `IHistoryProvider` / Core.Abstractions / Commons contract change. ## Architecture `OpcUaClientDriver` already implements `IHistoryProvider` and forwards Raw/Processed/AtTime through a shared wire path (`ExecuteHistoryReadAsync` → `Session.HistoryReadAsync`). Events needs a sibling path because the request details type differs (`ReadEventDetails` carrying an `EventFilter`) and the response payload differs (`HistoryEvent` whose `Events` are `HistoryEventFieldList` arrays, ordered to match the SelectClauses the driver sent) and the return type differs (`HistoricalEventsResult`, not `HistoryReadResult`). The two duplicated history seams stay distinct and unchanged: - **`IHistoryProvider`** = a **driver capability** (OpcUaClient → upstream server). This is what we extend. - **`IHistorianDataSource`** = the **server-side** HistoryRead backend (single registered Wonderware/Null source the OtOpcUa node-manager dispatches to). **Untouched.** Note the driver's `IHistoryProvider` is not currently bridged into the OtOpcUa server's HistoryRead service — wiring a driver as a server history backend is a separate, larger concern and is out of scope. This item completes the driver capability surface. ## Components (all in `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs`) 1. **`internal static EventFilter BuildBaseEventFilter()`** — 6 `SimpleAttributeOperand` SelectClauses on `ObjectTypeIds.BaseEventType` (`AttributeId = Attributes.Value`), in the fixed canonical order the mapper relies on: `EventId, SourceName, Time, ReceiveTime, Message, Severity` (via `BrowseNames.*`). 2. **`internal static IReadOnlyList MapHistoryEvents(HistoryEvent he)`** — maps each `HistoryEventFieldList.EventFields[i]` by index → `HistoricalEvent`, with defensive per-field coercion: - `[0] EventId` — upstream `byte[]` (ByteString) → base64 string (stable/unique; the field is opaque "driver-specific format" per the record's XML doc). String passthrough if the upstream already returned a string. - `[1] SourceName` — string (null-safe). - `[2] Time` → `EventTimeUtc` (DateTime). - `[3] ReceiveTime` → `ReceivedTimeUtc` (DateTime). - `[4] Message` — `LocalizedText` → `.Text` (or string). - `[5] Severity` → `ushort` (safe convert). - Field-lists shorter than 6 degrade gracefully (missing fields → null/default), never throw. - Empty `he.Events` → empty list. 3. **`public Task ReadEventsAsync(string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, CancellationToken ct)`** (replaces the inherited throwing default): - Resolve the upstream notifier NodeId: `sourceName` null/empty → `ObjectIds.Server` (i=2253, the standard server-wide event notifier); else `TryParseNodeId(session, sourceName, out nodeId)` — parse-fail → empty result (mirrors the raw path's malformed- NodeId short-circuit). `sourceName` is the upstream notifier NodeId, mirroring how `fullReference` is the upstream NodeId for raw reads. - Build `ReadEventDetails { StartTime = startUtc, EndTime = endUtc, NumValuesPerNode = maxEvents <= 0 ? 0u : (uint)maxEvents, Filter = BuildBaseEventFilter() }` (the `maxEvents <= 0` → 0 "no cap" sentinel honored per the interface's documented convention; OPC UA treats `NumValuesPerNode = 0` as unbounded). - Call `session.HistoryReadAsync(ReadEventDetails)` under `_gate`; unwrap `r.HistoryData?.Body is HistoryEvent he` → `MapHistoryEvents(he)`; preserve the upstream continuation point (`r.ContinuationPoint`). - Remove the stale "out of scope for this PR" comment at `OpcUaClientDriver.cs:1678-1682`. ## Data flow ``` driver.ReadEventsAsync(sourceName, start, end, maxEvents) → BuildBaseEventFilter() (canonical 6-clause EventFilter) → Session.HistoryReadAsync(ReadEventDetails) (upstream HistoryReadEvents) → upstream HistoryEvent { Events: HistoryEventFieldList[] } → MapHistoryEvents() (index-mapped → HistoricalEvent[]) → HistoricalEventsResult(events, continuationPoint) ``` ## Error handling - No session → `InvalidOperationException` (via `RequireSession()`, same as the sibling reads). - Unparseable `sourceName` → empty result (mirrors raw's malformed-NodeId short-circuit). - Upstream rejects events (e.g. opc-plc is not a historian) → the upstream Bad status surfaces through the SDK; empty `Events` returns an empty list, never a throw. Cascading-quality rule: no status translation. ## Testing (NO bUnit; xUnit + Shouldly) Mirrors the existing history-test split: the **pure cores** are `internal static` and unit- tested offline (like `MapAggregateToNodeId`); the thin wire glue is covered by the without-init unit test + the skip-gated integration smoke (the driver exposes no in-process session-injection seam, so the unwrap glue is integration/live-proven — same as today's Raw/Processed path). - **Pure unit (offline, `OpcUaClientHistoryTests`):** - `BuildBaseEventFilter` → 6 clauses, canonical order, correct `BrowseNames` + `Value` attr + `BaseEventType` type-definition. - `MapHistoryEvents` → full 6-field map; EventId base64; short/empty field-list resilience; empty `Events` → empty list. - **Contract flip:** replace `ReadEventsAsync_throws_NotSupportedException_as_documented` with `ReadEventsAsync_without_initialize_throws_InvalidOperationException` (mirrors the sibling without-init tests). - **Integration smoke (skip-gated, `OpcUaClientSmokeTests`, opc-plc):** connect + `ReadEventsAsync` against the Server node returns a `HistoricalEventsResult` gracefully (no throw), proving the wire request is well-formed and handled. The unit + integration test projects already suppress the `UnwrappedCapabilityCallAnalyzer` rule for direct driver-capability calls (the existing smoke tests call `drv.ReadAsync` directly) — the new tests follow the same suppression; no new analyzer concern (the driver's own method is an implementation, not a caller). ## Live verification (honest) Live `/run` drives the skip-gated integration smoke against opc-plc (`--alm`): proves the driver issues HistoryReadEvents and handles the upstream verdict. A **non-empty** event list is **infra-gated** — opc-plc generates live alarm conditions for subscriptions but is **not** a historian, so it does not serve historical events; a non-empty proof needs an upstream OPC UA server that historizes events. Stated plainly (same class of caveat as the recent S7-Timer/Counter and TwinCAT-live items). ## Hard rules honored - NO Commons / proto / EF / `IHistoryProvider` contract change. - NO bUnit. NO `git add .` (stage by explicit path). Never stage `sql_login.txt`, `src/Server/.../pki/`, `pending.md`, `stillpending.md`, `docker-dev/docker-compose.yml`. - No force-push, no `--no-verify`. - Driver-internal only (`Driver.OpcUaClient` + its two test projects + docs). - Finish = merge to master + push. ## Slices (for the implementation plan) - **T1** — pure cores: `BuildBaseEventFilter` + `MapHistoryEvents` (+ field-coercion helpers) as `internal static`, with full offline unit tests (TDD). - **T2** — wire-in: `public ReadEventsAsync` using the pure cores + `Session.HistoryReadAsync`; remove the stale "out of scope" comment; replace the NotSupported contract test with the without-init test. - **T3** — integration smoke (skip-gated) + docs (driver comment region, `docs/Historian.md` driver-passthrough note, the backlog line) + build + tests + live `/run` + finish (merge+push).