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
Seven-stage e2e script covering every Galaxy-specific capability surface:
IReadable + IWritable + ISubscribable + IAlarmSource + IHistoryProvider.
Unlike the other drivers there is no per-protocol CLI — Galaxy's proxy
lives in-process with the server + talks to OtOpcUaGalaxyHost over a
named pipe (MXAccess COM is 32-bit-only), so every stage runs through
`otopcua-cli` against the published OPC UA address space.
## Stages
1. Probe — otopcua-cli read on the source NodeId
2. Source read — capture value for downstream comparison
3. Virtual-tag bridge — Phase 7 VirtualTag (source × 2) through
CachedTagUpstreamSource
4. Subscribe-sees-change — data-change events propagate
5. Reverse bridge — opc-ua write → Galaxy; soft-passes if the
attribute's Galaxy-side ACL forbids writes
(`BadUserAccessDenied` / `BadNotWritable`)
6. Alarm fires — scripted-alarm Condition fires with Active
state when source crosses threshold
7. History read — historyread returns samples from the Aveva
Historian → IHistoryProvider path
## Two new helpers in _common.ps1
- `Test-AlarmFiresOnThreshold` — start `otopcua-cli alarms --refresh`
in the background on a Condition NodeId, drive the source change,
assert captured stdout contains `ALARM` + `Active`. Uses the same
Start-Process + temp-file pattern as `Test-SubscribeSeesChange` since
the alarms command runs until Ctrl+C (no built-in --duration).
- `Test-HistoryHasSamples` — call `otopcua-cli historyread` over a
configurable lookback window, parse `N values returned.` marker, fail
if below MinSamples. Works for driver-sourced, virtual, or scripted-
alarm historized nodes.
## Wiring
- `test-all.ps1` picks up the optional `galaxy` sidecar section and
runs the script with the configured NodeIds + wait windows.
- `e2e-config.sample.json` adds a `galaxy` section seeded with the
Phase 7 defaults (`p7-smoke-tag-source` / `-vt-derived` /
`-al-overtemp`) — matches `scripts/smoke/seed-phase-7-smoke.sql`.
- `scripts/e2e/README.md` expected-matrix gains a Galaxy row.
## Prereqs
- OtOpcUaGalaxyHost running (NSSM-wrapped) with the Galaxy + MXAccess
runtime available
- `seed-phase-7-smoke.sql` applied with a live Galaxy attribute
substituted into `dbo.Tag.TagConfig`
- OtOpcUa server running against the `p7-smoke` cluster
- Non-elevated shell (Galaxy.Host pipe ACL denies Admins)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaced the "ab_server PCCC upstream-broken" skip gate with the actual
root cause: libplctag's ab_server rejects empty CIP routing paths at the
unconnected-send layer before the PCCC dispatcher runs. Real SLC/
MicroLogix/PLC-5 hardware accepts empty paths (no backplane); ab_server
does not. With `/1,0` in place, N (Int16), F (Float32), and L (Int32)
file reads + writes round-trip cleanly across all three compose profiles.
## Fixture changes
- `AbLegacyServerFixture.cs`:
- Drop `AB_LEGACY_TRUST_WIRE` env var + the reachable-but-untrusted
skip branch. Fixture now only skips on TCP unreachability.
- Add `AB_LEGACY_CIP_PATH` env var (default `1,0`) + expose `CipPath`
property. Set `AB_LEGACY_CIP_PATH=` (empty) against real hardware.
- Shorter skip messages on the `[AbLegacyFact]` / `[AbLegacyTheory]`
attributes — one reason: endpoint not reachable.
- `AbLegacyReadSmokeTests.cs`:
- Device URI built from `sim.CipPath` instead of hardcoded empty path.
- New `AB_LEGACY_COMPOSE_PROFILE` env var filters the parametric
theory to the running container's family. Only one container binds
`:44818` at a time, so cross-family params would otherwise fail.
- `Slc500_write_then_read_round_trip` skips cleanly when the running
profile isn't `slc500`.
## E2E + seed + docs
- `scripts/e2e/test-ablegacy.ps1` — drop the `AB_LEGACY_TRUST_WIRE`
skip gate; synopsis calls out the `/1,0` vs empty cip-path split
between the Docker fixture and real hardware.
- `scripts/e2e/e2e-config.sample.json` — sample gateway flipped from
the hardware placeholder (`192.168.1.10`) to the Docker fixture
(`127.0.0.1/1,0`); comment rewritten.
- `scripts/e2e/README.md` — AB Legacy expected-matrix row goes from
SKIP to PASS.
- `scripts/smoke/seed-ablegacy-smoke.sql` — default HostAddress points
at the Docker fixture + header / footer text reflect the new state.
- `tests/.../Docker/README.md` — "Known limitations" section rewritten
to describe the cip-path gate (not a dispatcher gap); env-var table
picks up `AB_LEGACY_CIP_PATH` + `AB_LEGACY_COMPOSE_PROFILE`.
- `docs/drivers/AbLegacy-Test-Fixture.md` + `docs/drivers/README.md`
+ `docs/DriverClis.md` — flip status from blocked to functional;
residual bit-file-write gap (B3:0/5 → 0x803D0000) documented.
## Residual gap
Bit-file writes (`B3:0/5` style) surface `0x803D0000` against
`ab_server --plc=SLC500`; bit reads work. Non-blocking for smoke
coverage — N/F/L round-trip is enough. Real hardware / RSEmulate 500
for bit-write fidelity. Documented in `Docker/README.md` §"Known
limitations" + the `AbLegacy-Test-Fixture.md` follow-ups list.
## Verified
- Full-solution build: 0 errors, 334 pre-existing warnings.
- Integration suite passes per-profile with
`AB_LEGACY_COMPOSE_PROFILE=<slc500|micrologix|plc5>` + matching
compose container up.
- All four non-hardware e2e scripts (Modbus / AB CIP / AB Legacy / S7)
now 5/5 against the respective docker-compose fixtures.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Running `test-all.ps1` end-to-end with a partial sidecar (only modbus/
abcip/s7 populated, no focas/twincat/phase7) crashed:
[FAIL] modbus runner crashed: The property 'opcUaUrl' cannot be
found on this object. Verify that the property exists.
Root cause: `_common.ps1` sets `Set-StrictMode -Version 3.0`, which
turns missing-property access on PSCustomObject into a throw. Every
`$config.<driver>.<optional-field> ?? $default` and `if
($config.<missing-section>)` check is therefore unsafe against a
normal JSON where optional fields are omitted.
Fix: switch to `ConvertFrom-Json -AsHashtable` and add a `Get-Or`
helper. Hashtables tolerate `.ContainsKey()` / indexer access even
under StrictMode, so the per-driver sections now read:
$modbus = Get-Or $config "modbus"
if ($modbus) {
... -OpcUaUrl (Get-Or $modbus "opcUaUrl" $OpcUaUrl) ...
}
Verified end-to-end with live docker-compose fixtures:
- Modbus / AB CIP / S7 each run to completion, report 2/5 PASS (the
driver-only stages) and FAIL the 3 server-bridge stages (expected —
server-side factory wiring is blocked on #209).
- The FINAL MATRIX header renders cleanly with SKIP rows for the
drivers not present in the sidecar + FAIL rows for the present ones.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ran the driver CLIs against the live docker-compose fixtures to debug
what actually works. Two real bugs surfaced:
1. `e2e-config.sample.json` pointed at the wrong simulator ports:
- Modbus: 5502 → **5020** (pymodbus compose binding)
- S7: 102 → **1102** (python-snap7, non-priv port)
- AbCip: no port → now explicit **:44818**
`test-modbus.ps1` default `-ModbusHost` also shipped with 5502;
fixed to 5020.
2. Modbus loopback was off-by-2 because pymodbus `standard.json` makes
HR[100] an auto-increment register (value ticks on every poll).
Switched `test-modbus.ps1` to **HR[200]** (scratch range in the
profile) + updated sample config `bridgeNodeId` to match.
Also fixed the AbCip probe: `-t @raw_cpu_type` was rejected by the
driver's TagPath parser ("malformed TagPath"). Probe now uses the
user-supplied `-TagPath` for every family. Works against both
ab_server and real controllers.
Verified driver-side against live containers:
- Modbus 5020: probe ✓, HR[200] write+read round-trip ✓
- AB CIP 44818: probe ✓, TestDINT write+read round-trip ✓
- S7 1102: probe ✓, DB1.DBW0 write+read round-trip ✓
## Known blocker (stages 3-5)
README now flags — and the 4 child issues under umbrella #209 track —
that `src/ZB.MOM.WW.OtOpcUa.Server/Program.cs:98-104` only registers
Galaxy + FOCAS driver factories. `DriverInstanceBootstrapper` silently
skips any `DriverType` without a registered factory, so stages 3-5
(anything crossing the OPC UA server) can't work today even with a
perfect Config DB seed. Issues #210-213 scope the factory + seed SQL
work per driver.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The original three-stage design (probe / driver-loopback / forward-
bridge) only proved driver-write → server-read. It missed:
- OPC UA write → server → driver → PLC (the reverse direction)
- server-side data-change notifications actually firing (a stale
subscription can still let a read-after-the-fact return the new
value and look fine)
Extend _common.ps1 with two helpers:
- Test-OpcUaWriteBridge: otopcua-cli write the NodeId -> wait 3s ->
driver CLI read the PLC side, assert equality.
- Test-SubscribeSeesChange: Start-Process otopcua-cli subscribe in the
background with --duration N, settle 2s, driver-side write, wait for
the subscription window to close, assert captured stdout contains
the new value.
Wire both into test-modbus / test-abcip / test-ablegacy / test-s7 /
test-focas / test-twincat after the existing forward-bridge stage.
Update README to describe the five-stage design + note that the
published NodeId must be writable for stages 4 + 5.
Also prepend UTF-8 BOM to every script in scripts/e2e so Windows
PowerShell 5.1 parsers agree on em-dash byte sequences the way
PowerShell 7 already does. The scripts still #Requires -Version 7.0 —
the BOM is purely defensive for IDE / CI step parsers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The driver-layer integration tests confirm the driver sees the PLC, and
the Client.CLI tests confirm the client sees the server. Nothing glued
them end-to-end until this PR.
- scripts/e2e/_common.ps1: shared helpers — CLI invocation (published-
binary OR `dotnet run` fallback), Test-Probe / Test-DriverLoopback /
Test-ServerBridge (all return @{Passed;Reason} hashtables).
- scripts/e2e/test-<modbus|abcip|ablegacy|s7|focas|twincat>.ps1: per-
driver three-stage script (probe → driver-loopback → server-bridge).
AB Legacy / FOCAS / TwinCAT are gated behind *_TRUST_WIRE env vars
since they need real hardware (#222) or a licensed runtime (#221).
- scripts/e2e/test-phase7-virtualtags.ps1: writes a Modbus HR, reads
the server-side VirtualTag (VT = input * 2) back via OPC UA, triggers
+ clears a scripted alarm. Exercises the Phase 7 CachedTagUpstreamSource
+ ScriptedAlarmEngine path.
- scripts/e2e/test-all.ps1: reads e2e-config.json sidecar, runs each
present driver, prints a FINAL MATRIX (PASS/FAIL/SKIP). Missing
sections SKIP rather than fail hard.
- scripts/e2e/e2e-config.sample.json: commented sample — each dev's
NodeIds are local-seed-specific so e2e-config.json is .gitignore-d.
- scripts/e2e/README.md: full walkthrough — prereqs, three-stage design,
env-var gates, expected matrix, why this is separate from `dotnet test`.
Tasks #249-#251 shipped Modbus/AbCip/AbLegacy/S7/TwinCAT CLIs but left
FOCAS out. Since test-focas.ps1 needs it, the 6th CLI ships here:
- src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli: probe/read/write/subscribe
commands, AssemblyName `otopcua-focas-cli`. WriteCommand.ParseValue
handles the full FocasDataType enum (Bit/Byte/Int16/Int32/Float32/
Float64/String — no UInt variants; the FOCAS protocol exposes signed
PMC + Fanuc-Float only). Default DataType is Int16 to match the PMC
register convention.
Full-solution build clean (0 errors). FOCAS CLI wired into
ZB.MOM.WW.OtOpcUa.slnx. No .Tests project for the FOCAS CLI yet —
symmetric with how ProbeCommand has no unit-testable pure logic in the
other 5 CLIs either; WriteCommand.ParseValue parity will land in a
follow-up to keep this PR scoped.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>