From 767bc56d9753a8c595e99dffcd395f50183fafb1 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 18 Jun 2026 06:00:01 -0400 Subject: [PATCH] docs(plan): OpcUaClient ReadEventsAsync implementation plan + tasks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3 serial tasks (T1 pure cores → T2 wire-in → T3 smoke+docs+finish), all driver-internal. No interface/Commons/proto/EF change; no bUnit. --- .../2026-06-18-opcuaclient-read-events.md | 440 ++++++++++++++++++ ...6-18-opcuaclient-read-events.md.tasks.json | 13 + 2 files changed, 453 insertions(+) create mode 100644 docs/plans/2026-06-18-opcuaclient-read-events.md create mode 100644 docs/plans/2026-06-18-opcuaclient-read-events.md.tasks.json diff --git a/docs/plans/2026-06-18-opcuaclient-read-events.md b/docs/plans/2026-06-18-opcuaclient-read-events.md new file mode 100644 index 00000000..ad73eac9 --- /dev/null +++ b/docs/plans/2026-06-18-opcuaclient-read-events.md @@ -0,0 +1,440 @@ +# 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. diff --git a/docs/plans/2026-06-18-opcuaclient-read-events.md.tasks.json b/docs/plans/2026-06-18-opcuaclient-read-events.md.tasks.json new file mode 100644 index 00000000..53f11724 --- /dev/null +++ b/docs/plans/2026-06-18-opcuaclient-read-events.md.tasks.json @@ -0,0 +1,13 @@ +{ + "planPath": "docs/plans/2026-06-18-opcuaclient-read-events.md", + "designPath": "docs/plans/2026-06-18-opcuaclient-read-events-design.md", + "branch": "feat/opcuaclient-read-events", + "baseSha": "bd791e79", + "designCommit": "400bef47", + "tasks": [ + {"id": 534, "subject": "Task 1: Pure cores — BuildBaseEventFilter + MapHistoryEvents + unit tests", "classification": "standard", "status": "pending"}, + {"id": 535, "subject": "Task 2: ReadEventsAsync wire-in + remove stale comment + flip contract test", "classification": "standard", "status": "pending", "blockedBy": [534]}, + {"id": 536, "subject": "Task 3: Integration smoke + docs + build + live /run + finish", "classification": "small", "status": "pending", "blockedBy": [535]} + ], + "lastUpdated": "2026-06-18" +}