3 serial tasks (T1 pure cores → T2 wire-in → T3 smoke+docs+finish), all driver-internal. No interface/Commons/proto/EF change; no bUnit.
23 KiB
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 pathExecuteHistoryReadAsync: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: …), unwrapr.HistoryData?.Body,r.ContinuationPoint is { Length: > 0 }). :1668-1676:internal static NodeId MapAggregateToNodeId(...)— theinternal staticpure-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 withCore.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 twointernal statichelpers + private coercion helpers near the existingMapAggregateToNodeIdat:1666; do NOT addReadEventsAsyncyet) - 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)
/// <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]);
}
}
/// <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].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):
// 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(),
};
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(alreadyusingd by the driver):EventFilter,SimpleAttributeOperand,ObjectTypeIds,Attributes,BrowseNames,QualifiedName,QualifiedNameCollection,HistoryEvent,HistoryEventFieldList,VariantCollection,Variant,LocalizedText. - Fully-qualify
Core.Abstractions.HistoricalEventto match the existingCore.Abstractions.HistoryReadResultstyle 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
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 publicReadEventsAsync; delete the:1678-1682stale 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:
/// <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));
}
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):
/// <summary>
/// Forwards OPC UA HistoryReadEvents to the upstream server. Sends the fixed canonical
/// <see cref="BuildBaseEventFilter"/> and maps the result onto <see cref="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).
/// </summary>
/// <param name="sourceName">
/// Upstream event-notifier NodeId to read from (mirrors how <c>fullReference</c> is the
/// upstream NodeId for raw reads). Null/empty → the upstream Server object (<c>i=2253</c>),
/// the standard server-wide event notifier. An unparseable id short-circuits to an empty
/// result, matching the raw path's malformed-NodeId behavior.
/// </param>
/// <param name="startUtc">Inclusive lower bound on event time (UTC).</param>
/// <param name="endUtc">Exclusive upper bound on event time (UTC).</param>
/// <param name="maxEvents">Upper cap; <c><= 0</c> means "no cap" (NumValuesPerNode = 0).</param>
/// <param name="cancellationToken">Request cancellation.</param>
/// <returns>The historical events plus the upstream continuation point (null when complete).</returns>
public async Task<Core.Abstractions.HistoricalEventsResult> 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,TimestampsToReturnare allOpc.Ua(already in scope, used by the raw path).TryParseNodeId(ISession, string, out NodeId)andRequireSession()are the existing private members (seeExecuteHistoryReadAsync);_gateis the existingSemaphoreSlim.- Verify
notifierNodeIddefinite-assignment compiles (assigned in both reachable branches; theelse ifearly-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
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:
/// <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();
}
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
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:
# 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..HEADbefore finishing.