Adds a filter-aware overload of IHistoryProvider.ReadEventsAsync that carries EventFilter SelectClauses + WhereClause, and implements it on the OPC UA Client driver via Session.HistoryReadAsync + ReadEventDetails. The change is additive (default-impl returns NotSupportedException) so the existing Galaxy.Proxy.GalaxyProxyDriver implementation keeps compiling against the fixed-field overload — no cross-driver refactor required. * Core.Abstractions: new EventHistoryRequest / SimpleAttributeSpec / ContentFilterSpec records mirror the OPC UA wire shape transport-neutrally. HistoricalEventBatch / HistoricalEventRow carry an open-ended Fields bag keyed by SimpleAttributeSpec.FieldName so server-side dispatch can re-align with the client's wire-side SelectClause order. * OpcUaClient driver: new ReadEventsAsync(fullReference, EventHistoryRequest, ct) builds an EventFilter, calls Session.HistoryReadAsync, and unwraps HistoryEvent.Events into HistoricalEventBatch rows. Default SelectClause set matches BuildHistoryEvent on the server side. ContentFilter bytes are decoded through the live session's MessageContext (passthrough — the driver does not evaluate filters). * Unit tests: 7 new tests cover SelectClause translation, default-clause fallback, malformed where-clause swallowing, uninitialized-driver guard, null-request guard, and IHistoryProvider default fallback. * Integration scaffold: build-only [Fact] gated on opc-plc --alm; flips to green when the fixture image is upgraded. * Docs: HistoryRead Events section in docs/drivers/OpcUaClient.md plus a cross-link from Client.CLI.md historyread page. * E2E: -HistoryEvents switch on scripts/e2e/test-opcuaclient.ps1 confirms the gateway round-trips HistoryReadEvents without BadHistoryOperationUnsupported (gated; defaults to skip). Closes #284
E2E CLI test scripts
End-to-end black-box tests that drive each protocol through its driver CLI
and verify the resulting OPC UA address-space state through
otopcua-cli. They answer one question per driver:
If I poke the real PLC through the driver, does the running OtOpcUa server see the change?
This is the acceptance gate v1 was missing — the driver-level integration
tests (tests/.../IntegrationTests/) confirm the driver sees the PLC, and
the OPC UA Client.CLI.Tests confirm the client sees the server — but
nothing glued them end-to-end. These scripts close that loop.
Five-stage test per driver
Every per-driver script runs the same five tests. The goal is to prove
both directions across the bridge plus subscription delivery —
forward-only coverage would miss writable-flag drops, IWritable
dispatch bugs, and broken data-change notification paths where a fresh
read still returns the right value.
probe— driver CLI opens a session + reads a sentinel. Confirms the simulator / PLC is reachable and speaking the protocol.- Driver loopback — write a random value via the driver CLI, read it back via the same CLI. Confirms the driver round-trips without involving the OPC UA server. A failure here is a driver bug, not a server-bridge bug.
- Forward bridge (driver → server → client) — write a different
random value via the driver CLI, wait
--ServerPollDelaySec(default 3s), read the OPC UA NodeId the server publishes that tag at viaotopcua-cli read. Confirms reads propagate from PLC to OPC UA client. - Reverse bridge (client → server → driver) — write a fresh random
value via
otopcua-cli writeagainst the same NodeId, wait--DriverPollDelaySec(default 3s), read the PLC-side via the driver CLI. Confirms writes propagate the other way — catches writable-flag drops, ACL misconfiguration, andIWritabledispatch bugs the forward test can't see. - Subscribe-sees-change — start
otopcua-cli subscribe --duration Nin the background, give it--SettleSec(default 2s) to attach, write a random value via the driver CLI, wait for the subscription window to close, and assert the captured output mentions the new value. Confirms the server's monitored-item + data-change path actually fires — not just that a fresh read returns the new value.
The OtOpcUa server must already be running with a config that
(a) binds a driver instance to the same PLC the script points at, and
(b) publishes the address the script writes under a NodeId the script
knows. Those NodeIds live in e2e-config.json (see below). The
published tag must be writable — stages 4 + 5 will fail against a
read-only tag.
Status
Stages 1 + 2 (driver-side probe + loopback) are verified end-to-end against the pymodbus / ab_server / python-snap7 fixtures. Stages 3-5 (anything crossing the OtOpcUa server) are blocked on server-side driver factory wiring:
src/ZB.MOM.WW.OtOpcUa.Server/Program.csonly registers Galaxy + FOCAS factories.DriverInstanceBootstrapperskips anyDriverTypewithout a registered factory — so Modbus / AB CIP / AB Legacy / S7 / TwinCAT rows in the Config DB are silently no-op'd even when the seed is perfect.- No Config DB seed script exists for non-Galaxy drivers; Admin UI is currently the only path to author one.
Tracking: #209 (umbrella) → #210 (Modbus), #211 (AB CIP), #212 (S7), #213 (AB Legacy, also hardware-gated — #222). Each child issue lists the factory class to write + the seed SQL shape + the verification command.
Until those ship, stages 3-5 will fail with "read failed" (nothing
published at that NodeId) and [FAIL] the suite even on a running
server.
Prereqs
- OtOpcUa server running on
opc.tcp://localhost:4840(or pass-OpcUaUrlto override). The server's Config DB must define a driver instance per protocol you want to test, bound to the matching simulator endpoint. - Per-driver simulators running. See
docs/v2/test-data-sources.mdfor the simulator matrix — pymodbus / ab_server / python-snap7 / opc-plc cover Modbus / AB / S7 / OPC UA Client. FOCAS and TwinCAT have no public simulator; they are gated with env-var skip flags below. - PowerShell 7+. The runner uses null-coalescing +
Set-StrictMode; the Windows-PowerShell-5.1 shell will not parsetest-all.ps1. - .NET 10 SDK. Each script either runs
dotnet run --project src/ZB.MOM.WW.OtOpcUa.Driver.<Name>.Clidirectly, or if$env:OTOPCUA_CLI_BINpoints at a publish folder, runs the pre-builtotopcua-*.exefrom there (faster for repeat loops).
Running
One protocol at a time
./scripts/e2e/test-modbus.ps1 `
-ModbusHost 127.0.0.1:5502 `
-BridgeNodeId "ns=2;s=Modbus/HR100"
Every per-protocol script takes the driver endpoint, the address to write, and the OPC UA NodeId the server exposes it at.
Full matrix
./scripts/e2e/test-all.ps1 `
-ConfigFile ./scripts/e2e/e2e-config.json
The runner reads the sidecar JSON, invokes each driver's script with the
parameters from that section, and prints a FINAL MATRIX showing
PASS / FAIL / SKIP per driver. Any driver absent from the sidecar is
SKIP-ed rather than failing hard — useful on dev boxes that only have
one simulator up.
Sidecar format
Copy e2e-config.sample.json → e2e-config.json and fill in the
NodeIds from your server's Config DB. The file is .gitignore-d
(each dev's NodeIds are specific to their local seed). Omit a driver
section to skip it.
Expected pass/fail matrix (default config)
| Driver | Gate | Default state on a clean dev box |
|---|---|---|
| Modbus | — | PASS (pymodbus fixture) |
| AB CIP | — | PASS (ab_server fixture) |
| AB Legacy | — | PASS (ab_server SLC500/MicroLogix/PLC-5 profiles; /1,0 cip-path required for the Docker fixture) |
| Galaxy | — | PASS (requires OtOpcUaGalaxyHost + a live Galaxy; 7 stages including alarms + history) |
| S7 | — | PASS (python-snap7 fixture) |
| FOCAS | FOCAS_TRUST_WIRE=1 |
SKIP (no public simulator — task #222 lab rig) |
| TwinCAT | TWINCAT_TRUST_WIRE=1 |
SKIP (needs XAR or standalone Router — task #221) |
| Phase 7 | — | PASS if the Modbus instance seeds a VT_DoubledHR100 virtual tag + AlarmHigh scripted alarm |
Set the *_TRUST_WIRE env vars to 1 when you've pointed the script at
real hardware or a properly-configured simulator.
Output
Each step prints one of:
[PASS] ...— step succeeded[FAIL] ...— step failed, stdout of the failing CLI is echoed below for diagnosis[SKIP] ...— step short-circuited (env-var gate)[INFO] ...— progress note (e.g., "waiting 3s for server-side poll")
The runner ends with a coloured summary per driver:
==================== FINAL MATRIX ====================
modbus PASS
abcip PASS
ablegacy SKIP (no config entry)
s7 PASS
focas SKIP (no config entry)
twincat SKIP (no config entry)
phase7 PASS
All present suites passed.
Non-zero exit if any present suite failed. SKIPs do not fail the run.
Why this is separate from dotnet test
dotnet test covers driver-layer + server-layer correctness in
isolation — mocks + in-process test hosts. These e2e scripts cover the
integration seam that unit tests can't cover by design: a live OPC UA
server process, a live simulator, and the wire between them. Run them
before a v2 release-readiness sign-off, after a driver-layer change
that could plausibly affect the NodeManager contract, and before any
"it works on my box" handoff to QA.