# OpcUaClient `ReadEventsAsync` event-history passthrough — Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task. **Goal:** Implement the optional `IHistoryProvider.ReadEventsAsync` on `OpcUaClientDriver` so it forwards OPC UA HistoryReadEvents to its upstream server, completing the driver's history-provider surface. **Architecture:** Driver-internal only. The driver builds a fixed canonical 6-clause `EventFilter` and maps the upstream `HistoryEvent` onto the existing `HistoricalEvent` record (mirrors `WonderwareHistorianClient.ReadEventsAsync`). The pure logic (`BuildBaseEventFilter`, `MapHistoryEvents`) is `internal static` and unit-tested offline; the thin `Session.HistoryReadAsync` wire glue is covered by a without-init unit test + a skip-gated opc-plc integration smoke. NO `IHistoryProvider`/Core.Abstractions/Commons/proto/EF change. NO bUnit. **Tech Stack:** C#/.NET 10, OPC Foundation UA-.NETStandard SDK, xUnit + Shouldly. **Design doc:** `docs/plans/2026-06-18-opcuaclient-read-events-design.md` (committed `400bef47`). **Branch:** `feat/opcuaclient-read-events` (off master `bd791e79`). **Hard rules:** stage by explicit path (never `git add .`); never stage `sql_login.txt`, `src/Server/.../pki/`, `pending.md`, `stillpending.md`, `docker-dev/docker-compose.yml`; no `--no-verify`; no force-push; NO EF migration / Commons / proto / interface change; NO bUnit. Run all build/test/rig commands with `dangerouslyDisableSandbox: true`. --- ## Reference: exact existing code **`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs`** - Class decl `:31`: `public sealed class OpcUaClientDriver : … IHistoryProvider, …` (already implements the interface). - Raw/Processed/AtTime passthrough `:1546-1664`, shared wire path `ExecuteHistoryReadAsync` `:1615-1664` — the pattern to mirror (`RequireSession()`, `TryParseNodeId(session, ref, out nodeId)`, `_gate.WaitAsync`, `session.HistoryReadAsync(requestHeader: null, historyReadDetails: …, timestampsToReturn: TimestampsToReturn.Both, releaseContinuationPoints: false, nodesToRead: …, ct: …)`, unwrap `r.HistoryData?.Body`, `r.ContinuationPoint is { Length: > 0 }`). - `:1668-1676`: `internal static NodeId MapAggregateToNodeId(...)` — the `internal static` pure-helper precedent the new helpers follow. - `:1678-1682`: the stale "ReadEventsAsync stays at the interface default … out of scope for this PR" comment — **delete in T2.** - Existing methods return `Core.Abstractions.HistoryReadResult` (fully qualified) — mirror with `Core.Abstractions.HistoricalEventsResult` / `Core.Abstractions.HistoricalEvent`. **`src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IHistoryProvider.cs`** — `ReadEventsAsync(string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, CancellationToken)` returns `HistoricalEventsResult`; `HistoricalEvent(string EventId, string? SourceName, DateTime EventTimeUtc, DateTime ReceivedTimeUtc, string? Message, ushort Severity)`. `maxEvents <= 0` = "backend default cap" sentinel. **`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientHistoryTests.cs`** — `using Opc.Ua; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions;`. Has the current `ReadEventsAsync_throws_NotSupportedException_as_documented` test (`:83-98`) — **replace in T2.** Direct driver-capability calls (e.g. `drv.ReadRawAsync`) already build, so the project already tolerates the `UnwrappedCapabilityCallAnalyzer` rule — follow that (no new suppression). **`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcUaClientSmokeTests.cs`** — `[Collection(OpcPlcCollection.Name)]`, ctor `(OpcPlcFixture sim)`, `if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);`, `OpcPlcProfile.BuildOptions(sim.EndpointUrl)`, `await drv.InitializeAsync("{}", ct)`. opc-plc runs with `--alm` (live alarm conditions, not a historian). --- ### Task 1: Pure cores — `BuildBaseEventFilter` + `MapHistoryEvents` + offline unit tests **Classification:** standard **Estimated implement time:** ~5 min **Parallelizable with:** none (T2 edits the same driver file) **Files:** - Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs` (add the two `internal static` helpers + private coercion helpers near the existing `MapAggregateToNodeId` at `:1666`; do NOT add `ReadEventsAsync` yet) - Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientHistoryTests.cs` (add new tests; leave the existing NotSupported test untouched in this task) **Step 1: Write the failing tests** (append to `OpcUaClientHistoryTests.cs`) ```csharp /// BuildBaseEventFilter emits the six canonical BaseEventType select clauses in order. [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]); } } /// MapHistoryEvents maps every field by its canonical select-clause index. [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); } /// MapHistoryEvents returns empty for a HistoryEvent with no rows. [Fact] public void MapHistoryEvents_with_no_events_returns_empty() { OpcUaClientDriver.MapHistoryEvents(new HistoryEvent()).ShouldBeEmpty(); } /// MapHistoryEvents tolerates a field list shorter than six without throwing. [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].Severity.ShouldBe((ushort)0); } ``` **Step 2: Run to verify they fail to compile** (`BuildBaseEventFilter`/`MapHistoryEvents` don't exist yet) Run: `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests --filter "FullyQualifiedName~OpcUaClientHistoryTests"` (`dangerouslyDisableSandbox: true`) Expected: BUILD FAILS — `OpcUaClientDriver` has no `BuildBaseEventFilter` / `MapHistoryEvents`. **Step 3: Implement the helpers** in `OpcUaClientDriver.cs`, immediately after `MapAggregateToNodeId` (around `:1676`, before the `:1678` comment): ```csharp // Canonical BaseEventType select-clause order — MapHistoryEvents maps by these indices. private static readonly string[] EventFieldBrowseNames = [ BrowseNames.EventId, // 0 BrowseNames.SourceName, // 1 BrowseNames.Time, // 2 BrowseNames.ReceiveTime, // 3 BrowseNames.Message, // 4 BrowseNames.Severity, // 5 ]; /// /// Builds the fixed canonical EventFilter the driver sends upstream for HistoryReadEvents — /// the six BaseEventType fields the OtOpcUa server projects (). /// The clause order is load-bearing: reads results by index. /// /// An EventFilter with six SimpleAttributeOperand value clauses. internal static EventFilter BuildBaseEventFilter() { var filter = new EventFilter(); foreach (var browseName in EventFieldBrowseNames) { filter.SelectClauses.Add(new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.BaseEventType, BrowsePath = new QualifiedNameCollection { new QualifiedName(browseName) }, AttributeId = Attributes.Value, }); } return filter; } /// /// Maps an upstream (field arrays ordered to match /// ) onto records. Defensive: /// short / null / wrong-typed fields degrade to null/default rather than throwing. /// /// The upstream history-event payload. /// The mapped historical events in upstream order. internal static IReadOnlyList MapHistoryEvents(HistoryEvent historyEvent) { if (historyEvent?.Events is not { Count: > 0 } rows) return []; var result = new List(rows.Count); foreach (var row in rows) { var fields = row?.EventFields; result.Add(new Core.Abstractions.HistoricalEvent( EventId: CoerceEventId(FieldAt(fields, 0)), SourceName: CoerceString(FieldAt(fields, 1)), EventTimeUtc: CoerceDateTime(FieldAt(fields, 2)), ReceivedTimeUtc: CoerceDateTime(FieldAt(fields, 3)), Message: CoerceString(FieldAt(fields, 4)), Severity: CoerceSeverity(FieldAt(fields, 5)))); } return result; } private static object? FieldAt(VariantCollection? fields, int index) => fields is not null && index < fields.Count ? fields[index].Value : null; private static string CoerceEventId(object? value) => value switch { byte[] bytes => Convert.ToBase64String(bytes), string s => s, null => string.Empty, _ => value.ToString() ?? string.Empty, }; private static string? CoerceString(object? value) => value switch { LocalizedText lt => lt.Text, string s => s, null => null, _ => value.ToString(), }; private static DateTime CoerceDateTime(object? value) => value is DateTime dt ? dt : DateTime.MinValue; 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; } } ``` Notes for the implementer: - These types are all in `Opc.Ua` (already `using`d by the driver): `EventFilter`, `SimpleAttributeOperand`, `ObjectTypeIds`, `Attributes`, `BrowseNames`, `QualifiedName`, `QualifiedNameCollection`, `HistoryEvent`, `HistoryEventFieldList`, `VariantCollection`, `Variant`, `LocalizedText`. - Fully-qualify `Core.Abstractions.HistoricalEvent` to match the existing `Core.Abstractions.HistoryReadResult` style and avoid any SDK name collision. **Step 4: Run the tests** — Run the same `dotnet test --filter` command. Expected: the 4 new tests PASS (the existing `ReadEventsAsync_throws_NotSupportedException_as_documented` still passes too — untouched). **Step 5: Commit** ```bash git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs \ tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientHistoryTests.cs git commit -m "feat(opcuaclient): add BuildBaseEventFilter + MapHistoryEvents pure cores" ``` --- ### Task 2: `ReadEventsAsync` wire-in + remove stale comment + flip contract test **Classification:** standard **Estimated implement time:** ~5 min **Parallelizable with:** none (depends on T1; edits the same driver + test files) **Files:** - Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs` (add the public `ReadEventsAsync`; delete the `:1678-1682` stale comment) - Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientHistoryTests.cs` (replace the NotSupported test) **Step 1: Replace the contract test.** Delete `ReadEventsAsync_throws_NotSupportedException_as_documented` (`:83-98`) and add in its place: ```csharp /// ReadEventsAsync requires an initialized session like the sibling history reads. [Fact] public async Task ReadEventsAsync_without_initialize_throws_InvalidOperationException() { using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-events-uninit"); await Should.ThrowAsync(async () => await drv.ReadEventsAsync( sourceName: null, startUtc: DateTime.UtcNow.AddMinutes(-5), endUtc: DateTime.UtcNow, maxEvents: 100, cancellationToken: TestContext.Current.CancellationToken)); } ``` **Step 2: Run to verify it fails** — Run: `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests --filter "FullyQualifiedName~ReadEventsAsync_without_initialize"` (`dangerouslyDisableSandbox: true`). Expected: FAIL — `ReadEventsAsync` still inherits the throwing default, so it throws `NotSupportedException`, not `InvalidOperationException` (RequireSession runs first only once the override exists). **Step 3: Implement `ReadEventsAsync`.** Delete the stale comment block at `:1678-1682` and add the method in its place (after the helpers from T1): ```csharp /// /// Forwards OPC UA HistoryReadEvents to the upstream server. Sends the fixed canonical /// and maps the result onto /// (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). /// /// /// Upstream event-notifier NodeId to read from (mirrors how fullReference is the /// upstream NodeId for raw reads). Null/empty → the upstream Server object (i=2253), /// the standard server-wide event notifier. An unparseable id short-circuits to an empty /// result, matching the raw path's malformed-NodeId behavior. /// /// Inclusive lower bound on event time (UTC). /// Exclusive upper bound on event time (UTC). /// Upper cap; <= 0 means "no cap" (NumValuesPerNode = 0). /// Request cancellation. /// The historical events plus the upstream continuation point (null when complete). public async Task ReadEventsAsync( string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, CancellationToken cancellationToken) { var session = RequireSession(); NodeId notifierNodeId; if (string.IsNullOrEmpty(sourceName)) { notifierNodeId = ObjectIds.Server; } else if (!TryParseNodeId(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 }, }; await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { 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(); } } ``` Notes: - `ReadEventDetails`, `HistoryReadValueIdCollection`, `HistoryReadValueId`, `ObjectIds`, `ExtensionObject`, `TimestampsToReturn` are all `Opc.Ua` (already in scope, used by the raw path). - `TryParseNodeId(ISession, string, out NodeId)` and `RequireSession()` are the existing private members (see `ExecuteHistoryReadAsync`); `_gate` is the existing `SemaphoreSlim`. - Verify `notifierNodeId` definite-assignment compiles (assigned in both reachable branches; the `else if` early-returns). **Step 4: Run the tests** Run: `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests --filter "FullyQualifiedName~OpcUaClientHistoryTests"` (`dangerouslyDisableSandbox: true`) Expected: ALL pass — including the new `ReadEventsAsync_without_initialize_throws_InvalidOperationException`. **Step 5: Commit** ```bash git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs \ tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientHistoryTests.cs git commit -m "feat(opcuaclient): implement IHistoryProvider.ReadEventsAsync passthrough" ``` --- ### Task 3: Integration smoke + docs + build + live `/run` + finish **Classification:** small **Estimated implement time:** ~4 min (implement); live-verify + finish run in the controller **Parallelizable with:** none (depends on T2) **Files:** - Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcUaClientSmokeTests.cs` (add one skip-gated smoke) - Modify: `docs/Historian.md` (add a short driver-passthrough note) **Step 1: Add the integration smoke** to `OpcUaClientSmokeTests.cs`: ```csharp /// /// Verifies HistoryReadEvents passthrough issues a well-formed request and returns a /// result without throwing. opc-plc exposes live alarm conditions (--alm) but is NOT a /// historian, so the upstream may return zero historical events or reject the service — /// either way the driver must produce a HistoricalEventsResult, never throw. This proves /// the wire request + unwrap path; a non-empty event list is infra-gated on an upstream /// that historizes events. /// [Fact] public async Task Client_reads_events_returns_result_without_throwing() { if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason); var options = OpcPlcProfile.BuildOptions(sim.EndpointUrl); await using var drv = new OpcUaClientDriver(options, driverInstanceId: "opcua-smoke-events"); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); var result = await drv.ReadEventsAsync( sourceName: null, // null → upstream Server object (i=2253) startUtc: DateTime.UtcNow.AddHours(-1), endUtc: DateTime.UtcNow, maxEvents: 100, cancellationToken: TestContext.Current.CancellationToken); result.ShouldNotBeNull(); result.Events.ShouldNotBeNull(); } ``` **Step 2: Build the integration test project** Run: `dotnet build tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests` (`dangerouslyDisableSandbox: true`) Expected: 0 errors. (The test is skip-gated; running it without the fixture skips.) **Step 3: Doc note.** In `docs/Historian.md`, find the section describing `IHistorianDataSource` vs the driver `IHistoryProvider` (or the HistoryRead overview) and add a short paragraph: the OpcUaClient driver's `IHistoryProvider` now forwards **all four** history reads (Raw / Processed / AtTime / **Events**) to its upstream server; for events it sends a fixed canonical BaseEventType `EventFilter` and maps the upstream `HistoryEvent` onto `HistoricalEvent` (the same six fields the server projects). Keep it to ~3 sentences. Distinguish it from the server-side single `IHistorianDataSource` backend. If `docs/Historian.md` has no natural spot, add the note where the OpcUaClient driver history passthrough is first mentioned; if it isn't mentioned at all, add a one-line bullet under the HistoryRead section. **Step 4: Commit** ```bash git add tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcUaClientSmokeTests.cs \ docs/Historian.md git commit -m "test(opcuaclient): event-history smoke + docs(historian): driver event passthrough" ``` **Step 5: Full driver-test build + run (controller).** Run: `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests` (`dangerouslyDisableSandbox: true`) Expected: green (existing + 5 new unit tests). **Step 6: Live `/run` (controller).** Bring up opc-plc and run the skip-gated integration smoke against it: ```bash # opc-plc reference sim — either the local integration-test compose or the shared 10.100.0.35:50000. docker compose -f tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/Docker/docker-compose.yml up -d # Point the fixture at the running sim; run the event smoke (and the existing smokes as a sanity check). dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests \ --filter "FullyQualifiedName~Client_reads_events_returns_result_without_throwing" \ # dangerouslyDisableSandbox: true (the rig is outside the Bash sandbox) ``` Expected: the event smoke PASSES (returns a non-null result without throwing) — proving the wire path. Honest caveat to record: a non-empty event list is infra-gated (opc-plc is not a historian). If the fixture self-skips because the sim env var isn't wired, fall back to the shared `OPCUA_SIM_ENDPOINT=opc.tcp://10.100.0.35:50000` and document the outcome. **Step 7: Finish — REQUIRED SUB-SKILL `superpowers-extended-cc:finishing-a-development-branch`.** Verify tests green, then **merge to master + push** (the standing finish choice). Stage only by path. After merge, update memory + the never-staged `stillpending.md` §A backlog line (item 2 struck through SHIPPED) — these are local bookkeeping, NOT committed. --- ## Execution notes - T1 → T2 → T3 strictly serial (T1/T2 edit the same driver file; T2 depends on T1; T3 depends on T2). No parallel dispatch. - After all three, a final integration code-review over `git diff 400bef47..HEAD` before finishing.