Merge pull request 'S7 integration fixture via python-snap7 (#216) + per-driver test-fixture coverage docs' (#160) from s7-integration-fixture into v2
This commit was merged in pull request #160.
This commit is contained in:
@@ -34,6 +34,7 @@
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests.csproj"/>
|
||||
|
||||
97
docs/drivers/AbLegacy-Test-Fixture.md
Normal file
97
docs/drivers/AbLegacy-Test-Fixture.md
Normal file
@@ -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
|
||||
147
docs/drivers/AbServer-Test-Fixture.md
Normal file
147
docs/drivers/AbServer-Test-Fixture.md
Normal file
@@ -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
|
||||
95
docs/drivers/FOCAS-Test-Fixture.md
Normal file
95
docs/drivers/FOCAS-Test-Fixture.md
Normal file
@@ -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
|
||||
164
docs/drivers/Galaxy-Test-Fixture.md
Normal file
164
docs/drivers/Galaxy-Test-Fixture.md
Normal file
@@ -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
|
||||
113
docs/drivers/Modbus-Test-Fixture.md
Normal file
113
docs/drivers/Modbus-Test-Fixture.md
Normal file
@@ -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
|
||||
139
docs/drivers/OpcUaClient-Test-Fixture.md
Normal file
139
docs/drivers/OpcUaClient-Test-Fixture.md
Normal file
@@ -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
|
||||
@@ -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`.
|
||||
|
||||
119
docs/drivers/S7-Test-Fixture.md
Normal file
119
docs/drivers/S7-Test-Fixture.md
Normal file
@@ -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
|
||||
111
docs/drivers/TwinCAT-Test-Fixture.md
Normal file
111
docs/drivers/TwinCAT-Test-Fixture.md
Normal file
@@ -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://<netId>:<port>` 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 (`<localNetId> → <routerNetId> → <targetNetId>`)
|
||||
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`
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -0,0 +1,53 @@
|
||||
using S7NetCpuType = global::S7.Net.CpuType;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500;
|
||||
|
||||
/// <summary>
|
||||
/// Driver-side configuration matching what <c>PythonSnap7/s7_1500.json</c> 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.
|
||||
/// </summary>
|
||||
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),
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end smoke against the python-snap7 S7-1500 profile. Drives the real
|
||||
/// <see cref="S7Driver"/> + real S7netplus ISO-on-TCP stack + real CIP-free
|
||||
/// S7comm exchange against <c>localhost:1102</c>. 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.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Reachability probe for a python-snap7 simulator (see
|
||||
/// <c>PythonSnap7/serve.ps1</c>) or a real S7 PLC. Parses <c>S7_SIM_ENDPOINT</c>
|
||||
/// (default <c>localhost:1102</c>) + TCP-connects once at fixture construction.
|
||||
/// Tests check <see cref="SkipReason"/> + call <c>Assert.Skip</c> when unreachable, so
|
||||
/// `dotnet test` stays green on a fresh box without the simulator installed —
|
||||
/// mirrors the <c>ModbusSimulatorFixture</c> pattern.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Default port is <b>1102</b>, 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 <c>Plc</c> ctor that
|
||||
/// takes an explicit port (verified + wired through <c>S7DriverOptions.Port</c>),
|
||||
/// so the driver can reach the simulator on its non-standard port without
|
||||
/// hacks.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Fixture is a collection fixture so the probe runs once per test session, not
|
||||
/// per test.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
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<Snap7ServerFixture>
|
||||
{
|
||||
public const string Name = "Snap7Server";
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.S7\ZB.MOM.WW.OtOpcUa.Driver.S7.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="PythonSnap7\**\*" CopyToOutputDirectory="PreserveNewest"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user