Dockerize Modbus + AB CIP + S7 test fixtures for reproducibility. Every driver integration simulator now has a pinned Docker image alongside the existing native launcher — Docker is the primary path, native fallbacks kept for contributors who prefer them. Matches the already-Dockerized OpcUaClient/opc-plc pattern from #215 so every fixture in the fleet presents the same compose-up/test/compose-down loop. Reproducibility gain: what used to require a local pip/Python install (Modbus pymodbus, S7 python-snap7) or a per-OS C build from source (AB CIP ab_server from libplctag) now collapses to a Dockerfile + docker compose up. Modbus — new tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/ with Dockerfile (python:3.12-slim-bookworm + pymodbus[simulator]==3.13.0) + docker-compose.yml with four compose profiles (standard / dl205 / mitsubishi / s7_1500) backed by the existing profile JSONs copied under Docker/profiles/ as canonical; native fallback in Pymodbus/ retained with the same JSON set (symlink-equivalent — manual re-sync when profiles change, noted in both READMEs). Port 5020 unchanged so MODBUS_SIM_ENDPOINT + ModbusSimulatorFixture work without code change. Dropped the --no_http CLI arg the old serve.ps1 + compose draft passed — pymodbus 3.13 doesn't recognize it; the simulator's http ui just binds inside the container where nothing maps it out and costs nothing. S7 — new tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/ with Dockerfile (python:3.12-slim-bookworm + python-snap7>=2.0) + docker-compose.yml with one s7_1500 compose profile; copies the existing server.py shim + s7_1500.json seed profile; runs python -u server.py ... --port 1102. Native fallback in PythonSnap7/ retained. Port 1102 unchanged. AB CIP — hardest because ab_server is a source-only C tool in libplctag's src/tools/ab_server/. New tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker/ Dockerfile is multi-stage: build stage (debian:bookworm-slim + build-essential + cmake) clones libplctag at a pinned tag + cmake --build build --target ab_server; runtime stage (debian:bookworm-slim) copies just the binary from /src/build/bin_dist/ab_server. docker-compose.yml ships four compose profiles (controllogix / compactlogix / micro800 / guardlogix) with per-family ab_server CLI args matching AbServerProfile.cs. AbServerFixture updated: tries TCP probe on 127.0.0.1:44818 first (Docker path) + spawns the native binary only as fallback when no listener is there. AB_SERVER_ENDPOINT env var supported for pointing at a real PLC. AbServerFact/Theory attributes updated to IsServerAvailable() which accepts any of: live listener on 44818, AB_SERVER_ENDPOINT set, or binary on PATH. Required two CLI-compat fixes to ab_server's argument expectations that the existing native profile never caught because it was never actually run at CI: --plc is case-sensitive (ControlLogix not controllogix), CIP tags need [size] bracket notation (DINT[1] not bare DINT), ControlLogix also requires --path=1,0. Compose files carry the corrected flags; the existing native-path AbServerProfile.cs was never invoked in practice so we don't rewrite it here. Micro800 now uses the --plc=Micro800 mode rather than falling back to ControlLogix emulation — ab_server does have the dedicated mode, the old Notes saying otherwise were wrong. Updated docs: three fixture coverage docs (Modbus-Test-Fixture.md, S7-Test-Fixture.md, AbServer-Test-Fixture.md) flip their "What the fixture is" section from native-only to Docker-primary-with-native-fallback; dev-environment.md §Resource Inventory replaces the old ambiguous "Docker Desktop + ab_server native" mix with four per-driver rows (each listing the image, compose file, compose profiles, port, credentials) + a new Docker fixtures — quick reference subsection giving the one-line docker compose -f <…> --profile <…> up for each driver + the env-var override names + the native fallback install recipes. drivers/README.md coverage map table updated — Modbus/AB CIP/S7 entries now read "Dockerized …" consistent with OpcUaClient's line. Verified end-to-end against live containers: Modbus DL205 smoke 1/1, S7 3/3, AB CIP ControlLogix 4/4 (all family theory rows). Container lifecycle clean (up/test/down, no leaked state). Every fixture keeps its skip-when-absent probe + env-var endpoint override so dotnet test on a fresh clone without Docker running still gets a green run.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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())
|
||||
Reference in New Issue
Block a user