diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx
index 2c74aa2..6c2b4cc 100644
--- a/ZB.MOM.WW.OtOpcUa.slnx
+++ b/ZB.MOM.WW.OtOpcUa.slnx
@@ -34,6 +34,7 @@
+
diff --git a/docs/drivers/AbLegacy-Test-Fixture.md b/docs/drivers/AbLegacy-Test-Fixture.md
new file mode 100644
index 0000000..ee24f14
--- /dev/null
+++ b/docs/drivers/AbLegacy-Test-Fixture.md
@@ -0,0 +1,97 @@
+# AB Legacy test fixture
+
+Coverage map + gap inventory for the AB Legacy (PCCC) driver — SLC 500 /
+MicroLogix / PLC-5 / LogixPccc-mode.
+
+**TL;DR: there is no integration fixture.** Everything runs through a
+`FakeAbLegacyTag` injected via `IAbLegacyTagFactory`. libplctag powers the
+real wire path but ships no in-process fake, and `ab_server` has no PCCC
+emulation either — so PCCC behavior against real hardware is trusted from
+field deployments, not from CI.
+
+## What the fixture is
+
+Nothing at the integration layer.
+`tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/` is unit-only, all tests
+tagged `[Trait("Category", "Unit")]`. The driver accepts
+`IAbLegacyTagFactory` via ctor DI; every test supplies a `FakeAbLegacyTag`.
+
+## What it actually covers (unit only)
+
+- `AbLegacyAddressTests` — PCCC address parsing for SLC / MicroLogix / PLC-5
+ / LogixPccc-mode (`N7:0`, `F8:12`, `B3:0/5`, etc.)
+- `AbLegacyCapabilityTests` — data type mapping, read-only enforcement
+- `AbLegacyReadWriteTests` — read + write happy + error paths against the fake
+- `AbLegacyBitRmwTests` — bit-within-DINT read-modify-write serialization via
+ per-parent `SemaphoreSlim` (mirrors the AB CIP + FOCAS PMC-bit pattern from #181)
+- `AbLegacyHostAndStatusTests` — probe + host-status transitions driven by
+ fake-returned statuses
+- `AbLegacyDriverTests` — `IDriver` lifecycle
+
+Capability surfaces whose contract is verified: `IDriver`, `IReadable`,
+`IWritable`, `ITagDiscovery`, `ISubscribable`, `IHostConnectivityProbe`,
+`IPerCallHostResolver`.
+
+## What it does NOT cover
+
+### 1. Wire-level PCCC
+
+No PCCC frame is sent by the test suite. libplctag's PCCC subset (DF1,
+ControlNet-over-EtherNet, PLC-5 native EtherNet) is untested here;
+driver-side correctness depends on libplctag being correct.
+
+### 2. Family-specific behavior
+
+- SLC 500 timeout + retry thresholds (SLC's comm module has known slow-response
+ edges) — unit fakes don't simulate timing.
+- MicroLogix 1100 / 1400 max-connection-count limits — not stressed.
+- PLC-5 native EtherNet connection setup (PCCC-encapsulated-in-CIP vs raw
+ CSPv4) — routing covered at parse level only.
+
+### 3. Multi-device routing
+
+`IPerCallHostResolver` contract is verified; real PCCC wire routing across
+multiple gateways is not.
+
+### 4. Alarms / history
+
+PCCC has no alarm object + no history object. Driver doesn't implement
+`IAlarmSource` or `IHistoryProvider` — no test coverage is the correct shape.
+
+### 5. File-type coverage
+
+PCCC has many file types (N, F, B, T, C, R, S, ST, A) — the parser tests
+cover the common ones but uncommon ones (`R` counters, `S` status files,
+`A` ASCII strings) have thin coverage.
+
+## When to trust AB Legacy tests, when to reach for a rig
+
+| Question | Unit tests | Real PLC |
+| --- | --- | --- |
+| "Does `N7:0/5` parse correctly?" | yes | - |
+| "Does bit-in-word RMW serialize concurrent writers?" | yes | yes |
+| "Does the driver lifecycle hang / crash?" | yes | yes |
+| "Does a real read against an SLC 500 return correct bytes?" | no | yes (required) |
+| "Does MicroLogix 1100 respect its connection-count cap?" | no | yes (required) |
+| "Do PLC-5 ST-files round-trip correctly?" | no | yes (required) |
+
+## Follow-up candidates
+
+1. **Nothing open-source** — libplctag's test suite runs against real
+ hardware; there is no public PCCC simulator comparable to `pymodbus` or
+ `ab_server`.
+2. **Lab rig** — cheapest path is a used SLC 5/05 or MicroLogix 1100 on a
+ dedicated network; the parts are end-of-life but still available. PLC-5
+ and LogixPccc-mode behavior require those specific controllers.
+3. **libplctag upstream test harness** — the project's own `tests/` folder
+ has PCCC cases we could try to adapt, but they assume specific hardware.
+
+AB Legacy is inherently a trust-the-library driver until someone stands up
+a rig.
+
+## Key fixture / config files
+
+- `tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/FakeAbLegacyTag.cs` —
+ in-process fake + factory
+- `src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs` — scope remarks
+ at the top of the file
diff --git a/docs/drivers/AbServer-Test-Fixture.md b/docs/drivers/AbServer-Test-Fixture.md
new file mode 100644
index 0000000..1581c3c
--- /dev/null
+++ b/docs/drivers/AbServer-Test-Fixture.md
@@ -0,0 +1,147 @@
+# ab_server test fixture
+
+Coverage map + gap inventory for the AB CIP integration fixture backed by
+libplctag's `ab_server` simulator.
+
+**TL;DR:** `ab_server` is a connectivity + atomic-read smoke harness for the AB
+CIP driver. It does **not** benchmark UDTs, alarms, or any family-specific
+quirk. UDT / alarm / quirk behavior is verified only by unit tests with
+`FakeAbCipTagRuntime`.
+
+## What the fixture is
+
+- **Binary**: `ab_server` / `ab_server.exe` from libplctag
+ ([libplctag/libplctag](https://github.com/libplctag/libplctag) +
+ [kyle-github/ab_server](https://github.com/kyle-github/ab_server), MIT).
+ Resolved off `PATH` by `AbServerFixture.LocateBinary`; tests skip via
+ `[AbServerFact]` / `[AbServerTheory]` when missing.
+- **Lifecycle**: `AbServerFixture` (`tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbServerFixture.cs`)
+ starts the simulator with a profile-specific `--plc` arg + `--tag` seeds,
+ waits ~500 ms, kills on `DisposeAsync`.
+- **Profiles**: `KnownProfiles.{ControlLogix, CompactLogix, Micro800, GuardLogix}`
+ in `AbServerProfile.cs`. Each composes a CLI arg list + seed-tag set; their
+ own `Notes` fields document the quirks called out below.
+- **Tests**: one smoke, `AbCipReadSmokeTests.Driver_reads_seeded_DInt_from_ab_server`,
+ parametrized over all four profiles.
+
+## What it actually covers
+
+- Read path: driver → libplctag → CIP-over-EtherNet/IP → simulator → back.
+- Atomic Logix types per seed: `DINT`, `REAL`, `BOOL`, `SINT`, `STRING`.
+- One `DINT[16]` array tag (ControlLogix profile only).
+- `--plc controllogix` and `--plc compactlogix` mode dispatch.
+- The skip-on-missing-binary behavior (`AbServerFactAttribute`) so a fresh
+ clone without the simulator stays green.
+
+## What it does NOT cover
+
+Each gap below is either stated explicitly in the profile's `Notes` field or
+inferable from the seed-tag set + smoke-test surface.
+
+### 1. UDTs / CIP Template Object (class 0x6C)
+
+ControlLogix profile `Notes`: *"ab_server lacks full UDT emulation."*
+
+Unverified against `ab_server`:
+
+- PR 6 structured read/write (`AbCipStructureMember` fan-out)
+- #179 Template Object shape reader (`CipTemplateObjectDecoder` + `FetchUdtShapeAsync`)
+- #194 whole-UDT read optimization (`AbCipUdtReadPlanner` +
+ `AbCipUdtMemberLayout` + the `ReadGroupAsync` path in `AbCipDriver`)
+
+Unit coverage: `AbCipFetchUdtShapeTests`, `CipTemplateObjectDecoderTests`,
+`AbCipUdtMemberLayoutTests`, `AbCipUdtReadPlannerTests`,
+`AbCipDriverWholeUdtReadTests` — all with golden Template-Object byte buffers
++ offset-keyed `FakeAbCipTag` values.
+
+### 2. ALMD / ALMA alarm projection (#177)
+
+Depends on the ALMD UDT shape, which `ab_server` cannot emulate. The
+`OnAlarmEvent` raise/clear path + ack-write semantics are not exercised
+end-to-end.
+
+Unit coverage: `AbCipAlarmProjectionTests` — fakes feed `InFaulted` /
+`Severity` via `ValuesByOffset` + assert the emitted `AlarmEventArgs`.
+
+### 3. Micro800 unconnected-only path
+
+Micro800 profile `Notes`: *"ab_server has no --plc micro800 — falls back to
+controllogix emulation."*
+
+The empty routing path + unconnected-session requirement (PR 11) is unit-tested
+but never challenged at the CIP wire level. Real Micro800 (2080-series) on a
+lab rig would be the authoritative benchmark.
+
+### 4. GuardLogix safety subsystem
+
+GuardLogix profile `Notes`: *"ab_server doesn't emulate the safety
+subsystem."*
+
+Only the `_S`-suffix naming classifier (PR 12, `SecurityClassification.ViewOnly`
+forced on safety tags) runs. Actual safety-partition write rejection — what
+happens when a non-safety write lands on a real `1756-L8xS` — is not exercised.
+
+### 5. CompactLogix narrow ConnectionSize cap
+
+CompactLogix profile `Notes`: *"ab_server lacks the narrower limit itself."*
+
+Driver-side `AbCipPlcFamilyProfile` caps `ConnectionSize` at the CompactLogix
+value per PR 10, but `ab_server` accepts whatever the client asks for — the
+cap's correctness is trusted from its unit test, never stressed against a
+simulator that rejects oversized requests.
+
+### 6. BOOL-within-DINT read-modify-write (#181)
+
+The `AbCipDriver.WriteBitInDIntAsync` RMW path + its per-parent `SemaphoreSlim`
+serialization is unit-tested only (`AbCipBoolInDIntRmwTests`). `ab_server`
+seeds a plain `TestBOOL` tag; the `.N` bit-within-DINT syntax that triggers
+the RMW path is not exercised end-to-end.
+
+### 7. Capability surfaces beyond read
+
+No smoke test for:
+
+- `IWritable.WriteAsync`
+- `ITagDiscovery.DiscoverAsync` (`@tags` walker)
+- `ISubscribable.SubscribeAsync` (poll-group engine)
+- `IHostConnectivityProbe` state transitions under wire failure
+- `IPerCallHostResolver` multi-device routing
+
+The driver implements all of these + they have unit coverage, but the only
+end-to-end path `ab_server` validates today is atomic `ReadAsync`.
+
+## When to trust ab_server, when to reach for a rig
+
+| Question | ab_server | Unit tests | Lab rig |
+| --- | --- | --- | --- |
+| "Does the driver talk CIP at all?" | yes | - | - |
+| "Is my atomic read path wired correctly?" | yes | yes | yes |
+| "Does whole-UDT grouping work?" | no | yes | yes (required) |
+| "Do ALMD alarms raise + clear?" | no | yes | yes (required) |
+| "Is Micro800 unconnected-only enforced wire-side?" | no (emulated as CLX) | partial | yes (required) |
+| "Does GuardLogix reject non-safety writes on safety tags?" | no | no | yes (required) |
+| "Does CompactLogix refuse oversized ConnectionSize?" | no | partial | yes (required) |
+| "Does BOOL-in-DINT RMW race against concurrent writers?" | no | yes | yes (stress) |
+
+## Follow-up candidates
+
+If integration-level UDT / alarm / quirk proof becomes a shipping gate, the
+options are roughly:
+
+1. **Extend `ab_server`** upstream — the project accepts PRs + already carries
+ a CIP framing layer that UDT emulation could plug into.
+2. **Swap in a richer simulator** — e.g.
+ [OpenPLC](https://www.openplcproject.com/) or pycomm3's test harness, if
+ either exposes UDTs over EtherNet/IP in a way libplctag can consume.
+3. **Stand up a lab rig** — physical `1756-L7x` / `5069-L3x` / `2080-LC30` /
+ `1756-L8xS` controllers running Rockwell Studio 5000 projects; wire into
+ CI via a self-hosted runner. The only path that covers safety + narrow
+ ConnectionSize + real ALMD execution.
+
+See also:
+
+- `tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbServerFixture.cs`
+- `tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbServerProfile.cs`
+- `tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbCipReadSmokeTests.cs`
+- `docs/v2/test-data-sources.md` §2 — the broader test-data-source picking
+ rationale this fixture slots into
diff --git a/docs/drivers/FOCAS-Test-Fixture.md b/docs/drivers/FOCAS-Test-Fixture.md
new file mode 100644
index 0000000..9ee4401
--- /dev/null
+++ b/docs/drivers/FOCAS-Test-Fixture.md
@@ -0,0 +1,95 @@
+# FOCAS test fixture
+
+Coverage map + gap inventory for the FANUC FOCAS2 CNC driver.
+
+**TL;DR: there is no integration fixture.** Every test uses a
+`FakeFocasClient` injected via `IFocasClientFactory`. Fanuc's FOCAS library
+(`Fwlib32.dll`) is closed-source proprietary with no public simulator;
+CNC-side behavior is trusted from field deployments.
+
+## What the fixture is
+
+Nothing at the integration layer.
+`tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/` is unit-only. The driver ships
+as Tier C (process-isolated) per `docs/v2/driver-stability.md` because the
+FANUC DLL has known crash modes; tests can't replicate those in-process.
+
+## What it actually covers (unit only)
+
+- `FocasCapabilityTests` — data-type mapping (PMC bit / word / float,
+ macro variable types, parameter types)
+- `FocasReadWriteTests` — read + write against the fake, FOCAS native status
+ → OPC UA StatusCode mapping
+- `FocasScaffoldingTests` — `IDriver` lifecycle + multi-device routing
+- `FocasPmcBitRmwTests` — PMC bit read-modify-write synchronization (per-byte
+ `SemaphoreSlim`, mirrors the AB / Modbus pattern from #181)
+- `FwlibNativeHelperTests` — `Focas32.dll` → `Fwlib32.dll` bridge validation
+ + P/Invoke signature validation
+
+Capability surfaces whose contract is verified: `IDriver`, `IReadable`,
+`IWritable`, `ITagDiscovery`, `ISubscribable`, `IHostConnectivityProbe`,
+`IPerCallHostResolver`.
+
+## What it does NOT cover
+
+### 1. FOCAS wire traffic
+
+No FOCAS TCP frame is sent. `Fwlib32.dll`'s TCP-to-FANUC-gateway exchange is
+closed-source; the driver trusts the P/Invoke layer per #193. Real CNC
+correctness is trusted from field deployments.
+
+### 2. Alarm / parameter-change callbacks
+
+FOCAS has no push model — the driver polls via the shared `PollGroupEngine`.
+There are no CNC-initiated callbacks to test; the absence is by design.
+
+### 3. Macro / ladder variable types
+
+FANUC has CNC-specific extensions (macro variables `#100-#999`, system
+variables `#1000-#5000`, PMC timers / counters / keep-relays) whose
+per-address semantics differ across 0i-F / 30i / 31i / 32i Series. Driver
+covers the common address shapes; per-model quirks are not stressed.
+
+### 4. Model-specific behavior
+
+- Alarm retention across power cycles (model-specific CNC behavior)
+- Parameter range enforcement (CNC rejects out-of-range writes)
+- MTB (machine tool builder) custom screens that expose non-standard data
+
+### 5. Tier-C process isolation behavior
+
+Per driver-stability.md, FOCAS should run process-isolated because
+`Fwlib32.dll` has documented crash modes. The test suite runs in-process +
+only exercises the happy path + mapped error codes — a native access
+violation from the DLL would take the test host down. The process-isolation
+path (similar to Galaxy's out-of-process Host) has been scoped but not
+implemented.
+
+## When to trust FOCAS tests, when to reach for a rig
+
+| Question | Unit tests | Real CNC |
+| --- | --- | --- |
+| "Does PMC address `R100.3` route to the right bit?" | yes | yes |
+| "Does the FANUC status → OPC UA StatusCode map cover every documented code?" | yes (contract) | yes |
+| "Does a real read against a 30i Series return correct bytes?" | no | yes (required) |
+| "Does `Fwlib32.dll` crash on concurrent reads?" | no | yes (stress) |
+| "Do macro variables round-trip across power cycles?" | no | yes (required) |
+
+## Follow-up candidates
+
+1. **Nothing public** — Fanuc's FOCAS Developer Kit ships an emulator DLL
+ but it's under NDA + tied to licensed dev-kit installations; can't
+ redistribute for CI.
+2. **Lab rig** — used FANUC 0i-F simulator controller (or a retired machine
+ tool) on a dedicated network; only path that covers real CNC behavior.
+3. **Process isolation first** — before trusting FOCAS in production at
+ scale, shipping the Tier-C out-of-process Host architecture (similar to
+ Galaxy) is higher value than a CI simulator.
+
+## Key fixture / config files
+
+- `tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs` —
+ in-process fake implementing `IFocasClient`
+- `src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs` — ctor takes
+ `IFocasClientFactory`
+- `docs/v2/driver-stability.md` — Tier C scope + process-isolation rationale
diff --git a/docs/drivers/Galaxy-Test-Fixture.md b/docs/drivers/Galaxy-Test-Fixture.md
new file mode 100644
index 0000000..53eb41f
--- /dev/null
+++ b/docs/drivers/Galaxy-Test-Fixture.md
@@ -0,0 +1,164 @@
+# Galaxy test fixture
+
+Coverage map + gap inventory for the Galaxy driver — out-of-process Host
+(net48 x86 MXAccess COM) + Proxy (net10) + Shared protocol.
+
+**TL;DR: Galaxy has the richest test harness in the fleet** — real Host
+subprocess spawn, real ZB SQL queries, IPC parity checks against the v1
+LmxProxy reference, + live-smoke tests when MXAccess runtime is actually
+installed. Gaps are live-plant + failover-shaped: the E2E suite covers the
+representative ~50-tag deployment but not large-site discovery stress, real
+Rockwell/Siemens PLC enumeration through MXAccess, or ZB SQL Always-On
+replica failover.
+
+## What the fixture is
+
+Multi-project test topology:
+
+- **E2E parity** —
+ `tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/ParityFixture.cs` spawns the
+ production `OtOpcUa.Driver.Galaxy.Host.exe` as a subprocess, opens the
+ named-pipe IPC, connects `GalaxyProxyDriver` + runs hierarchy / stability
+ parity tests against both.
+- **Host.Tests** —
+ `tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/` — direct Host process
+ testing (18+ test classes covering alarm discovery, AVEVA prerequisite
+ checks, IPC dispatcher, alarm tracker, probe manager, historian
+ cluster/quality/wiring, history read, OPC UA attribute mapping,
+ subscription lifecycle, reconnect, multi-host proxy, ADS address routing,
+ expression evaluation) + `GalaxyRepositoryLiveSmokeTests` that hit real
+ ZB SQL.
+- **Proxy.Tests** — `GalaxyProxyDriver` client contract tests.
+- **Shared.Tests** — shared protocol + address model.
+- **TestSupport** — test helpers reused across the above.
+
+## How tests skip
+
+- **E2E parity**: `ParityFixture.SkipIfUnavailable()` runs at class init and
+ checks Windows-only, non-admin user, ZB SQL reachable on
+ `localhost:1433`, Host EXE built in the expected `bin/` folder. Any miss
+ → tests skip.
+- **Live-smoke** (`GalaxyRepositoryLiveSmokeTests`): `Assert.Skip` when ZB
+ unreachable. A `per project_galaxy_host_installed` memory on this repo's
+ dev box notes the MXAccess runtime is installed + pipe ACL denies Admins,
+ so live tests must run from a non-elevated shell.
+- **Unit** tests (Shared, Proxy contract, most Host.Tests) have no skip —
+ they run anywhere.
+
+## What it actually covers
+
+### E2E parity suite
+
+- `HierarchyParityTests` — Host address-space hierarchy vs v1 LmxProxy
+ reference (same ZB, same Galaxy, same shape)
+- `StabilityFindingsRegressionTests` — probe subscription failure
+ handling + host-status mutation guard from the v1 stability findings
+ backlog
+
+### Host.Tests (representative)
+
+- Alarm discovery → subsystem setup
+- AVEVA prerequisite checks (runtime installed, platform deployed, etc.)
+- IPC dispatcher — request/response routing over the named pipe
+- Alarm tracker state machine
+- Probe manager — per-runtime probe subscription + reconnect
+- Historian cluster / quality / wiring — Aveva Historian integration
+- OPC UA attribute mapping
+- Subscription lifecycle + reconnect
+- Multi-host proxy routing
+- ADS address routing + expression evaluation (Galaxy's legacy expression
+ language)
+
+### Live-smoke
+
+- `GalaxyRepositoryLiveSmokeTests` — real SQL against ZB database, verifies
+ the ZB schema + `LocalPlatform` scope filter + change-detection query
+ shape match production.
+
+### Capability surfaces hit
+
+All of them: `IDriver`, `IReadable`, `IWritable`, `ITagDiscovery`,
+`ISubscribable`, `IHostConnectivityProbe`, `IPerCallHostResolver`,
+`IAlarmSource`, `IHistoryProvider`. Galaxy is the only driver where every
+interface sees both contract + real-integration coverage.
+
+## What it does NOT cover
+
+### 1. MXAccess COM by default
+
+The E2E parity suite backs subscriptions via the DB-only path; MXAccess COM
+integration opts in via a separate live-smoke. So "does the MXAccess STA
+pump correctly handle real Wonderware runtime events" is exercised only
+when the operator runs live smoke on a machine with MXAccess installed.
+
+### 2. Real Rockwell / Siemens PLC enumeration
+
+Galaxy runtime talks to PLCs through MXAccess (Device Integration Objects).
+The CI parity suite uses a representative ~50-tag deployment; large sites
+(1000+ tag hierarchies, multi-Galaxy replication, deeply-nested templates)
+are not stressed.
+
+### 3. ZB SQL Always-On failover
+
+Live-smoke hits a single SQL instance. Real production ZB often runs on
+Always-On availability groups; replica failover behavior is not tested.
+
+### 4. Galaxy replication / backup-restore
+
+Galaxy supports backup + partial replication across platforms — these
+rewrite the ZB schema in ways that change the contained_name vs tag_name
+mapping. Not exercised.
+
+### 5. Historian failover
+
+Aveva Historian can be clustered. `historian cluster / quality` tests
+verify the cluster-config query; they don't exercise actual failover
+(primary dies → secondary takes over mid-HistoryRead).
+
+### 6. AVEVA runtime version matrix
+
+MXAccess COM contract varies subtly across System Platform 2017 / 2020 /
+2023. The live-smoke runs against whatever version is installed on the dev
+box; CI has no AVEVA installed at all (licensing + footprint).
+
+## When to trust the Galaxy suite, when to reach for a live plant
+
+| Question | E2E parity | Live-smoke | Real plant |
+| --- | --- | --- | --- |
+| "Does Host spawn + IPC round-trip work?" | yes | yes | yes |
+| "Does the ZB schema query match production shape?" | partial | yes | yes |
+| "Does MXAccess COM handle runtime reconnect correctly?" | no | yes | yes |
+| "Does the driver scale to 1000+ tags on one Galaxy?" | no | partial | yes (required) |
+| "Does historian failover mid-read return a clean error?" | no | no | yes (required) |
+| "Does System Platform 2023's MXAccess differ from 2020?" | no | partial | yes (required) |
+| "Does ZB Always-On replica failover preserve generation?" | no | no | yes (required) |
+
+## Follow-up candidates
+
+1. **System Platform 2023 live-smoke matrix** — set up a second dev box
+ running SP2023; run the same live-smoke against both to catch COM-contract
+ drift early.
+2. **Synthetic large-site fixture** — script a ZB populator that creates a
+ 1000-Equipment / 20000-tag hierarchy, run the parity suite against it.
+ Catches O(N) → O(N²) discovery regressions.
+3. **Historian failover scripted test** — with a two-node AVEVA Historian
+ cluster, tear down primary mid-HistoryRead + verify the driver's failover
+ behavior + error surface.
+4. **ZB Always-On CI** — SQL Server 2022 on Linux supports Always-On;
+ could stand up a two-replica group for replica-failover coverage.
+
+This is already the best-tested driver; the remaining work is site-scale
++ production-topology coverage, not capability coverage.
+
+## Key fixture / config files
+
+- `tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/ParityFixture.cs` — E2E fixture
+ that spawns Host + connects Proxy
+- `tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/GalaxyRepositoryLiveSmokeTests.cs`
+ — live ZB smoke with `Assert.Skip` gate
+- `tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/` — shared helpers
+- `docs/drivers/Galaxy.md` — COM bridge + STA pump + IPC architecture
+- `docs/drivers/Galaxy-Repository.md` — ZB SQL reader + `LocalPlatform`
+ scope filter + change detection
+- `docs/v2/aveva-system-platform-io-research.md` — MXAccess + Wonderware
+ background
diff --git a/docs/drivers/Modbus-Test-Fixture.md b/docs/drivers/Modbus-Test-Fixture.md
new file mode 100644
index 0000000..57e36bd
--- /dev/null
+++ b/docs/drivers/Modbus-Test-Fixture.md
@@ -0,0 +1,113 @@
+# Modbus test fixture
+
+Coverage map + gap inventory for the Modbus TCP driver's integration-test
+harness backed by `pymodbus` simulator profiles per PLC family.
+
+**TL;DR:** Modbus is the best-covered driver — a real `pymodbus` server on
+localhost with per-family seed-register profiles, plus a skip-gate when the
+simulator port isn't reachable. Covers DL205 / Mitsubishi MELSEC / Siemens
+S7-1500 family quirks end-to-end. Gaps are mostly error-path + alarm/history
+shaped (neither is a Modbus-side concept).
+
+## What the fixture is
+
+- **Simulator**: `pymodbus` (Python, BSD) driven from PowerShell + per-family
+ JSON profiles under
+ `tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Pymodbus/`.
+- **Lifecycle**: `ModbusSimulatorFixture` (collection-scoped) TCP-probes
+ `localhost:5020` on first use. `MODBUS_SIM_ENDPOINT` env var overrides the
+ endpoint so the same suite can target a real PLC.
+- **Profiles**: `DL205Profile`, `MitsubishiProfile`, `S7_1500Profile` —
+ each composes device-specific register-format + quirk-seed JSON for pymodbus.
+- **Tests skip** via `Assert.Skip(sim.SkipReason)` when the probe fails; no
+ custom FactAttribute needed because `ModbusSimulatorCollection` carries the
+ skip reason.
+
+## What it actually covers
+
+### DL205 (Automation Direct)
+
+- `DL205SmokeTests` — FC16 write → FC03 read round-trip on holding register
+- `DL205CoilMappingTests` — Y-output / C-relay / X-input address mapping
+ (octal → Modbus offset)
+- `DL205ExceptionCodeTests` — Modbus exception → OPC UA StatusCode mapping
+- `DL205FloatCdabQuirkTests` — CDAB word-swap float encoding
+- `DL205StringQuirkTests` — packed-string V-memory layout
+- `DL205VMemoryQuirkTests` — V-memory octal addressing
+- `DL205XInputTests` — X-register read-only enforcement
+
+### Mitsubishi MELSEC
+
+- `MitsubishiSmokeTests` — read + write round-trip
+- `MitsubishiQuirkTests` — word-order, device-code mapping (D/M/X/Y ranges)
+
+### Siemens S7-1500 (Modbus gateway flavor)
+
+- `S7_1500SmokeTests` — read + write round-trip
+- `S7_ByteOrderTests` — ABCD/DCBA/BADC/CDAB byte-order matrix
+
+### Capability surfaces hit
+
+- `IReadable` + `IWritable` — full round-trip
+- `ISubscribable` — via the shared `PollGroupEngine` (polled subscription)
+- `IHostConnectivityProbe` — TCP-reach transitions
+
+## What it does NOT cover
+
+### 1. No `ITagDiscovery`
+
+Modbus has no symbol table — the driver requires a static tag map from
+`DriverConfig`. There is no discovery path to test + none in the fixture.
+
+### 2. Error-path fuzzing
+
+`pymodbus` serves the seeded values happily; the fixture can't easily inject
+exception responses (code 0x01–0x0B) or malformed PDUs. The
+`AbCipStatusMapper`-equivalent for exception codes is unit-tested via
+`DL205ExceptionCodeTests` but the simulator itself never refuses a read.
+
+### 3. Variant-specific quirks beyond the three profiles
+
+- FX5U / QJ71MT91 Mitsubishi variants — profile scaffolds exist, no tests yet
+- Non-S7-1500 Siemens (S7-1200 / ET200SP) — byte-order covered but
+ connection-pool + fragmentation quirks untested
+- DL205-family cousins (DL06, DL260) — no dedicated profile
+
+### 4. Subscription stress
+
+`PollGroupEngine` is unit-tested standalone but the simulator doesn't exercise
+it under multi-register packing stress (FC03 with 125-register batches,
+boundary splits, etc.).
+
+### 5. Alarms / history
+
+Not a Modbus concept. Driver doesn't implement `IAlarmSource` or
+`IHistoryProvider`; no test coverage is the correct shape.
+
+## When to trust the Modbus fixture, when to reach for a rig
+
+| Question | Fixture | Unit tests | Real PLC |
+| --- | --- | --- | --- |
+| "Does FC03/FC06/FC16 work end-to-end?" | yes | - | yes |
+| "Does DL205 octal addressing map correctly?" | yes | yes | yes |
+| "Does float CDAB word-swap round-trip?" | yes | yes | yes |
+| "Does the driver handle exception responses?" | no | yes | yes (required) |
+| "Does packing 125 regs into one FC03 work?" | no | no | yes (required) |
+| "Does FX5U behave like Q-series?" | no | no | yes (required) |
+
+## Follow-up candidates
+
+1. Add `MODBUS_SIM_ENDPOINT` override documentation to
+ `docs/v2/test-data-sources.md` so operators can point the suite at a lab rig.
+2. Extend `pymodbus` profiles to inject exception responses — a JSON flag per
+ register saying "next read returns exception 0x04."
+3. Add an FX5U profile once a lab rig is available; the scaffolding is in place.
+
+## Key fixture / config files
+
+- `tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ModbusSimulatorFixture.cs`
+- `tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/DL205/DL205Profile.cs`
+- `tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Mitsubishi/MitsubishiProfile.cs`
+- `tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/S7/S7_1500Profile.cs`
+- `tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Pymodbus/` — simulator
+ driver script + per-family JSON profiles
diff --git a/docs/drivers/OpcUaClient-Test-Fixture.md b/docs/drivers/OpcUaClient-Test-Fixture.md
new file mode 100644
index 0000000..f179fd7
--- /dev/null
+++ b/docs/drivers/OpcUaClient-Test-Fixture.md
@@ -0,0 +1,139 @@
+# OPC UA Client test fixture
+
+Coverage map + gap inventory for the OPC UA Client (gateway / aggregation)
+driver.
+
+**TL;DR: there is no integration fixture.** Tests mock the OPC UA SDK's
+`Session` + `Subscription` types directly; there is no upstream OPC UA
+server standup in CI. The irony is not lost — this repo *is* an OPC UA
+server, and the integration fixtures for `OpcUaApplicationHost`
+(`tests/ZB.MOM.WW.OtOpcUa.Server.Tests/OpcUaServerIntegrationTests.cs` +
+`OpcUaEquipmentWalkerIntegrationTests.cs`) stand up the server-side stack
+end-to-end. The client-side driver could in principle wire against one of
+those, but doesn't today.
+
+## What the fixture is
+
+Nothing at the integration layer.
+`tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/` is unit-only. Tests
+inject fakes through the driver's construction path; the
+OPCFoundation.NetStandard `Session` surface is wrapped behind an interface
+the tests mock.
+
+## What it actually covers (unit only)
+
+The surface is broad because `OpcUaClientDriver` is the richest-capability
+driver in the fleet (it's a gateway for another OPC UA server, so it
+mirrors the full capability matrix):
+
+- `OpcUaClientDriverScaffoldTests` — `IDriver` lifecycle
+- `OpcUaClientReadWriteTests` — read + write lifecycle
+- `OpcUaClientSubscribeAndProbeTests` — monitored-item subscription + probe
+ state transitions
+- `OpcUaClientDiscoveryTests` — `GetEndpoints` + endpoint selection
+- `OpcUaClientAttributeMappingTests` — OPC UA node attribute → driver value
+ mapping
+- `OpcUaClientSecurityPolicyTests` — `SignAndEncrypt` / `Sign` / `None`
+ policy negotiation contract
+- `OpcUaClientCertAuthTests` — cert store paths, revocation-list config
+- `OpcUaClientReconnectTests` — SDK reconnect hook + `TransferSubscriptions`
+ across the disconnect boundary
+- `OpcUaClientFailoverTests` — primary → secondary session fallback per
+ driver config
+- `OpcUaClientAlarmTests` — A&E severity bucket (1–1000 → Low / Medium /
+ High / Critical), subscribe / unsubscribe / ack contract
+- `OpcUaClientHistoryTests` — historical data read + interpolation contract
+
+Capability surfaces whose contract is verified: `IDriver`, `ITagDiscovery`,
+`IReadable`, `IWritable`, `ISubscribable`, `IHostConnectivityProbe`,
+`IAlarmSource`, `IHistoryProvider`.
+
+## What it does NOT cover
+
+### 1. Real stack exchange
+
+No UA Secure Channel is ever opened. Every test mocks `Session.ReadAsync`,
+`Session.CreateSubscription`, `Session.AddItem`, etc. — the SDK itself is
+trusted. Certificate validation, signing, nonce handling, chunk assembly,
+keep-alive cadence — all SDK-internal and untested here.
+
+### 2. Subscription transfer across reconnect
+
+Contract test: "after a simulated reconnect, `TransferSubscriptions` is
+called with the right handles." Real behavior: SDK re-publishes against the
+new channel and some events can be lost depending on publish-queue state.
+The lossy window is not characterized.
+
+### 3. Large-scale subscription stress
+
+100+ monitored items with heterogeneous publish intervals under a single
+session — the shape that breaks publish-queue-size tuning in the wild — is
+not exercised.
+
+### 4. Real historian mappings
+
+`IHistoryProvider.ReadRawAsync` + `ReadProcessedAsync` +
+`ReadAtTimeAsync` + `ReadEventsAsync` are contract-mocked. Against a real
+historian (AVEVA Historian, Prosys historian, Kepware LocalHistorian) each
+has specific interpolation + bad-quality-handling quirks the contract test
+doesn't see.
+
+### 5. Real A&E events
+
+Alarm subscription is mocked via filtered monitored items; the actual
+`EventFilter` select-clause behavior against a server that exposes typed
+ConditionType events (non-base `BaseEventType`) is not verified.
+
+### 6. Authentication variants
+
+- Anonymous, UserName/Password, X509 cert tokens — each is contract-tested
+ but not exchanged against a server that actually enforces each.
+- LDAP-backed `UserName` (matching this repo's server-side
+ `LdapUserAuthenticator`) requires a live LDAP round-trip; not tested.
+
+## When to trust OpcUaClient tests, when to reach for a server
+
+| Question | Unit tests | Real upstream server |
+| --- | --- | --- |
+| "Does severity 750 bucket as High?" | yes | yes |
+| "Does the driver call `TransferSubscriptions` after reconnect?" | yes | yes |
+| "Does a real OPC UA read/write round-trip work?" | no | yes (required) |
+| "Does event-filter-based alarm subscription return ConditionType events?" | no | yes (required) |
+| "Does history read from AVEVA Historian return correct aggregates?" | no | yes (required) |
+| "Does the SDK's publish queue lose notifications under load?" | no | yes (stress) |
+
+## Follow-up candidates
+
+The easiest win here is to **wire the client driver tests against this
+repo's own server**. The integration test project
+`tests/ZB.MOM.WW.OtOpcUa.Server.Tests/OpcUaServerIntegrationTests.cs`
+already stands up a real OPC UA server on a non-default port with a seeded
+FakeDriver. An `OpcUaClientLiveLoopbackTests` that connects the client
+driver to that server would give:
+
+- Real Secure Channel negotiation
+- Real Session / Subscription / MonitoredItem exchange
+- Real read/write round-trip
+- Real certificate validation (the integration test already sets up PKI)
+
+It wouldn't cover upstream-server-specific quirks (AVEVA Historian, Kepware,
+Prosys), but it would cover 80% of the SDK surface the driver sits on top
+of.
+
+Beyond that:
+
+1. **Prosys OPC UA Simulation Server** — free, Windows-available, scriptable.
+2. **UaExpert Server-Side Simulator** — Unified Automation's sample server;
+ good coverage of typed ConditionType events.
+3. **Dedicated historian integration lab** — only path for
+ historian-specific coverage.
+
+## Key fixture / config files
+
+- `tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/` — unit tests with
+ mocked `Session`
+- `src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs` — ctor +
+ session-factory seam tests mock through
+- `tests/ZB.MOM.WW.OtOpcUa.Server.Tests/OpcUaServerIntegrationTests.cs` —
+ the server-side integration harness a future loopback client test could
+ piggyback on
diff --git a/docs/drivers/README.md b/docs/drivers/README.md
index 164ac03..97a1f34 100644
--- a/docs/drivers/README.md
+++ b/docs/drivers/README.md
@@ -37,6 +37,19 @@ Driver type metadata is registered at startup in `DriverTypeRegistry` (`src/ZB.M
- **All other drivers** share a single per-driver specification in [docs/v2/driver-specs.md](../v2/driver-specs.md) — addressing, data-type maps, connection settings, and quirks live there. That file is the authoritative per-driver reference; this index points at it rather than duplicating.
+## Test-fixture coverage maps
+
+Each driver has a dedicated fixture doc that lays out what the integration / unit harness actually covers vs. what's trusted from field deployments. Read the relevant one before claiming "green suite = production-ready" for a driver.
+
+- [AB CIP](AbServer-Test-Fixture.md) — `ab_server` simulator, atomic-read smoke only; UDT / ALMD / family quirks are unit-only
+- [Modbus](Modbus-Test-Fixture.md) — `pymodbus` + per-family profiles; best-covered driver, gaps are error-path-shaped
+- [Siemens S7](S7-Test-Fixture.md) — no integration fixture, unit-only via fake `IS7Client`
+- [AB Legacy](AbLegacy-Test-Fixture.md) — no integration fixture, unit-only via `FakeAbLegacyTag` (libplctag PCCC)
+- [TwinCAT](TwinCAT-Test-Fixture.md) — no integration fixture, unit-only via `FakeTwinCATClient` with native-notification harness
+- [FOCAS](FOCAS-Test-Fixture.md) — no integration fixture, unit-only via `FakeFocasClient`; Tier C out-of-process isolation scoped but not shipped
+- [OPC UA Client](OpcUaClient-Test-Fixture.md) — no integration fixture, unit-only via mocked `Session`; loopback against this repo's own server is the obvious next step
+- [Galaxy](Galaxy-Test-Fixture.md) — richest harness: E2E Host subprocess + ZB SQL live-smoke + MXAccess opt-in
+
## Related cross-driver docs
- [HistoricalDataAccess.md](../HistoricalDataAccess.md) — `IHistoryProvider` dispatch, aggregate mapping, continuation points. The Galaxy driver's Aveva Historian implementation is the first; OPC UA Client forwards to the upstream server; other drivers do not implement the interface and return `BadHistoryOperationUnsupported`.
diff --git a/docs/drivers/S7-Test-Fixture.md b/docs/drivers/S7-Test-Fixture.md
new file mode 100644
index 0000000..6c9d3eb
--- /dev/null
+++ b/docs/drivers/S7-Test-Fixture.md
@@ -0,0 +1,119 @@
+# Siemens S7 test fixture
+
+Coverage map + gap inventory for the S7 driver.
+
+**TL;DR:** S7 now has a wire-level integration fixture backed by
+[python-snap7](https://github.com/gijzelaerr/python-snap7)'s `Server` class
+(task #216). Atomic reads (u16 / i16 / i32 / f32 / bool-with-bit) + DB
+write-then-read round-trip are exercised end-to-end through S7netplus +
+real ISO-on-TCP on `localhost:1102`. Unit tests still carry everything
+else (address parsing, error-branch handling, probe-loop contract). Gaps
+remaining are variant-quirk-shaped: Optimized-DB symbolic access, PG/OP
+session types, PUT/GET-disabled enforcement — all need real hardware.
+
+## What the fixture is
+
+**Integration layer** (task #216):
+`tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/` stands up a
+python-snap7 `Server` via `PythonSnap7/serve.ps1 -Profile s7_1500` on
+`localhost:1102`. `Snap7ServerFixture` probes the port at collection init
++ skips with a clear message when unreachable (matches the pymodbus
+pattern). `server.py` reads a JSON profile + seeds DB/MB bytes at declared
+offsets; seeds are typed (`u16` / `i16` / `i32` / `f32` / `bool` / `ascii`
+for S7 STRING).
+
+**Unit layer**: `tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/` covers
+everything the wire-level suite doesn't — address parsing, error
+branches, probe-loop contract. All tests tagged
+`[Trait("Category", "Unit")]`.
+
+The driver ctor change that made this possible:
+`Plc(CpuType, host, port, rack, slot)` — S7netplus 0.20's 5-arg overload
+— wires `S7DriverOptions.Port` through so the simulator can bind 1102
+(non-privileged) instead of 102 (root / Firewall-prompt territory).
+
+## What it actually covers
+
+### Integration (python-snap7, task #216)
+
+- `S7_1500SmokeTests.Driver_reads_seeded_u16_through_real_S7comm` — DB1.DBW0
+ read via real S7netplus over TCP + simulator; proves handshake + read path
+- `S7_1500SmokeTests.Driver_reads_seeded_typed_batch` — i16, i32, f32,
+ bool-with-bit in one batch call; proves typed decode per S7DataType
+- `S7_1500SmokeTests.Driver_write_then_read_round_trip_on_scratch_word` —
+ `DB1.DBW100` write → read-back; proves write path + buffer visibility
+
+### Unit
+
+- `S7AddressParserTests` — S7 address syntax (`DB1.DBD0`, `M10.3`, `IW4`, etc.)
+- `S7DriverScaffoldTests` — `IDriver` lifecycle (init / reinit / shutdown / health)
+- `S7DriverReadWriteTests` — error paths (uninitialized read/write, bad
+ addresses, transport exceptions)
+- `S7DiscoveryAndSubscribeTests` — `ITagDiscovery.DiscoverAsync` + polled
+ `ISubscribable` contract with the shared `PollGroupEngine`
+
+Capability surfaces whose contract is verified: `IDriver`, `ITagDiscovery`,
+`IReadable`, `IWritable`, `ISubscribable`, `IHostConnectivityProbe`.
+Wire-level surfaces verified: `IReadable`, `IWritable`.
+
+## What it does NOT cover
+
+### 1. Wire-level anything
+
+No ISO-on-TCP frame is ever sent during the test suite. S7netplus is the only
+wire-path abstraction and it has no in-process fake mode; the shipping choice
+was to contract-test via `IS7Client` rather than patch into S7netplus
+internals.
+
+### 2. Read/write happy path
+
+Every `S7DriverReadWriteTests` case exercises error branches. A successful
+read returning real PLC data is not tested end-to-end — the return value is
+whatever the fake says it is.
+
+### 3. Mailbox serialization under concurrent reads
+
+The driver's `SemaphoreSlim` serializes S7netplus calls because the S7 CPU's
+comm mailbox is scanned at most once per cycle. Contention behavior under
+real PLC latency is not exercised.
+
+### 4. Variant quirks
+
+S7-1200 vs S7-1500 vs S7-300/400 connection semantics (PG vs OP vs S7-Basic)
+not differentiated at test time.
+
+### 5. Data types beyond the scalars
+
+UDT fan-out, `STRING` with length-prefix quirks, `DTL` / `DATE_AND_TIME`,
+arrays of structs — not covered.
+
+## When to trust the S7 tests, when to reach for a rig
+
+| Question | Unit tests | Real PLC |
+| --- | --- | --- |
+| "Does the address parser accept X syntax?" | yes | - |
+| "Does the driver lifecycle hang / crash?" | yes | yes |
+| "Does a real read against an S7-1500 return correct bytes?" | no | yes (required) |
+| "Does mailbox serialization actually prevent PG timeouts?" | no | yes (required) |
+| "Does a UDT fan-out produce usable member variables?" | no | yes (required) |
+
+## Follow-up candidates
+
+1. **Snap7 server** — [Snap7](https://snap7.sourceforge.net/) ships a
+ C-library-based S7 server that could run in-CI on Linux. A pinned build +
+ a fixture shape similar to `ab_server` would give S7 parity with Modbus /
+ AB CIP coverage.
+2. **Plcsim Advanced** — Siemens' paid emulator. Licensed per-seat; fits a
+ lab rig but not CI.
+3. **Real S7 lab rig** — cheapest physical PLC (CPU 1212C) on a dedicated
+ network port, wired via self-hosted runner.
+
+Without any of these, S7 driver correctness against real hardware is trusted
+from field deployments, not from the test suite.
+
+## Key fixture / config files
+
+- `tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/` — unit tests only, no harness
+- `src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs` — ctor takes
+ `IS7ClientFactory` which tests fake; docstring lines 8-20 note the deferred
+ integration fixture
diff --git a/docs/drivers/TwinCAT-Test-Fixture.md b/docs/drivers/TwinCAT-Test-Fixture.md
new file mode 100644
index 0000000..ed92364
--- /dev/null
+++ b/docs/drivers/TwinCAT-Test-Fixture.md
@@ -0,0 +1,111 @@
+# TwinCAT test fixture
+
+Coverage map + gap inventory for the Beckhoff TwinCAT ADS driver.
+
+**TL;DR: there is no integration fixture.** Every test uses a
+`FakeTwinCATClient` injected via `ITwinCATClientFactory`. Beckhoff's ADS
+library has no open-source simulator; ADS traffic against real TwinCAT
+runtimes is trusted from field deployments.
+
+The silver lining: TwinCAT is the only driver outside Galaxy that uses
+**native notifications** (no polling) for `ISubscribable`, and the fake
+exposes a fire-event harness so notification routing is contract-tested
+rigorously — just not on the wire.
+
+## What the fixture is
+
+Nothing at the integration layer.
+`tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/` is unit-only.
+`FakeTwinCATClient` also fakes the `AddDeviceNotification` flow so tests can
+trigger callbacks without a running runtime.
+
+## What it actually covers (unit only)
+
+- `TwinCATAmsAddressTests` — `ads://:` parsing + routing
+- `TwinCATCapabilityTests` — data-type mapping (primitives + declared UDTs),
+ read-only classification
+- `TwinCATReadWriteTests` — read + write through the fake, status mapping
+- `TwinCATSymbolPathTests` — symbol-path routing for nested struct members
+- `TwinCATSymbolBrowserTests` — `ITagDiscovery.DiscoverAsync` via
+ `ReadSymbolsAsync` (#188) + system-symbol filtering
+- `TwinCATNativeNotificationTests` — `AddDeviceNotification` (#189)
+ registration, callback-delivery-to-`OnDataChange` wiring, unregister on
+ unsubscribe
+- `TwinCATDriverTests` — `IDriver` lifecycle
+
+Capability surfaces whose contract is verified: `IDriver`, `IReadable`,
+`IWritable`, `ITagDiscovery`, `ISubscribable`, `IHostConnectivityProbe`,
+`IPerCallHostResolver`.
+
+## What it does NOT cover
+
+### 1. AMS / ADS wire traffic
+
+No real AMS router frame is exchanged. Beckhoff's `TwinCAT.Ads` NuGet (their
+own .NET SDK, not libplctag-style OSS) has no in-process fake; tests stub
+the `ITwinCATClient` abstraction above it.
+
+### 2. Multi-route AMS
+
+ADS supports chained routes (` → → `)
+for PLCs behind an EC master / IPC gateway. Parse coverage exists; wire-path
+coverage doesn't.
+
+### 3. Notification reliability under jitter
+
+`AddDeviceNotification` delivers at the runtime's cycle boundary; under high
+CPU load or network jitter real notifications can coalesce. The fake fires
+one callback per test invocation — real callback-coalescing behavior is
+untested.
+
+### 4. TC2 vs TC3 variant handling
+
+TwinCAT 2 (ADS v1) and TwinCAT 3 (ADS v2) have subtly different
+`GetSymbolInfoByName` semantics + symbol-table layouts. Driver targets TC3;
+TC2 compatibility is not exercised.
+
+### 5. Cycle-time alignment for `ISubscribable`
+
+Native ADS notifications fire on the PLC cycle boundary. The fake test
+harness assumes notifications fire on a timer the test controls;
+cycle-aligned firing under real PLC control is not verified.
+
+### 6. Alarms / history
+
+Driver doesn't implement `IAlarmSource` or `IHistoryProvider` — not in
+scope for this driver family. TwinCAT 3's TcEventLogger could theoretically
+back an `IAlarmSource`, but shipping that is a separate feature.
+
+## When to trust TwinCAT tests, when to reach for a rig
+
+| Question | Unit tests | Real TwinCAT runtime |
+| --- | --- | --- |
+| "Does the AMS address parser accept X?" | yes | - |
+| "Does notification → `OnDataChange` wire correctly?" | yes (contract) | yes |
+| "Does symbol browsing filter TwinCAT internals?" | yes | yes |
+| "Does a real ADS read return correct bytes?" | no | yes (required) |
+| "Do notifications coalesce under load?" | no | yes (required) |
+| "Does a TC2 PLC work the same as TC3?" | no | yes (required) |
+
+## Follow-up candidates
+
+1. **TwinCAT 3 runtime on CI** — Beckhoff ships a free developer runtime
+ (7-day trial, restartable). Could run on a Windows CI runner with a
+ helper that auto-restarts the runtime every 6 days. Works but operational
+ overhead.
+2. **AdsSimulator** — Beckhoff has a closed-source "ADS simulator" library
+ used internally; not publicly available.
+3. **Lab rig** — cheapest IPC (CX7000 / CX9020) on a dedicated network; the
+ only route that covers TC2 + real notification behavior + EtherCAT I/O
+ effects.
+
+Without a rig, TwinCAT correctness is trusted from the fake matching
+reality, which has held across field deployments so far.
+
+## Key fixture / config files
+
+- `tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/FakeTwinCATClient.cs` —
+ in-process fake with the notification-fire harness used by
+ `TwinCATNativeNotificationTests`
+- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs` — ctor takes
+ `ITwinCATClientFactory`
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs
index b7bb365..52260e4 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs
@@ -84,7 +84,7 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
_health = new DriverHealth(DriverState.Initializing, null, null);
try
{
- var plc = new Plc(_options.CpuType, _options.Host, _options.Rack, _options.Slot);
+ var plc = new Plc(_options.CpuType, _options.Host, _options.Port, _options.Rack, _options.Slot);
// S7netplus writes timeouts into the underlying TcpClient via Plc.WriteTimeout /
// Plc.ReadTimeout (milliseconds). Set before OpenAsync so the handshake itself
// honours the bound.
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/PythonSnap7/README.md b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/PythonSnap7/README.md
new file mode 100644
index 0000000..1a38810
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/PythonSnap7/README.md
@@ -0,0 +1,110 @@
+# python-snap7 server profiles
+
+JSON-driven seed profiles for `snap7.server.Server` from
+[python-snap7](https://github.com/gijzelaerr/python-snap7) (MIT). Shape
+mirrors the pymodbus profiles under
+`tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Pymodbus/` — a
+PowerShell launcher + per-family JSON + a Python shim that the launcher
+exec's.
+
+| File | What it seeds | Test category |
+|---|---|---|
+| [`s7_1500.json`](s7_1500.json) | DB1 (1024 bytes) with smoke values at known offsets (i16 @ DBW10, i32 @ DBD20, f32 @ DBD30, bool @ DBX50.3, scratch word @ DBW100, STRING "Hello" @ 200) + MB (256 bytes) with probe marker at MW0. | `Trait=Integration, Device=S7_1500` |
+
+Default port **1102** (non-privileged; sidesteps Windows Firewall prompt +
+Linux's root-required bind on port 102). The fixture
+(`Snap7ServerFixture`) defaults to `localhost:1102`. Override via
+`S7_SIM_ENDPOINT` to point at a real S7 CPU on port 102. The S7 driver
+threads `_options.Port` through to S7netplus's 5-arg `Plc` ctor, so the
+non-standard port works end-to-end.
+
+## Install
+
+```powershell
+pip install "python-snap7>=2.0"
+```
+
+`python-snap7` wraps the upstream `snap7` C library; the install pulls
+platform-specific binaries automatically. Requires Python ≥ 3.10.
+Windows Firewall will prompt on first bind; allow Private network.
+
+## Run
+
+Foreground (Ctrl+C to stop):
+
+```powershell
+.\serve.ps1 -Profile s7_1500
+```
+
+Non-default port:
+
+```powershell
+.\serve.ps1 -Profile s7_1500 -Port 102
+```
+
+Or invoke the Python shim directly:
+
+```powershell
+python .\server.py .\s7_1500.json --port 1102
+```
+
+## Run the integration tests
+
+In a separate shell with the simulator running:
+
+```powershell
+cd C:\Users\dohertj2\Desktop\lmxopcua
+dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests
+```
+
+Tests auto-skip with a clear `SkipReason` when `localhost:1102` isn't
+reachable within 2 seconds.
+
+## What's encoded in `s7_1500.json`
+
+| Address | Type | Seed | Purpose |
+|---|---|---|---|
+| `DB1.DBW0` | u16 | `4242` | read-back probe |
+| `DB1.DBW10` | i16 | `-12345` | smoke i16 read |
+| `DB1.DBD20` | i32 | `1234567890` | smoke i32 read |
+| `DB1.DBD30` | f32 | `3.14159` | smoke f32 read (big-endian) |
+| `DB1.DBX50.3` | bool | `true` | smoke bool read at bit 3 |
+| `DB1.DBW100` | u16 | `0` | scratch for write-then-read |
+| `DB1.STRING[200]` | S7 STRING | `"Hello"` (max 32, cur 5) | smoke string read |
+| `MW0` | u16 | `1` | `S7ProbeOptions.ProbeAddress` default |
+
+Seed types supported by `server.py`: `u8`, `i8`, `u16`, `i16`, `u32`,
+`i32`, `f32`, `bool` (with `"bit": 0..7`), `ascii` (S7 STRING type with
+configurable `max_len`).
+
+## Known limitations (python-snap7 upstream)
+
+The `snap7.server.Server` docstring admits:
+
+> "Legacy S7 server implementation. Emulates a Siemens S7 PLC for testing
+> and development purposes. [...] pure Python emulator implementation that
+> simulates PLC behaviour for protocol compliance testing rather than
+> full industrial-grade functionality."
+
+What that means in practice — things this fixture does NOT cover:
+
+- **S7-1500 Optimized-DB symbolic access** — the real S7-1500 with TIA Portal
+ optimization enabled uses symbolic addressing that's wire-incompatible with
+ absolute DB addressing. Our driver targets non-optimized DBs; so does
+ snap7's server. Rig test required to verify against an Optimized CPU.
+- **PG / OP / S7-Basic session types** — S7netplus uses OP session; the
+ simulator accepts whatever session type is requested, unlike real CPUs
+ that allocate session slots differently.
+- **SCL variant-specific behaviour** — e.g. S7-1200 missing certain PDU
+ types, S7-300's older handshake, S7-400 multi-CPU racks with non-zero
+ slot. Simulator collapses all into one generic CPU emulation.
+- **PUT/GET-disabled-by-default** — real S7-1200/1500 CPUs refuse reads
+ when PUT/GET is off in TIA Portal hardware config; the driver maps that
+ to `BadDeviceFailure`. Simulator has no such toggle + always accepts.
+
+## References
+
+- [python-snap7 GitHub](https://github.com/gijzelaerr/python-snap7) — source + install
+- [snap7.server API](https://python-snap7.readthedocs.io/en/latest/API/server.html) — `Server` class reference
+- [`docs/drivers/S7-Test-Fixture.md`](../../../docs/drivers/S7-Test-Fixture.md) — coverage map + gap inventory
+- [`docs/v2/s7.md`](../../../docs/v2/s7.md) — driver-side addressing + family notes
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/PythonSnap7/s7_1500.json b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/PythonSnap7/s7_1500.json
new file mode 100644
index 0000000..20d8955
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/PythonSnap7/s7_1500.json
@@ -0,0 +1,35 @@
+{
+ "_description": "S7-1500 profile — single DB1 (1024 bytes) + MB (256 bytes) with well-known seeds at named offsets for the smoke + byte-order + string tests. Big-endian Siemens wire order throughout.",
+ "areas": [
+ {
+ "area": "DB",
+ "index": 1,
+ "size": 1024,
+ "seeds": [
+ { "_desc": "DB1.DBW0 — read-back probe, S7Driver default ProbeAddress target is MW0; this shadows it",
+ "offset": 0, "type": "u16", "value": 4242 },
+ { "_desc": "DB1.DBW10 — i16 smoke value for SmokeI16 read path",
+ "offset": 10, "type": "i16", "value": -12345 },
+ { "_desc": "DB1.DBD20 — i32 smoke value for SmokeI32 read path",
+ "offset": 20, "type": "i32", "value": 1234567890 },
+ { "_desc": "DB1.DBD30 — f32 smoke value for SmokeF32 read path (IEEE-754 big-endian)",
+ "offset": 30, "type": "f32", "value": 3.14159 },
+ { "_desc": "DB1.DBX50.3 — bool bit at byte-50 bit-3 for SmokeBool read path",
+ "offset": 50, "type": "bool", "value": true, "bit": 3 },
+ { "_desc": "DB1.DBW100 — scratch for write-then-read round-trip tests; seeded 0",
+ "offset": 100, "type": "u16", "value": 0 },
+ { "_desc": "DB1.STRING[200] — S7 string 'Hello' (max 32, cur 5)",
+ "offset": 200, "type": "ascii", "value": "Hello", "max_len": 32 }
+ ]
+ },
+ {
+ "area": "MK",
+ "index": 0,
+ "size": 256,
+ "seeds": [
+ { "_desc": "MW0 — probe target for S7ProbeOptions.ProbeAddress default",
+ "offset": 0, "type": "u16", "value": 1 }
+ ]
+ }
+ ]
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/PythonSnap7/serve.ps1 b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/PythonSnap7/serve.ps1
new file mode 100644
index 0000000..a6af910
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/PythonSnap7/serve.ps1
@@ -0,0 +1,56 @@
+<#
+.SYNOPSIS
+ Launches the python-snap7 S7 server with one of the integration-test
+ profiles. Foreground process — Ctrl+C to stop. Mirrors the pymodbus
+ `serve.ps1` wrapper in tests\...\Modbus.IntegrationTests\Pymodbus\.
+
+.PARAMETER Profile
+ Which profile JSON to load: currently only 's7_1500' ships. Additional
+ families (S7-1200, S7-300) can drop in as new JSON files alongside.
+
+.PARAMETER Port
+ TCP port to bind. Default 1102 (non-privileged; matches
+ Snap7ServerFixture default endpoint). Pass 102 to match S7 standard —
+ requires root on Linux + triggers Windows Firewall prompt.
+
+.EXAMPLE
+ .\serve.ps1 -Profile s7_1500
+
+.EXAMPLE
+ .\serve.ps1 -Profile s7_1500 -Port 102
+#>
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory)] [ValidateSet('s7_1500')] [string]$Profile,
+ [int]$Port = 1102
+)
+
+$ErrorActionPreference = 'Stop'
+$here = $PSScriptRoot
+
+# python-snap7 installs the `snap7` Python package; we call via `python -m`
+# or via the server.py shim in this folder. Shim path is simpler to diagnose.
+$python = Get-Command python -ErrorAction SilentlyContinue
+if (-not $python) { $python = Get-Command py -ErrorAction SilentlyContinue }
+if (-not $python) {
+ Write-Error "python not found on PATH. Install Python 3.10+ and 'pip install python-snap7'."
+ exit 1
+}
+
+# Verify python-snap7 is installed so failures surface here, not in a
+# confusing ImportError from server.py.
+& $python.Source -c "import snap7.server" 2>$null
+if ($LASTEXITCODE -ne 0) {
+ Write-Error "python-snap7 not importable. Install with: pip install 'python-snap7>=2.0'"
+ exit 1
+}
+
+$jsonFile = Join-Path $here "$Profile.json"
+if (-not (Test-Path $jsonFile)) {
+ Write-Error "Profile config not found: $jsonFile"
+ exit 1
+}
+
+Write-Host "Starting python-snap7 server: profile=$Profile TCP=localhost:$Port"
+Write-Host "Ctrl+C to stop."
+& $python.Source (Join-Path $here "server.py") $jsonFile --port $Port
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/PythonSnap7/server.py b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/PythonSnap7/server.py
new file mode 100644
index 0000000..ce1824b
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/PythonSnap7/server.py
@@ -0,0 +1,150 @@
+"""python-snap7 S7 server for integration tests.
+
+Reads a JSON profile from argv[1], allocates bytearrays for each declared area
+(DB / MB / EB / AB), poke-seeds values at declared offsets, then starts the
+snap7 Server on the configured port + blocks until Ctrl+C. Shape intentionally
+mirrors the pymodbus `serve.ps1 + *.json` pattern one directory over so
+someone familiar with the Modbus fixture can read this without re-learning.
+
+The snap7.server.Server class is the MIT-licensed S7 PLC emulator wrapped by
+python-snap7 (https://github.com/gijzelaerr/python-snap7). Its own docstring
+admits "protocol compliance testing rather than full industrial-grade
+functionality" — good enough for ISO-on-TCP wire-level round-trip but NOT
+for S7-1500 Optimized-DB symbolic access, SCL variant-specific behaviour, or
+PG/OP/S7-Basic session differentiation.
+"""
+
+from __future__ import annotations
+
+import argparse
+import ctypes
+import json
+import signal
+import sys
+import time
+from pathlib import Path
+
+# python-snap7 installs as `snap7` package; Server class lives under `snap7.server`.
+import snap7
+from snap7.type import SrvArea
+
+
+# Map JSON area names → SrvArea enum values. PE = inputs (I/E), PA = outputs
+# (Q/A), MK = memory (M), DB = data blocks, TM = timers, CT = counters.
+AREA_MAP: dict[str, int] = {
+ "PE": SrvArea.PE,
+ "PA": SrvArea.PA,
+ "MK": SrvArea.MK,
+ "DB": SrvArea.DB,
+ "TM": SrvArea.TM,
+ "CT": SrvArea.CT,
+}
+
+
+def seed_buffer(buf: bytearray, seeds: list[dict]) -> None:
+ """Poke seed values into the area buffer at declared byte offsets.
+
+ Each seed is {"offset": int, "type": str, "value": int|float|bool|str}
+ where type ∈ {u8, i8, u16, i16, u32, i32, f32, bool, ascii}. Endianness is
+ big-endian (Siemens wire format).
+ """
+ for seed in seeds:
+ off = int(seed["offset"])
+ t = seed["type"]
+ v = seed["value"]
+ if t == "u8":
+ buf[off] = int(v) & 0xFF
+ elif t == "i8":
+ buf[off] = int(v) & 0xFF
+ elif t == "u16":
+ buf[off:off + 2] = int(v).to_bytes(2, "big", signed=False)
+ elif t == "i16":
+ buf[off:off + 2] = int(v).to_bytes(2, "big", signed=True)
+ elif t == "u32":
+ buf[off:off + 4] = int(v).to_bytes(4, "big", signed=False)
+ elif t == "i32":
+ buf[off:off + 4] = int(v).to_bytes(4, "big", signed=True)
+ elif t == "f32":
+ import struct
+ buf[off:off + 4] = struct.pack(">f", float(v))
+ elif t == "bool":
+ bit = int(seed.get("bit", 0))
+ if bool(v):
+ buf[off] |= (1 << bit)
+ else:
+ buf[off] &= ~(1 << bit) & 0xFF
+ elif t == "ascii":
+ # Siemens STRING type: byte 0 = max length, byte 1 = current length,
+ # bytes 2+ = payload. Seeds supply the payload text; we fill max/cur.
+ payload = str(v).encode("ascii")
+ max_len = int(seed.get("max_len", 254))
+ buf[off] = max_len
+ buf[off + 1] = len(payload)
+ buf[off + 2:off + 2 + len(payload)] = payload
+ else:
+ raise ValueError(f"Unknown seed type '{t}'")
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description="python-snap7 S7 server for integration tests")
+ parser.add_argument("profile", help="Path to profile JSON")
+ parser.add_argument("--port", type=int, default=1102, help="TCP port (default 1102 non-privileged)")
+ args = parser.parse_args()
+
+ profile_path = Path(args.profile)
+ if not profile_path.is_file():
+ print(f"profile not found: {profile_path}", file=sys.stderr)
+ return 1
+
+ with profile_path.open() as f:
+ profile = json.load(f)
+
+ server = snap7.server.Server()
+ # Keep bytearray refs alive for the server's lifetime — snap7 doesn't copy
+ # the buffer, it takes a pointer. Letting GC collect would corrupt reads.
+ buffers: list[bytearray] = []
+
+ for area_decl in profile.get("areas", []):
+ area_name = area_decl["area"]
+ if area_name not in AREA_MAP:
+ print(f"unknown area '{area_name}' (expected one of {list(AREA_MAP)})", file=sys.stderr)
+ return 1
+ index = int(area_decl.get("index", 0)) # DB number for DB area, 0 for MK/PE/PA
+ size = int(area_decl["size"])
+ buf = bytearray(size)
+ seed_buffer(buf, area_decl.get("seeds", []))
+ buffers.append(buf)
+ # register_area takes (area, index, c-array); we wrap the bytearray
+ # into a ctypes char array so the native lib can take &buf[0].
+ arr_type = ctypes.c_char * size
+ arr = arr_type.from_buffer(buf)
+ server.register_area(AREA_MAP[area_name], index, arr)
+ print(f" seeded {area_name}{index} size={size} seeds={len(area_decl.get('seeds', []))}")
+
+ port = int(args.port)
+ print(f"Starting python-snap7 server on TCP {port} (Ctrl+C to stop)")
+ server.start(tcp_port=port)
+
+ stop = {"sig": False}
+ def _handle(*_a):
+ stop["sig"] = True
+ signal.signal(signal.SIGINT, _handle)
+ try:
+ signal.signal(signal.SIGTERM, _handle)
+ except Exception:
+ pass # SIGTERM not on all platforms
+
+ try:
+ while not stop["sig"]:
+ time.sleep(0.25)
+ finally:
+ print("stopping python-snap7 server")
+ try:
+ server.stop()
+ except Exception:
+ pass
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500Profile.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500Profile.cs
new file mode 100644
index 0000000..e0a29c1
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500Profile.cs
@@ -0,0 +1,53 @@
+using S7NetCpuType = global::S7.Net.CpuType;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500;
+
+///
+/// Driver-side configuration matching what PythonSnap7/s7_1500.json seeds
+/// into the simulator's DB1 + MB areas. Tag names here become the full references
+/// the smoke tests read/write against; addresses map 1:1 to the JSON profile's
+/// seed offsets so a seed drift in the JSON surfaces as a driver-side read
+/// mismatch, not a mystery test failure.
+///
+public static class S7_1500Profile
+{
+ public const string ProbeTag = "ProbeProbeWord";
+ public const int ProbeSeedValue = 4242;
+
+ public const string SmokeI16Tag = "SmokeI16";
+ public const short SmokeI16SeedValue = -12345;
+
+ public const string SmokeI32Tag = "SmokeI32";
+ public const int SmokeI32SeedValue = 1234567890;
+
+ public const string SmokeF32Tag = "SmokeF32";
+ public const float SmokeF32SeedValue = 3.14159f;
+
+ public const string SmokeBoolTag = "SmokeBool";
+
+ public const string WriteScratchTag = "WriteScratch";
+
+ public static S7DriverOptions BuildOptions(string host, int port) => new()
+ {
+ Host = host,
+ Port = port,
+ CpuType = S7NetCpuType.S71500,
+ Rack = 0,
+ Slot = 0,
+ Timeout = TimeSpan.FromSeconds(5),
+ // Disable the probe loop — the integration tests run their own reads +
+ // a background probe would race with them for the S7netplus mailbox
+ // gate, injecting flakiness that has nothing to do with the code
+ // under test.
+ Probe = new S7ProbeOptions { Enabled = false },
+ Tags =
+ [
+ new S7TagDefinition(ProbeTag, "DB1.DBW0", S7DataType.UInt16),
+ new S7TagDefinition(SmokeI16Tag, "DB1.DBW10", S7DataType.Int16),
+ new S7TagDefinition(SmokeI32Tag, "DB1.DBD20", S7DataType.Int32),
+ new S7TagDefinition(SmokeF32Tag, "DB1.DBD30", S7DataType.Float32),
+ new S7TagDefinition(SmokeBoolTag, "DB1.DBX50.3", S7DataType.Bool),
+ new S7TagDefinition(WriteScratchTag, "DB1.DBW100", S7DataType.UInt16),
+ ],
+ };
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500SmokeTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500SmokeTests.cs
new file mode 100644
index 0000000..ca64cc1
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500SmokeTests.cs
@@ -0,0 +1,83 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500;
+
+///
+/// End-to-end smoke against the python-snap7 S7-1500 profile. Drives the real
+/// + real S7netplus ISO-on-TCP stack + real CIP-free
+/// S7comm exchange against localhost:1102. Success proves initialisation,
+/// typed reads (u16 / i16 / i32 / f32 / bool-with-bit), and a write-then-read
+/// round-trip all work against a real S7 server — the baseline everything
+/// S7-specific (byte-order, optimized-DB differences, probe behaviour) layers on.
+///
+[Collection(Snap7ServerCollection.Name)]
+[Trait("Category", "Integration")]
+[Trait("Device", "S7_1500")]
+public sealed class S7_1500SmokeTests(Snap7ServerFixture sim)
+{
+ [Fact]
+ public async Task Driver_reads_seeded_u16_through_real_S7comm()
+ {
+ if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
+
+ var options = S7_1500Profile.BuildOptions(sim.Host, sim.Port);
+ await using var drv = new S7Driver(options, driverInstanceId: "s7-smoke-u16");
+ await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
+
+ var snapshots = await drv.ReadAsync(
+ [S7_1500Profile.ProbeTag], TestContext.Current.CancellationToken);
+
+ snapshots.Count.ShouldBe(1);
+ snapshots[0].StatusCode.ShouldBe(0u, "seeded u16 read must succeed end-to-end");
+ Convert.ToInt32(snapshots[0].Value).ShouldBe(S7_1500Profile.ProbeSeedValue);
+ }
+
+ [Fact]
+ public async Task Driver_reads_seeded_typed_batch()
+ {
+ if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
+
+ var options = S7_1500Profile.BuildOptions(sim.Host, sim.Port);
+ await using var drv = new S7Driver(options, driverInstanceId: "s7-smoke-batch");
+ await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
+
+ var snapshots = await drv.ReadAsync(
+ [S7_1500Profile.SmokeI16Tag, S7_1500Profile.SmokeI32Tag,
+ S7_1500Profile.SmokeF32Tag, S7_1500Profile.SmokeBoolTag],
+ TestContext.Current.CancellationToken);
+
+ snapshots.Count.ShouldBe(4);
+ foreach (var s in snapshots) s.StatusCode.ShouldBe(0u);
+
+ Convert.ToInt32(snapshots[0].Value).ShouldBe((int)S7_1500Profile.SmokeI16SeedValue);
+ Convert.ToInt32(snapshots[1].Value).ShouldBe(S7_1500Profile.SmokeI32SeedValue);
+ Convert.ToSingle(snapshots[2].Value).ShouldBe(S7_1500Profile.SmokeF32SeedValue, tolerance: 0.0001f);
+ Convert.ToBoolean(snapshots[3].Value).ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task Driver_write_then_read_round_trip_on_scratch_word()
+ {
+ if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
+
+ var options = S7_1500Profile.BuildOptions(sim.Host, sim.Port);
+ await using var drv = new S7Driver(options, driverInstanceId: "s7-smoke-write");
+ await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
+
+ const ushort probe = 0xBEEF;
+ var writeResults = await drv.WriteAsync(
+ [new WriteRequest(S7_1500Profile.WriteScratchTag, probe)],
+ TestContext.Current.CancellationToken);
+ writeResults.Count.ShouldBe(1);
+ writeResults[0].StatusCode.ShouldBe(0u,
+ "write must succeed against snap7's DB1.DBW100 scratch register");
+
+ var readResults = await drv.ReadAsync(
+ [S7_1500Profile.WriteScratchTag], TestContext.Current.CancellationToken);
+ readResults.Count.ShouldBe(1);
+ readResults[0].StatusCode.ShouldBe(0u);
+ Convert.ToInt32(readResults[0].Value).ShouldBe(probe);
+ }
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Snap7ServerFixture.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Snap7ServerFixture.cs
new file mode 100644
index 0000000..780d4ce
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Snap7ServerFixture.cs
@@ -0,0 +1,83 @@
+using System.Net.Sockets;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests;
+
+///
+/// Reachability probe for a python-snap7 simulator (see
+/// PythonSnap7/serve.ps1) or a real S7 PLC. Parses S7_SIM_ENDPOINT
+/// (default localhost:1102) + TCP-connects once at fixture construction.
+/// Tests check + call Assert.Skip when unreachable, so
+/// `dotnet test` stays green on a fresh box without the simulator installed —
+/// mirrors the ModbusSimulatorFixture pattern.
+///
+///
+///
+/// Default port is 1102, not the S7-standard 102. 102 is a privileged port
+/// on Linux (needs root) + triggers the Windows Firewall prompt on first bind;
+/// 1102 sidesteps both. S7netplus 0.20 supports the 5-arg Plc ctor that
+/// takes an explicit port (verified + wired through S7DriverOptions.Port),
+/// so the driver can reach the simulator on its non-standard port without
+/// hacks.
+///
+///
+/// The probe is a one-shot liveness check; tests open their own S7netplus
+/// sessions against the same endpoint. Don't share a socket — S7 CPUs serialise
+/// concurrent connections against the same mailbox anyway, but sharing would
+/// couple test ordering to socket reuse in ways this harness shouldn't care
+/// about.
+///
+///
+/// Fixture is a collection fixture so the probe runs once per test session, not
+/// per test.
+///
+///
+public sealed class Snap7ServerFixture : IAsyncDisposable
+{
+ // Default 1102 (non-privileged) matches PythonSnap7/server.py. Override with
+ // S7_SIM_ENDPOINT to point at a real PLC on its native 102.
+ private const string DefaultEndpoint = "localhost:1102";
+ private const string EndpointEnvVar = "S7_SIM_ENDPOINT";
+
+ public string Host { get; }
+ public int Port { get; }
+ public string? SkipReason { get; }
+
+ public Snap7ServerFixture()
+ {
+ var raw = Environment.GetEnvironmentVariable(EndpointEnvVar) ?? DefaultEndpoint;
+ var parts = raw.Split(':', 2);
+ Host = parts[0];
+ Port = parts.Length == 2 && int.TryParse(parts[1], out var p) ? p : 102;
+
+ try
+ {
+ // Force IPv4 — python-snap7 binds 0.0.0.0 (IPv4) and .NET's default
+ // dual-stack "localhost" resolves IPv6 ::1 first then times out before
+ // falling back. Same story the Modbus fixture hits.
+ using var client = new TcpClient(AddressFamily.InterNetwork);
+ var task = client.ConnectAsync(
+ System.Net.Dns.GetHostAddresses(Host)
+ .FirstOrDefault(a => a.AddressFamily == AddressFamily.InterNetwork)
+ ?? System.Net.IPAddress.Loopback,
+ Port);
+ if (!task.Wait(TimeSpan.FromSeconds(2)) || !client.Connected)
+ {
+ SkipReason = $"python-snap7 simulator at {Host}:{Port} did not accept a TCP connection within 2s. " +
+ $"Start it (PythonSnap7\\serve.ps1 -Profile s7_1500) or override {EndpointEnvVar}.";
+ }
+ }
+ catch (Exception ex)
+ {
+ SkipReason = $"python-snap7 simulator at {Host}:{Port} unreachable: {ex.GetType().Name}: {ex.Message}. " +
+ $"Start it (PythonSnap7\\serve.ps1 -Profile s7_1500) or override {EndpointEnvVar}.";
+ }
+ }
+
+ public ValueTask DisposeAsync() => ValueTask.CompletedTask;
+}
+
+[Xunit.CollectionDefinition(Name)]
+public sealed class Snap7ServerCollection : Xunit.ICollectionFixture
+{
+ public const string Name = "Snap7Server";
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.csproj b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.csproj
new file mode 100644
index 0000000..e91d714
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.csproj
@@ -0,0 +1,35 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+ true
+ ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+