Migration closes the FOCAS Tier-C architecture. OtOpcUa previously had
`Driver.FOCAS.Host` (NSSM-wrapped Windows service loading Fwlib64.dll via
P/Invoke) + `Driver.FOCAS.Shared` (MessagePack IPC contracts) + a C shim
DLL stand-in for unit tests. All of it is deleted; the driver is now a
single in-process managed assembly talking the FOCAS/2 Ethernet binary
protocol directly on TCP:8193.
Architecture
- Pure-managed `FocasWireClient` inlined at `src/.../Driver.FOCAS/Wire/`
(owner-imported — see Wire/FocasWireClient.cs for the full surface).
Opens two TCP sockets, runs the initiate handshake, serialises requests
on socket 2 through a semaphore, closes cleanly with PDU + socket
teardown. Both sync `IDisposable` and async `IAsyncDisposable`.
- `WireFocasClient` (same folder) adapts the wire client to OtOpcUa's
`IFocasClient` surface — fixed-tree reads, PARAM/MACRO/PMC addresses,
alarms. Writes return `BadNotWritable` by design — OtOpcUa is read-only
against FOCAS.
- `FocasDriverFactoryExtensions` now accepts `"Backend": "wire"` (default)
and `"Backend": "unimplemented"`. Legacy `ipc` and `fwlib` backends are
rejected at startup with a diagnostic pointing at the migration doc.
Deletions
- `src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/` — whole project + Ipc/,
Backend/, Stability/, Program.cs.
- `src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/` — Contracts/, FrameReader,
FrameWriter, whole project.
- `tests/...Driver.FOCAS.Host.Tests/` + `.Shared.Tests/` — whole projects.
- `src/.../Driver.FOCAS/FwlibNative.cs` + `FwlibFocasClient.cs` — 21
P/Invokes + 7 `Pack=1` marshalling structs + the Fwlib-backed
`IFocasClient` implementation.
- `src/.../Driver.FOCAS/Ipc/` + `Supervisor/` — IPC client wrapper +
Host-process supervisor (backoff, circuit breaker, heartbeat, post-
mortem reader, process launcher).
- `scripts/install/Install-FocasHost.ps1` — NSSM service installer.
- `tests/.../Driver.FOCAS.Tests/{IpcFocasClientTests, IpcLoopback,
FwlibNativeHelperTests, PostMortemReaderCompatibilityTests,
SupervisorTests, FocasDriverFactoryExtensionsTests}.cs` — tests that
exercised the retired surfaces.
- `tests/.../Driver.FOCAS.IntegrationTests/Shim/` — the zig-built C shim
DLL that masqueraded as Fwlib64.dll.
Solution changes
- `ZB.MOM.WW.OtOpcUa.slnx` drops the 4 retired project refs.
- `src/.../Driver.FOCAS.csproj` drops the Shared ProjectReference, adds
`Microsoft.Extensions.Logging.Abstractions` for the optional `ILogger`
hook in `FocasWireClient`.
- `src/.../Driver.FOCAS.Cli.csproj` drops the six `<Content Include>`
entries that copied `vendor/fanuc/*.dll` into the CLI bin. CLI now uses
`WireFocasClient` directly.
- `FocasDriver` default factory flips to `Wire.WireFocasClientFactory`.
Integration tests
- New `tests/.../Driver.FOCAS.IntegrationTests/` project covering fixed-
tree reads (identity, axes, dynamic, program, operation mode, timers,
spindle load + max RPM, servo meters), user-authored PARAM / MACRO /
PMC reads, `DiscoverAsync` emission, `SubscribeAsync` + `OnDataChange`,
`IAlarmSource` raise/clear transitions, and `ProbeAsync` /
`OnHostStatusChanged`. 9 e2e tests against the focas-mock fixture
(Docker container with the vendored Python mock's native FOCAS/2
Ethernet responder).
- `scripts/integration/run-focas.ps1` orchestrates compose up → tests →
compose down. Dropped the shim-build stage + DLL-copy step + the split
testhost workaround (the latter only existed because of native-DLL
lifecycle bugs the shim tripped).
- Docker compose collapses from 11 per-series services to one `focas-sim`
service. Tests seed per-series state via `mock_load_profile` at test
start.
- Vendored focas-mock snapshot refreshed to pick up upstream's native
FOCAS/2 Ethernet responder (was 660 lines, now 1018) — the
pre-refresh snapshot only spoke the JSON admin protocol.
Tests
- 145/145 unit tests in `Driver.FOCAS.Tests` pass (was 208 pre-deletion;
63 removed tests exercised the retired IPC/shim/supervisor/Fwlib
surfaces).
- 9/9 integration tests pass against the refreshed mock.
- `FocasScaffoldingTests.Unimplemented_factory_throws_on_Create…` updated
to assert the new diagnostic message pointing at
`docs/drivers/FOCAS.md` rather than the now-gone `Fwlib64.dll`.
Docs
- `docs/drivers/FOCAS.md` rewritten for the managed wire topology —
deployment collapses to one `"Backend": "wire"` config block, no
separate service, no DLL deployment, no pipe ACL.
- `docs/drivers/FOCAS-Test-Fixture.md` updated — single TCP probe skip
gate instead of TCP + shim probe; fewer moving parts.
- `docs/drivers/README.md` row for FOCAS reflects the Tier-A managed
topology (previously listed Tier-C + `Fwlib64.dll` P/Invoke).
- `docs/Driver.FOCAS.Cli.md` drops the Tier-C architecture-note section.
- `docs/v2/implementation/focas-isolation-plan.md` marked historical —
the plan it documents was executed then superseded by the wire client.
- `docs/v2/v2-release-readiness.md` re-audited 2026-04-24. Phase 5
driver complement closed. FOCAS change-log entry added.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.