Deterministic, fixture-driven Snap7 interoperability servers for testing S7 clients against two independent implementations.
Status: Ready for use. Both images build for linux/amd64 and linux/arm64,
the full canonical fixture matrix passes dual interop (make interop), and
images publish to GHCR. Remaining work is listed under Open TODOs.
| Image | Registry |
|---|---|
| Native Snap7 server | ghcr.io/otfabric/snap7-interop-native |
| Pure-Python server | ghcr.io/otfabric/snap7-interop-python |
Releases up to v0.2.0 were published as ghcr.io/otfabric/snap-interop-native
and ghcr.io/otfabric/snap-interop-python; those tags stay available.
This repository stays client-neutral: nothing outside own/go-s7comm/ depends on go-s7comm (CI checks it). See Our own stack.
- What you can test today
- Out of scope (and why)
- Open TODOs
- Core design
- Fixtures
- Published images (GHCR)
- Container contract
- Developer workflow
- Relationship with go-s7comm
- Repository layout
- Documentation index
- Versioning
- Security and licensing
Both servers expose classic S7 communication over ISO-on-TCP (container port 102) and are gated by the same compiled fixtures:
| Area | Operations | How it is proven |
|---|---|---|
| Data blocks (DB) | byte read/write, bit read/write | basic-db, write-cycle, multiple-dbs, bit-access, … |
| Markers (Merkers) | byte read/write | markers-io, typed-values |
| Inputs / outputs | byte read (and marker write path) | markers-io |
| Typed scalars | int/uint/float as compiled big-endian bytes | integer-boundaries, floating-point, typed-values |
| Boundaries | unaligned spans, last valid byte, out-of-range reject | unaligned-range, last-valid-byte, out-of-range |
| Access policy | read-only write reject (normalized outcome) | read-only-reject |
| Session / load | concurrent readers, reconnect with preserved memory | concurrent-reads, reconnect-state |
| PDU | large single-request read within negotiated PDU | large-read |
Normalized probe outcomes used in expectations:
success/acceptedaddress-out-of-rangewrite-protected(item0x03; the references send0x0Afor a read-only write, soread-only-rejectexpectsaddress-out-of-range— see docs/COMPATIBILITY.md)unsupported,transport-failure,protocol-failure
Run the full black-box matrix locally:
make fixtures-check
make interop # all canonical fixtures
FIXTURE=basic-db make interopReports land under reports/<fixture-id>/ (report.json, report.md, junit.xml).
reports/results.json is the organisation's
interop-result-v1, derived from them: one
result per fixture, with the stable ID fixture.<fixture-id>. Only pass counts as
passing; a run with no pass fails.
Client API mapping for go-s7comm is in docs/go-s7comm-coverage.md.
| Topic | Status | Reason |
|---|---|---|
| Full PLC / scan-cycle emulation | out of scope | Fixtures define static memory, not a CPU program |
| LAD / FBD / STL / SCL execution | out of scope | Not a runtime; servers only expose registered areas |
| Per-CPU / firmware quirk fidelity | out of scope | Goal is shared, reproducible interop—not Siemens parity |
| Optimized S7-1200/1500 DBs | out of scope (v1) | Classic DB addressing only until characterized |
| Production PLC simulator | out of scope | Test infrastructure only; see SECURITY.md |
| Timers / counters areas | deferred | Need equivalent semantics on both adapters before schema freeze |
| Identify / SZL / CPU state / diagnostics | deferred | Upstream-specific; characterize both servers first |
| Block list / upload / download | deferred | Outside memory interop contract |
Client-only APIs (ProbeReadableRanges, CompareRead, discovery, rack/slot probe) |
not fixture-gated here | Orchestration belongs in the client; servers covered by OOR/span/memory fixtures |
| Multi-PDU client chunking beyond negotiated PDU | client-side | large-read covers a large single request; go-s7comm chunks larger spans |
Distinct wire code for “write-protected” (0x03) |
classified difference | Stock Snap7 ResourceLess rejects as 0x0A; both adapters match that surface. The otfabric image sends the Siemens 0x03 (COMPATIBILITY.md) |
Do not encode unresolved assumptions into the fixture schema. Document differences in docs/COMPATIBILITY.md.
Potential follow-ups (not blockers for current GHCR consumption):
| Item | Notes |
|---|---|
Wire go-s7comm CI to pinned GHCR digests |
Downstream: pull both images, run the client suite, retain logs on failure |
| AddressSanitizer / UndefinedBehaviorSanitizer CI for the native adapter | Optional hardening for adapters/native |
| Timers / counters memory areas | Characterize both adapters, then extend schema + fixtures |
| Identify / SZL / CPU state / diagnostics | Characterize both adapters before freezing expectations |
| Block list / upload / download | Outside current memory interop contract |
| Real-CPU read-only write capture | What a physical S7-1200/S7-300/S7-1500 sends per item for a write-protected DB (#19) |
| Optimized S7-1200/1500 DB addressing | Out of v1 scope until characterized |
When closing a TODO, update this table and any related fixture/docs.
Fixtures define observable server state. Servers are adapters.
source fixture (fixtures/canonical/*.json)
│
├── JSON Schema + semantic validation
└── canonical compilation → fixtures/compiled/*.compiled.json
│
├── ghcr.io/otfabric/snap7-interop-native
└── ghcr.io/otfabric/snap7-interop-python
│
└── black-box probe / make interop reports
- Source fixtures are implementation-neutral JSON.
- Typed values are encoded only by the compiler.
- Adapters register compiled bytes; they do not re-interpret types.
- Unsupported capabilities must surface as explicit skips, never silent downgrades.
Fifteen canonical fixtures (DB, markers, inputs and outputs; bits, typed values,
boundaries, out-of-range and read-only rejects, concurrent readers, reconnect,
a large read) define what both servers serve and what the probe expects. The
catalog, the layout of fixtures/, the invalid fixtures and how to add one:
docs/FIXTURES.md. Capability tokens:
docs/CAPABILITIES.md.
Tags are immutable (no floating latest): v<version> and <version> from
versions.yaml (interop_version, currently 0.3.0). Changelog: RELEASE.md.
docker pull ghcr.io/otfabric/snap7-interop-native:v0.3.0
docker pull ghcr.io/otfabric/snap7-interop-python:v0.3.0
docker run --rm -p 1102:102 --read-only \
--tmpfs /tmp --tmpfs /run/snap-interop:uid=10001,gid=10001,mode=0755 \
--cap-drop ALL --cap-add NET_BIND_SERVICE \
-v "$PWD/fixtures/compiled:/fixtures:ro" \
-e SNAP_INTEROP_FIXTURE=/fixtures/basic-db.compiled.json \
ghcr.io/otfabric/snap7-interop-native:v0.3.0Publish multi-arch images (requires docker login ghcr.io with write:packages;
set package visibility to Public for anonymous pulls):
make publish
# IMAGE_TAG=0.3.0 PUBLISH_PLATFORMS=linux/amd64,linux/arm64 make publishPrefer digest pins in go-s7comm CI:
docker buildx imagetools inspect ghcr.io/otfabric/snap7-interop-native:v0.3.0ghcr.io/otfabric/snap7-interop-otfabric is OT Fabric's own S7 server, the server package of go-s7comm, behind the same container contract (adapter name otfabric), so you can test an S7 client against our stack with the same fixtures and commands as the references:
docker run --rm -p 3102:102 --read-only \
--tmpfs /tmp --tmpfs /run/snap-interop:uid=10001,gid=10001,mode=0755 \
--cap-drop ALL --cap-add NET_BIND_SERVICE \
-e SNAP_INTEROP_FIXTURE=/fixtures/basic-db.compiled.json \
ghcr.io/otfabric/snap7-interop-otfabric:latestIt is not an independent implementation and not part of a harness release. The manual workflow Own stack (go-s7comm) builds it from the latest go-s7comm release, runs the smoke test and every fixture against the reference images of the latest harness release, and publishes :<go-s7comm version> and :latest from main. It answers a write to a read-only area with the Siemens code 0x03 where the references send 0x0A (an intentional, classified difference). Details: own/go-s7comm/README.md.
SNAP_INTEROP_FIXTURE=/fixtures/basic-db.compiled.json
SNAP_INTEROP_LISTEN_ADDRESS=0.0.0.0
SNAP_INTEROP_PORT=102
SNAP_INTEROP_LOG_FORMAT=json
SNAP_INTEROP_STATE_PATH=/run/snap-interop/state.json
Both containers:
- reject missing/invalid fixtures before readiness;
- emit
server_readyonly after accepting connections; - expose a Docker health check;
- handle
SIGTERMcleanly; - log to stdout/stderr;
- keep an atomic diagnostic state snapshot under
SNAP_INTEROP_STATE_PATH; - run with read-only rootfs +
cap_drop ALL+NET_BIND_SERVICEin compose/smoke.
Suggested host ports (do not publish privileged host port 102):
native: localhost:1102 → container:102
python: localhost:2102 → container:102
make help
make ci # lint, tests, fixture goldens, case lists, version pins (no Docker)
make images # build every adapter image (ADAPTERS, default: native python)
make smoke # container-contract smoke test of each (scripts/smoke.sh <adapter>)
make interop # every case of tests/cases.tsv on both servers (scripts/interop.sh native python)
make documents # check reports/results.json against the schema and the run rules
make capabilities # the capability tokens every server is run with
make candidate-python CANDIDATE_VERSION=<v> # smoke a newer python-snap7 (pins untouched)
make candidate-native CANDIDATE_SHA=<commit> # smoke another Snap7 commit
make publish # multi-arch push to GHCR (publish-arch, publish-manifest, digests per architecture)scripts/ takes any image through ADAPTER_IMAGE_<adapter>, and the images of a
release with VERSION=vX.Y.Z:
VERSION=v0.6.0 scripts/smoke.sh native
VERSION=v0.6.0 ADAPTER_IMAGE_otfabric=ghcr.io/otfabric/snap7-interop-otfabric:latest scripts/interop.sh native otfabricThe daily Upstream workflow (.github/workflows/upstream.yml) compares the pins with
the latest upstream (scripts/latest-upstream.py), runs these candidates for pins
that are behind and keeps the issue "Upstream releases: pins behind the latest" up to date.
Architecture override:
BUILD_PLATFORM=linux/arm64 make smoke interop| Owns | Component |
|---|---|
snap7-interop |
Images, fixtures, compiler, dual-server equivalence |
go-s7comm |
Client API assertions, client CI matrix, digest pins; its manual Self-interop check runs its client against snap7-interop-otfabric:latest |
go-s7comm CI
├── pull ghcr.io/otfabric/snap7-interop-native@sha256:…
├── run client tests
├── pull ghcr.io/otfabric/snap7-interop-python@sha256:…
├── run identical client tests
└── retain logs / fixture reports on failure
snap7-interop/
├── README.md / REQUIREMENTS.md / RELEASE.md / SECURITY.md / THIRD_PARTY_NOTICES.md / CONTRIBUTING.md
├── Makefile / compose.yaml / versions.yaml / licenses/
├── adapters/{native,python}/ # the reference servers, one image each
├── own/go-s7comm/ # our own stack (otfabric image), not a reference
├── fixtures/{schema,canonical,invalid,compiled}/
├── schemas/ # interop-result-v1
├── scripts/ # lib.sh, smoke.sh, interop.sh, results writer, checks
├── tests/{cases,known-differences}.tsv
├── tooling/{fixturectl,interop-probe}/ # fixture compiler and the harness's own S7 client (Go)
├── internal/{fixture,interop,s7client}/
├── docs/{CONTAINER_CONTRACT,FIXTURES,CAPABILITIES,COMPATIBILITY}.md, docs/adr/
├── reports/ # generated
└── .github/workflows/ # ci, policy, release, upstream, own-go-s7comm
| Document | Purpose |
|---|---|
| README.md | Scope, GHCR, open TODOs (this file) |
| REQUIREMENTS.md | What the repository must and must not do (R1 isolation) |
| RELEASE.md | Version changelog and release notes |
| docs/CONTAINER_CONTRACT.md | Environment, lifecycle, log, exit codes |
| docs/FIXTURES.md | Fixture catalog and format, case lists |
| docs/CAPABILITIES.md | Capability tokens per adapter |
| docs/COMPATIBILITY.md | Upstream constraints and classified differences |
| docs/go-s7comm-coverage.md | Client API ↔ fixture map |
| docs/adr/ | Architecture decisions |
| SECURITY.md | Reporting + release-blocking scan policy |
| THIRD_PARTY_NOTICES.md | Snap7 / python-snap7 / base image notices |
| CONTRIBUTING.md | Contribution workflow |
Version independently:
- repository / image tags (
versions.yaml→interop_version); - fixture schema and compiler;
- individual fixture revisions;
- native Snap7 git revision;
python-snap7package version.
Published tags must not move. Prefer digest pins downstream.
These servers are test infrastructure only. Do not expose them to untrusted networks. Do not bind host port 102 in examples.
The repository pins base images by digest, generates SBOMs (make sbom), scans
images (make scan), and excludes Siemens proprietary assets. See
SECURITY.md and THIRD_PARTY_NOTICES.md.