Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/README.md
T

203 lines
9.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# S7 integration-test fixture — python-snap7
[python-snap7](https://github.com/gijzelaerr/python-snap7) `Server` class
wrapped in a pinned `python:3.12-slim-bookworm` image. Docker is the
only supported launch path — a fresh clone needs Docker Desktop and
nothing else.
| File | Purpose |
|---|---|
| [`Dockerfile`](Dockerfile) | `python:3.12-slim-bookworm` + `python-snap7>=2.0` + the server shim + the profile JSONs |
| [`docker-compose.yml`](docker-compose.yml) | One service per profile; currently only `s7_1500` |
| [`server.py`](server.py) | Same Python shim the native fallback uses — copy kept in the build context |
| [`profiles/*.json`](profiles/) | Area-seed definitions (DB1 / MB layouts with typed seeds) |
## Run
From the repo root:
```powershell
docker compose -f tests\Drivers\ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests\Docker\docker-compose.yml --profile s7_1500 up
```
Detached + stop:
```powershell
docker compose -f tests\...\Docker\docker-compose.yml --profile s7_1500 up -d
docker compose -f tests\...\Docker\docker-compose.yml --profile s7_1500 down
```
### ⚠️ Editing a profile requires `--build`
`Dockerfile` **COPYs `profiles/` into the image**, so the seeds are baked in at
build time — they are not bind-mounted. A plain `up -d`, and even
`up -d --force-recreate`, reuses the existing `otopcua-python-snap7:1.0` image
and silently keeps serving the OLD seeds. After editing a profile JSON:
```bash
docker compose --profile s7_1500 up -d --build --force-recreate
```
Confirm it took: `docker logs otopcua-python-snap7-s7_1500` prints
`seeded DB1 size=1024 seeds=<n>` at startup — check `<n>` matches the seed count
you just wrote. This is the fastest way to tell a stale image from a bad address.
On the shared Docker host the deployment mirror at `/opt/otopcua-s7/` is
**root-owned**, so a plain `rsync` fails with `status 23` and leaves the old
files in place while still printing the file list. Push with:
```bash
rsync -av --rsync-path="sudo rsync" \
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/ \
dohertj2@10.100.0.35:/opt/otopcua-s7/
```
## Endpoint
- Default: `localhost:1102` (non-privileged; sidesteps Windows Firewall
prompt + Linux's root-required bind on port 102).
- Override with `S7_SIM_ENDPOINT` to point at a real S7 CPU on `:102`.
- The driver's S7DriverOptions.Port flows through S7netplus's 5-arg
`Plc(CpuType, host, port, rack, slot)` ctor so the non-standard port
works end-to-end.
## Run the integration tests
In a separate shell with the container up:
```powershell
cd C:\Users\dohertj2\Desktop\lmxopcua
dotnet test tests\Drivers\ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests
```
`Snap7ServerFixture` probes `localhost:1102` at collection init + records
a `SkipReason` when unreachable, so tests stay green on a fresh clone
without Docker running.
## What's encoded in `profiles/s7_1500.json`
DB1 (1024 bytes) + MB (256 bytes) + TM0/CT0 (64 bytes each) with typed seeds at
known offsets:
| 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"` | S7 STRING read |
| `MW0` | u16 | `1` | `S7ProbeOptions.ProbeAddress` default |
Seed types supported: `u8`, `i8`, `u16`, `i16`, `u32`, `i32`, `f32`,
`bool` (with `"bit": 0..7`), `ascii` (S7 STRING).
### Span-planner gate seeds (offsets 300+, `S7_1500SpanReadTests`)
These exist to give `S7ReadSpanPlanner` a layout with a **known** partition under
the shipped 16-byte-gap / 220-byte-span defaults — the offsets are the test, so
moving one changes what the gate proves.
| Address | Type | Seed | Role in the partition |
|---|---|---|---|
| `DB1.DBW300` | u16 | `51001` | cluster A — merges with the four below into ONE 300..320 span |
| `DB1.DBW302` | i16 | `-21001` | cluster A (gap 0) |
| `DB1.DBD304` | i32 | `305419896` | cluster A (gap 0) |
| `DB1.DBX310.5` | bool | `true` | cluster A (gap 2) |
| `DB1.DBD316` | f32 | `2.7182817` | cluster A (gap 5); seals the span at 20 bytes |
| `DB1.DBW400` | u16 | `40201` | cluster B — 80 bytes on, so the gap budget forces a SECOND span |
| `DB1.DBW402` | u16 | `40202` | cluster B's second member (keeps it off the `PrefersPerTagRead` demotion) |
| `DB1.DBW500` | u16 | `60001` | isolated ⇒ demoted single-member span ⇒ multi-var leftover |
| `DB1.DBW600` | i16 | `-32001` | isolated ⇒ multi-var leftover |
| `MW10` | u16 | `4097` | multi-var leftover in a **different area** — a block read can't cross `(area, DB)`, multi-var can |
Offsets `308`, `311` and `312` carry **poison** (`0xDEAD`, `0x5A`, `0xDEADBEEF`)
inside the holes cluster A's span reads through. No tag maps to them; they are
there so a mis-computed slice offset returns garbage instead of a zero that could
match by luck. Every seed above is distinct and non-zero for the same reason.
### Timer / Counter (TM / CT) areas
`TM0` and `CT0` are 64 bytes each — **32 device cells**, since a timer is a 2-byte
S5TIME word and a counter a 2-byte count word, with device number `N` at byte
offset `2 × N`. They exist so `S7_1500SpanReadTests` can pin, against real wire,
that `S7ReadSpanPlanner` excludes Timer/Counter from every span and the multi-var
pass excludes them again on `IsBufferType` — each takes its own area-typed
`ReadBytes` round-trip instead.
| Address | Byte offset | Seed | Decodes to |
|---|---|---|---|
| `T0` | 0 | `0x2456` | `456.0` s (S5TIME base 2 = ×1 s, BCD 456) |
| `T5` | 10 | `0x1123` | `12.3` s (base 1 = ×0.1 s, BCD 123) |
| `T9` | 18 | `0x0250` | `2.5` s (base 0 = ×0.01 s, BCD 250) |
| `C0` | 0 | `4711` | `4711` (raw big-endian count word) |
| `C7` | 14 | `31337` | `31337` |
| `C11` | 22 | `1234` | `1234` |
A timer decodes to seconds as a `double` (S7.Net `Timer.FromByteArray`) and a
counter to an `int` count (`Counter.FromByteArray`, raw word — **not** BCD).
#### ⚠️ Only device **0** is addressable on this fixture
Live-probed 2026-08-17: snap7's server resolves the TM/CT request address as a
**bit address** — it serves byte `addr >> 3` of the area — whereas S7.Net, and a
real S7 CPU, put the device **number** in that field. Measured with S7netplus
0.20.0 against `10.100.0.35:1102` running **the profile above**:
```
ReadBytes(Timer,db=0,start=0,count=8) => [24 56 00 00 00 00 00 00] # TM bytes 0..7: T0 = 0x2456
ReadBytes(Timer,db=0,start=0,count=2) => [24 56] # T0 -> byte 0. 0>>3 == 2*0, so BOTH conventions agree here
ReadBytes(Timer,db=0,start=5,count=2) => [24 56] # T5 -> 5>>3 = byte 0. WRONG cell: T5 lives at bytes 10..11 (0x1123)
ReadBytes(Timer,db=0,start=80,count=2) => [11 23] # byte 10 needs start 80 (= 10*8): the T5 cell, reached the bit-address way
ReadBytes(Counter,db=0,start=0,count=2) => [12 67] # C0 -> byte 0 = 4711. Both conventions agree
ReadBytes(Counter,db=0,start=7,count=2) => [12 67] # C7 -> 7>>3 = byte 0. WRONG cell: C7 lives at bytes 14..15 (0x7A69 = 31337)
ReadBytes(Counter,db=0,start=112,count=2) => [7A 69] # byte 14 needs start 112 (= 14*8): the C7 cell
```
Note lines 2 and 3: asking for `T0` and asking for `T5` return the **same bytes**
— every device 0..7 collapses onto TM byte 0. Same for `C0` / `C7`.
No error is raised — the reads succeed, they just land on the wrong cell. Device
**0** is the single fixed point of the two conventions (`0 >> 3 == 2 × 0 == 0`),
so `T0` / `C0` are the only cells whose *value* the fixture can pin; that is what
`Timer_and_counter_tags_never_enter_a_multi_var_request` asserts, and the
wire-shape half of that test is unaffected by the quirk. The `2 × N` cells above
are seeded for a real CPU: run
`Timer_and_counter_decode_the_seeded_values_at_their_device_numbers` against one
with
```bash
S7_SIM_ENDPOINT=<cpu>:102 S7_TIMER_NUMBER_ADDRESSING=1 \
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests
```
It skips (naming this limitation) whenever that variable is unset.
## Known limitations
From the `snap7.server.Server` docstring upstream:
> "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."
Not exercised here — needs a lab rig:
- S7-1500 Optimized-DB symbolic access
- PG / OP / S7-Basic session-type differentiation
- PUT/GET-disabled-by-default enforcement
- Device-number addressing of the TM / CT areas (see the ⚠️ above — the reads are
served, but resolved as bit addresses, so only device 0 lands where a CPU
would put it)
See [`docs/drivers/S7-Test-Fixture.md`](../../../docs/drivers/S7-Test-Fixture.md)
for the full coverage map.
## References
- [python-snap7 GitHub](https://github.com/gijzelaerr/python-snap7)
- [`docs/drivers/S7-Test-Fixture.md`](../../../docs/drivers/S7-Test-Fixture.md) — coverage map
- [`docs/v2/dev-environment.md`](../../../docs/v2/dev-environment.md) §Docker fixtures