diff --git a/docs/drivers/AbServer-Test-Fixture.md b/docs/drivers/AbServer-Test-Fixture.md index 1581c3c..afe77a1 100644 --- a/docs/drivers/AbServer-Test-Fixture.md +++ b/docs/drivers/AbServer-Test-Fixture.md @@ -10,19 +10,27 @@ quirk. UDT / alarm / quirk behavior is verified only by unit tests with ## 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`. +- **Binary**: `ab_server` — a C program in libplctag's + `src/tools/ab_server/` ([libplctag/libplctag](https://github.com/libplctag/libplctag), + MIT). +- **Primary launcher**: Docker. `Docker/Dockerfile` multi-stage-builds + `ab_server` from source against a pinned libplctag tag + copies the + binary into a slim runtime image. `Docker/docker-compose.yml` has + per-family services (`controllogix` / `compactlogix` / `micro800` / + `guardlogix`); all bind `:44818`. +- **Lifecycle**: `AbServerFixture` probes `127.0.0.1:44818` at collection + init. When a Docker container is running (or `AB_SERVER_ENDPOINT` is + set) the fixture skips its legacy spawn path + the tests dial the + running container. When neither is present, it falls back to the + pre-Docker path of resolving `ab_server` off `PATH` + spawning it. - **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. + in `AbServerProfile.cs`. Each composes a CLI arg list + seed-tag set; the + `Docker/docker-compose.yml` `command:` entries mirror those args 1:1. - **Tests**: one smoke, `AbCipReadSmokeTests.Driver_reads_seeded_DInt_from_ab_server`, - parametrized over all four profiles. + parametrized over all four profiles via `[AbServerTheory]` + `[MemberData]`. +- **Skip rules** via `[AbServerFact]` / `[AbServerTheory]`: accept a live + listener on `:44818` (Docker), an `AB_SERVER_ENDPOINT` override, or the + native binary on `PATH`; skip otherwise. ## What it actually covers diff --git a/docs/drivers/Modbus-Test-Fixture.md b/docs/drivers/Modbus-Test-Fixture.md index 57e36bd..f1e41d3 100644 --- a/docs/drivers/Modbus-Test-Fixture.md +++ b/docs/drivers/Modbus-Test-Fixture.md @@ -11,14 +11,20 @@ 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/`. +- **Simulator**: `pymodbus` (Python, BSD) — primary launcher is a pinned + Docker image at + `tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/`. + Native-Python fallback under `Pymodbus/` is kept for contributors who + don't want Docker. - **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. + Profile JSONs are canonical under `Docker/profiles/`; `Pymodbus/` carries + a copy for the native fallback. +- **Compose services**: one per profile (`standard` / `dl205` / + `mitsubishi` / `s7_1500`); only one binds `:5020` at a time. - **Tests skip** via `Assert.Skip(sim.SkipReason)` when the probe fails; no custom FactAttribute needed because `ModbusSimulatorCollection` carries the skip reason. diff --git a/docs/drivers/README.md b/docs/drivers/README.md index 97a1f34..899a479 100644 --- a/docs/drivers/README.md +++ b/docs/drivers/README.md @@ -41,9 +41,9 @@ Driver type metadata is registered at startup in `DriverTypeRegistry` (`src/ZB.M 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 CIP](AbServer-Test-Fixture.md) — Dockerized `ab_server` (multi-stage build from libplctag source); atomic-read smoke across 4 families; UDT / ALMD / family quirks unit-only +- [Modbus](Modbus-Test-Fixture.md) — Dockerized `pymodbus` + per-family JSON profiles (4 compose profiles); best-covered driver, gaps are error-path-shaped +- [Siemens S7](S7-Test-Fixture.md) — Dockerized `python-snap7` server; DB/MB read + write round-trip verified end-to-end on `:1102` - [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 diff --git a/docs/drivers/S7-Test-Fixture.md b/docs/drivers/S7-Test-Fixture.md index 6c9d3eb..1e75ced 100644 --- a/docs/drivers/S7-Test-Fixture.md +++ b/docs/drivers/S7-Test-Fixture.md @@ -15,12 +15,14 @@ session types, PUT/GET-disabled enforcement — all need real hardware. **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). +python-snap7 `Server` via `Docker/docker-compose.yml --profile s7_1500` +on `localhost:1102` (pinned `python:3.12-slim-bookworm` base + +`python-snap7>=2.0`). Native-Python fallback under `PythonSnap7/` kept +for contributors who prefer to avoid Docker. `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 diff --git a/docs/v2/dev-environment.md b/docs/v2/dev-environment.md index c38ef39..6d49756 100644 --- a/docs/v2/dev-environment.md +++ b/docs/v2/dev-environment.md @@ -143,15 +143,52 @@ Dev credentials in this inventory are convenience defaults, not secrets. Change | Resource | Purpose | Type | Default port | Default credentials | Owner | |----------|---------|------|--------------|---------------------|-------| -| **Docker Desktop for Windows** | Host for containerized simulators | Install | (Hyper-V required; not compatible with TwinCAT runtime — see TwinCAT row below for the workaround) | n/a | Integration host admin | -| **`oitc/modbus-server`** | Modbus TCP simulator (per `test-data-sources.md` §1) | Docker container | 502 (Modbus TCP) | n/a (no auth in protocol) | Integration host admin | -| **`ab_server`** (libplctag binary) | AB CIP + AB Legacy simulator (per `test-data-sources.md` §2 + §3) | Native binary built from libplctag source; runs in a separate VM or host since it conflicts with Docker Desktop's Hyper-V if run on bare metal | 44818 (CIP) | n/a | Integration host admin | -| **Snap7 Server** | S7 simulator (per `test-data-sources.md` §4) | Native binary; runs in a separate VM or in WSL2 to avoid Hyper-V conflict | 102 (ISO-TCP) | n/a | Integration host admin | +| **Docker Desktop for Windows** | Host for every driver test-fixture simulator (Modbus / AB CIP / S7 / OpcUaClient) + SQL Server | Install | (Hyper-V required; not compatible with TwinCAT runtime — see TwinCAT row below for the workaround) | n/a | Integration host admin | +| **Modbus fixture — `otopcua-pymodbus:3.13.0`** | Modbus driver integration tests | Docker image (local build, see `tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/`); 4 compose profiles: `standard` / `dl205` / `mitsubishi` / `s7_1500` | 5020 (non-privileged) | n/a (no auth in protocol) | Developer (per machine) | +| **AB CIP fixture — `otopcua-ab-server:libplctag-release`** | AB CIP driver integration tests | Docker image (multi-stage build of libplctag's `ab_server` from source, pinned to the `release` tag; see `tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker/`); 4 compose profiles: `controllogix` / `compactlogix` / `micro800` / `guardlogix` | 44818 (CIP / EtherNet/IP) | n/a | Developer (per machine) | +| **S7 fixture — `otopcua-python-snap7:1.0`** | S7 driver integration tests | Docker image (local build, `python-snap7>=2.0`; see `tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/`); 1 compose profile: `s7_1500` | 1102 (non-privileged; driver honours `S7DriverOptions.Port`) | n/a | Developer (per machine) | +| **OPC UA Client fixture — `mcr.microsoft.com/iotedge/opc-plc:2.14.10`** | OpcUaClient driver integration tests | Docker image (Microsoft-maintained, pinned; see `tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/Docker/`) | 50000 (OPC UA) | Anonymous (`--daa` off); auto-accept certs (`--aa`) | Developer (per machine) | | **TwinCAT XAR runtime VM** | TwinCAT ADS testing (per `test-data-sources.md` §5; Beckhoff XAR cannot coexist with Hyper-V on the same OS) | Hyper-V VM with Windows + TwinCAT XAR installed under 7-day renewable trial | 48898 (ADS over TCP) | TwinCAT default route credentials configured per Beckhoff docs | Integration host admin | -| **OPC Foundation reference server** | OPC UA Client driver test source (per `test-data-sources.md` §"OPC UA Client") | Built from `OPCFoundation/UA-.NETStandard` `ConsoleReferenceServer` project | 62541 (default for the reference server) | Anonymous + Username (`user1` / `password1`) per the reference server's built-in user list | Integration host admin | | **FOCAS TCP stub** (`Driver.Focas.TestStub`) | FOCAS functional testing (per `test-data-sources.md` §6) | Local .NET 10 console app from this repo | 8193 (FOCAS) | n/a | Developer / integration host (run on demand) | | **FOCAS FaultShim** (`Driver.Focas.FaultShim`) | FOCAS native-fault injection (per `test-data-sources.md` §6) | Test-only native DLL named `Fwlib64.dll`, loaded via DLL search path in the test fixture | n/a (in-process) | n/a | Developer / integration host (test-only) | +### Docker fixtures — quick reference + +Every driver's integration-test simulator ships as a Docker image (or pulls +one from MCR). Start the one you need, run `dotnet test`, stop it. +Container lifecycle is always manual — fixtures TCP-probe at collection +init + skip cleanly when nothing's running. + +| Driver | Fixture image | Compose file | Bring up | +|---|---|---|---| +| Modbus | local-build `otopcua-pymodbus:3.13.0` | `tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/docker-compose.yml` | `docker compose -f --profile up -d` | +| AB CIP | local-build `otopcua-ab-server:libplctag-release` | `tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker/docker-compose.yml` | `docker compose -f --profile up -d` | +| S7 | local-build `otopcua-python-snap7:1.0` | `tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/docker-compose.yml` | `docker compose -f --profile s7_1500 up -d` | +| OpcUaClient | `mcr.microsoft.com/iotedge/opc-plc:2.14.10` (pinned) | `tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/Docker/docker-compose.yml` | `docker compose -f up -d` | + +First build of a local-build image takes 1–5 minutes; subsequent runs use +layer cache. `ab_server` is the slowest (multi-stage build clones +libplctag + compiles C). Stop with `docker compose -f --profile <…> down`. + +**Endpoint overrides** — every fixture respects an env var to point at a +real PLC instead of the simulator: + +- `MODBUS_SIM_ENDPOINT` (default `localhost:5020`) +- `AB_SERVER_ENDPOINT` (no default; when set, skips both Docker + native-binary paths) +- `S7_SIM_ENDPOINT` (default `localhost:1102`) +- `OPCUA_SIM_ENDPOINT` (default `opc.tcp://localhost:50000`) + +**Native fallbacks** — contributors who prefer not to run Docker can +launch some fixtures natively: + +- Modbus: `pip install "pymodbus[simulator]==3.13.0"` + `.\Pymodbus\serve.ps1 -Profile ` +- S7: `pip install python-snap7` + `.\PythonSnap7\serve.ps1 -Profile s7_1500` +- AB CIP: build `ab_server` from libplctag + drop on `PATH` (fixture auto-detects + spawns it) +- OpcUaClient: no native fallback documented — Docker is the simplest route. + +See each driver's `docs/drivers/*-Test-Fixture.md` for the full coverage +map + gap inventory. + ### D. Cloud / external services | Resource | Purpose | Type | Access | Owner | diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbServerFixture.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbServerFixture.cs index 0a20229..ba3a830 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbServerFixture.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbServerFixture.cs @@ -49,6 +49,19 @@ public sealed class AbServerFixture : IAsyncLifetime public async ValueTask InitializeAsync(CancellationToken cancellationToken) { + // Docker-first path: if the operator has the container running (or pointed us at a + // real PLC via AB_SERVER_ENDPOINT), TCP-probe + skip the spawn. Matches the probe + // patterns in ModbusSimulatorFixture / Snap7ServerFixture / OpcPlcFixture. + var endpointOverride = Environment.GetEnvironmentVariable("AB_SERVER_ENDPOINT"); + if (endpointOverride is not null || TcpProbe("127.0.0.1", Port)) + { + IsAvailable = true; + await Task.Delay(0, cancellationToken).ConfigureAwait(false); + return; + } + + // Fallback: no container + no override — spawn ab_server from PATH (the original + // native-binary path the existing profile CLI args target). if (LocateBinary() is not string binary) { IsAvailable = false; @@ -74,6 +87,18 @@ public sealed class AbServerFixture : IAsyncLifetime await Task.Delay(500, cancellationToken).ConfigureAwait(false); } + /// One-shot TCP probe; 500 ms budget so a missing server fails the probe fast. + private static bool TcpProbe(string host, int port) + { + try + { + using var client = new System.Net.Sockets.TcpClient(); + var task = client.ConnectAsync(host, port); + return task.Wait(TimeSpan.FromMilliseconds(500)) && client.Connected; + } + catch { return false; } + } + public ValueTask DisposeAsync(CancellationToken cancellationToken) { try @@ -89,6 +114,18 @@ public sealed class AbServerFixture : IAsyncLifetime return ValueTask.CompletedTask; } + /// + /// true when the AB CIP integration path has a live target: a Docker-run + /// container bound to 127.0.0.1:44818, an AB_SERVER_ENDPOINT env + /// override, or ab_server on PATH (native spawn fallback). + /// + public static bool IsServerAvailable() + { + if (Environment.GetEnvironmentVariable("AB_SERVER_ENDPOINT") is not null) return true; + if (TcpProbe("127.0.0.1", AbServerProfile.DefaultPort)) return true; + return LocateBinary() is not null; + } + /// /// Locate ab_server on PATH. Returns null when missing — tests that /// depend on it should use so CI runs without the binary @@ -111,29 +148,36 @@ public sealed class AbServerFixture : IAsyncLifetime } /// -/// [Fact]-equivalent that skips when ab_server is not available on PATH. -/// Integration tests use this instead of [Fact] so a developer box without -/// ab_server installed still gets a green run. +/// [Fact]-equivalent that skips when neither the Docker container nor the +/// native binary is available. Accepts: (a) a running listener on +/// localhost:44818 (the Dockerized fixture's bind), or (b) ab_server on +/// PATH for the native-spawn fallback, or (c) an explicit +/// AB_SERVER_ENDPOINT env var pointing at a real PLC. /// public sealed class AbServerFactAttribute : FactAttribute { public AbServerFactAttribute() { - if (AbServerFixture.LocateBinary() is null) - Skip = "ab_server not on PATH; install libplctag test binaries to run."; + if (!AbServerFixture.IsServerAvailable()) + Skip = "ab_server not reachable. Start the Docker container " + + "(docker compose -f Docker/docker-compose.yml --profile controllogix up) " + + "or install libplctag test binaries."; } } /// -/// [Theory]-equivalent that skips when ab_server is not on PATH. Pair with -/// [MemberData(nameof(KnownProfiles.All))]-style providers to run one theory row per -/// profile so a single test covers all four families. +/// [Theory]-equivalent with the same availability rules as +/// . Pair with +/// [MemberData(nameof(KnownProfiles.All))]-style providers to run one theory row +/// per family. /// public sealed class AbServerTheoryAttribute : TheoryAttribute { public AbServerTheoryAttribute() { - if (AbServerFixture.LocateBinary() is null) - Skip = "ab_server not on PATH; install libplctag test binaries to run."; + if (!AbServerFixture.IsServerAvailable()) + Skip = "ab_server not reachable. Start the Docker container " + + "(docker compose -f Docker/docker-compose.yml --profile controllogix up) " + + "or install libplctag test binaries."; } } diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker/Dockerfile b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker/Dockerfile new file mode 100644 index 0000000..0f25902 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker/Dockerfile @@ -0,0 +1,43 @@ +# ab_server container for the AB CIP integration suite. +# +# ab_server is a C program in libplctag/libplctag under src/tools/ab_server. +# We clone at a pinned commit, build just the ab_server target via CMake, +# and copy the resulting binary into a slim runtime stage so the published +# image stays small (~60MB vs ~350MB for the build stage). + +# -------- stage 1: build ab_server from source -------- +FROM debian:bookworm-slim AS build + +RUN apt-get update && apt-get install -y --no-install-recommends \ + git \ + build-essential \ + cmake \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Pinned tag matches the `ab_server` version AbServerFixture + CI treat as +# canonical. Bump deliberately alongside a driver-side change that needs +# something newer. +ARG LIBPLCTAG_TAG=release +RUN git clone --depth 1 --branch "${LIBPLCTAG_TAG}" https://github.com/libplctag/libplctag.git /src + +WORKDIR /src +RUN cmake -S . -B build -DCMAKE_BUILD_TYPE=Release \ + && cmake --build build --target ab_server --parallel + +# -------- stage 2: runtime -------- +FROM debian:bookworm-slim + +LABEL org.opencontainers.image.source="https://github.com/dohertj2/lmxopcua" \ + org.opencontainers.image.description="libplctag ab_server for OtOpcUa AB CIP driver integration tests" + +# libplctag's ab_server is statically linked against libc / libstdc++ on +# Debian bookworm; no runtime dependencies beyond what the slim image +# already has. +COPY --from=build /src/build/bin_dist/ab_server /usr/local/bin/ab_server + +EXPOSE 44818 + +# docker-compose.yml overrides the command with per-family flags. +CMD ["ab_server", "--plc=ControlLogix", "--path=1,0", "--port=44818", \ + "--tag=TestDINT:DINT[1]", "--tag=TestREAL:REAL[1]", "--tag=TestBOOL:BOOL[1]"] diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker/README.md b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker/README.md new file mode 100644 index 0000000..65a4c04 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker/README.md @@ -0,0 +1,99 @@ +# AB CIP integration-test fixture — `ab_server` (Docker) + +[libplctag](https://github.com/libplctag/libplctag)'s `ab_server` — a +MIT-licensed C program that emulates a ControlLogix / CompactLogix CIP +endpoint over EtherNet/IP. Docker is the primary launcher because +`ab_server` otherwise requires per-OS build-from-source (libplctag ships +it as a source-only tool under `src/tools/ab_server/`). The existing +native-binary-on-PATH path via `AbServerFixture.LocateBinary` stays as a +fallback for contributors who've already built it locally. + +| File | Purpose | +|---|---| +| [`Dockerfile`](Dockerfile) | Multi-stage: build from libplctag at pinned tag → copy binary into `debian:bookworm-slim` runtime image | +| [`docker-compose.yml`](docker-compose.yml) | One service per family (`controllogix` / `compactlogix` / `micro800` / `guardlogix`); all bind `:44818` | + +## Run + +From the repo root: + +```powershell +# ControlLogix — widest-coverage profile +docker compose -f tests\ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests\Docker\docker-compose.yml --profile controllogix up + +# Per-family +docker compose -f tests\...\Docker\docker-compose.yml --profile compactlogix up +docker compose -f tests\...\Docker\docker-compose.yml --profile micro800 up +docker compose -f tests\...\Docker\docker-compose.yml --profile guardlogix up +``` + +Detached + stop: + +```powershell +docker compose -f tests\...\Docker\docker-compose.yml --profile controllogix up -d +docker compose -f tests\...\Docker\docker-compose.yml --profile controllogix down +``` + +First run builds the image (~3-5 minutes — clones libplctag + compiles +`ab_server` + its dependencies). Subsequent runs are fast because the +multi-stage build layer-caches the checkout + compile. + +## Endpoint + +- Default: `localhost:44818` (EtherNet/IP standard; non-privileged) +- No env-var override in the fixture today — add one if pointing at a + real PLC becomes a use case. + +## Run the integration tests + +In a separate shell with a container up: + +```powershell +cd C:\Users\dohertj2\Desktop\lmxopcua +dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests +``` + +`AbServerFixture` resolves `ab_server` off `PATH` — but when the Docker +container is running, the tests dial `127.0.0.1:44818` directly through +the libplctag client so the on-PATH lookup is effectively informational. +Tests skip via `[AbServerFact]` / `[AbServerTheory]` when the binary +isn't on PATH + the container's not running. + +## What each family seeds + +Tag sets match `AbServerProfile.cs` exactly — changing seeds in one +place means updating both. + +| Family | Seeded tags | Notes | +|---|---|---| +| ControlLogix | `TestDINT` `TestREAL` `TestBOOL` `TestSINT` `TestString` `TestArray` | Widest-coverage; PR 9 baseline. UDT emulation missing from ab_server | +| CompactLogix | `TestDINT` `TestREAL` `TestBOOL` | Narrow ConnectionSize cap enforced driver-side; ab_server accepts any size | +| Micro800 | `TestDINT` `TestREAL` | ab_server has no `micro800` mode; falls back to `controllogix` emulation | +| GuardLogix | `TestDINT` `SafetyDINT_S` | ab_server has no safety subsystem; `_S` suffix triggers driver-side classification only | + +## Known limitations + +- **No UDT / CIP Template Object emulation** — `ab_server` covers atomic + types only. UDT reads + task #194 whole-UDT optimization verify via + unit tests with golden byte buffers. +- **Family-specific quirks trust driver-side code** — ab_server emulates + a generic Logix CPU; the ConnectionSize cap, empty-path unconnected + mode, and safety-partition write rejection all need lab rigs for + wire-level proof. + +See [`docs/drivers/AbServer-Test-Fixture.md`](../../../docs/drivers/AbServer-Test-Fixture.md) +for the full coverage map. + +## Native-binary fallback + +`AbServerFixture.LocateBinary` checks `PATH` for `ab_server` / +`ab_server.exe`. Build from source via +[libplctag's build docs](https://github.com/libplctag/libplctag/blob/release/BUILD.md) +and drop the binary on `PATH` to use it. Kept for contributors who've +already built libplctag locally. Docker is the reproducible path. + +## References + +- [libplctag on GitHub](https://github.com/libplctag/libplctag) +- [`docs/drivers/AbServer-Test-Fixture.md`](../../../docs/drivers/AbServer-Test-Fixture.md) — coverage map +- [`docs/v2/dev-environment.md`](../../../docs/v2/dev-environment.md) §Docker fixtures diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker/docker-compose.yml b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker/docker-compose.yml new file mode 100644 index 0000000..a9b56f2 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker/docker-compose.yml @@ -0,0 +1,97 @@ +# AB CIP integration-test fixture — ab_server (libplctag). +# +# One service per family. All bind :44818 on the host; only one runs at a +# time. Commands mirror the CLI args AbServerProfile.cs constructs for the +# native-binary path. +# +# Usage: +# docker compose --profile controllogix up +# docker compose --profile compactlogix up +# docker compose --profile micro800 up +# docker compose --profile guardlogix up +services: + controllogix: + profiles: ["controllogix"] + build: + context: . + dockerfile: Dockerfile + image: otopcua-ab-server:libplctag-release + container_name: otopcua-ab-server-controllogix + restart: "no" + ports: + - "44818:44818" + command: [ + "ab_server", + "--plc=ControlLogix", + "--path=1,0", + "--port=44818", + "--tag=TestDINT:DINT[1]", + "--tag=TestREAL:REAL[1]", + "--tag=TestBOOL:BOOL[1]", + "--tag=TestSINT:SINT[1]", + "--tag=TestString:STRING[1]", + "--tag=TestArray:DINT[16]" + ] + + compactlogix: + profiles: ["compactlogix"] + image: otopcua-ab-server:libplctag-release + build: + context: . + dockerfile: Dockerfile + container_name: otopcua-ab-server-compactlogix + restart: "no" + ports: + - "44818:44818" + # ab_server doesn't distinguish CompactLogix from ControlLogix — no + # dedicated --plc mode. Driver-side ConnectionSize cap is enforced + # separately (see AbServerProfile.CompactLogix Notes). + command: [ + "ab_server", + "--plc=ControlLogix", + "--path=1,0", + "--port=44818", + "--tag=TestDINT:DINT[1]", + "--tag=TestREAL:REAL[1]", + "--tag=TestBOOL:BOOL[1]" + ] + + micro800: + profiles: ["micro800"] + image: otopcua-ab-server:libplctag-release + build: + context: . + dockerfile: Dockerfile + container_name: otopcua-ab-server-micro800 + restart: "no" + ports: + - "44818:44818" + # ab_server does have a Micro800 plc mode (unconnected-only, empty path). + command: [ + "ab_server", + "--plc=Micro800", + "--port=44818", + "--tag=TestDINT:DINT[1]", + "--tag=TestREAL:REAL[1]" + ] + + guardlogix: + profiles: ["guardlogix"] + image: otopcua-ab-server:libplctag-release + build: + context: . + dockerfile: Dockerfile + container_name: otopcua-ab-server-guardlogix + restart: "no" + ports: + - "44818:44818" + # ab_server has no safety subsystem — _S suffix triggers driver-side + # classification only. + command: [ + "ab_server", + "--plc=ControlLogix", + "--path=1,0", + "--port=44818", + "--tag=TestDINT:DINT[1]", + "--tag=SafetyDINT_S:DINT[1]" + ] diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.csproj b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.csproj index 26e818e..3f97c87 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.csproj +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.csproj @@ -23,6 +23,10 @@ + + + + diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/Dockerfile b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/Dockerfile new file mode 100644 index 0000000..3551e91 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/Dockerfile @@ -0,0 +1,27 @@ +# pymodbus simulator container for the Modbus integration suite. +# +# Pinned base + package version so the fixture surface is reproducible — +# matches the version referenced in tests/.../Pymodbus/README.md. +FROM python:3.12-slim-bookworm + +LABEL org.opencontainers.image.source="https://github.com/dohertj2/lmxopcua" \ + org.opencontainers.image.description="pymodbus simulator for OtOpcUa Modbus driver integration tests" + +RUN pip install --no-cache-dir "pymodbus[simulator]==3.13.0" + +# Ship every profile in the image so one container can serve whichever +# family a test run needs; the compose file picks which JSON is active via +# the command override. +WORKDIR /fixtures +COPY profiles/ /fixtures/ + +EXPOSE 5020 + +# Default to the standard profile; docker-compose.yml overrides per service. +# --http_port intentionally omitted; pymodbus 3.13's web UI binds on a +# container-local default we don't publish, so it's not reachable from the +# host and costs nothing. +CMD ["pymodbus.simulator", \ + "--modbus_server", "srv", \ + "--modbus_device", "dev", \ + "--json_file", "/fixtures/standard.json"] diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/README.md b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/README.md new file mode 100644 index 0000000..a9879b1 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/README.md @@ -0,0 +1,75 @@ +# Modbus integration-test fixture — pymodbus simulator (Docker) + +The Modbus driver's integration tests talk to a +[`pymodbus`](https://pymodbus.readthedocs.io/) simulator. Docker is the +primary launcher — one pinned image, per-profile service in compose, same +port binding (`5020`) regardless of which profile is live. A native-Python +fallback lives under [`../Pymodbus/`](../Pymodbus/) for contributors who +don't want to run Docker locally. + +| File | Purpose | +|---|---| +| [`Dockerfile`](Dockerfile) | `python:3.12-slim-bookworm` + `pymodbus[simulator]==3.13.0` + the four profile JSONs | +| [`docker-compose.yml`](docker-compose.yml) | One service per profile (`standard` / `dl205` / `mitsubishi` / `s7_1500`); all bind `:5020` so only one runs at a time | +| [`profiles/*.json`](profiles/) | Same seed-register definitions the native launcher uses — canonical source | + +## Run + +From the repo root: + +```powershell +# Build + start the standard profile +docker compose -f tests\ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests\Docker\docker-compose.yml --profile standard up + +# DL205 quirks +docker compose -f tests\ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests\Docker\docker-compose.yml --profile dl205 up + +# Mitsubishi MELSEC quirks +docker compose -f tests\ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests\Docker\docker-compose.yml --profile mitsubishi up + +# Siemens S7-1500 MB_SERVER quirks +docker compose -f tests\ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests\Docker\docker-compose.yml --profile s7_1500 up +``` + +Detached + stop: + +```powershell +docker compose -f tests\...\Docker\docker-compose.yml --profile dl205 up -d +docker compose -f tests\...\Docker\docker-compose.yml --profile dl205 down +``` + +Only one profile binds `:5020` at a time; switch by stopping the current +service + starting another. The integration tests discriminate by a +separate `MODBUS_SIM_PROFILE` env var so they skip correctly when the +wrong profile is live. + +## Endpoint + +- Default: `localhost:5020` +- Override with `MODBUS_SIM_ENDPOINT` (e.g. a real PLC on `:502`). + +## Run the integration tests + +In a separate shell with one profile live: + +```powershell +cd C:\Users\dohertj2\Desktop\lmxopcua +dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests +``` + +`ModbusSimulatorFixture` probes `localhost:5020` at collection init + +records a `SkipReason` when unreachable, so tests stay green on a fresh +clone without Docker running. + +## Native-Python fallback + +See [`../Pymodbus/README.md`](../Pymodbus/README.md) + `serve.ps1` — the +same profiles, launched via local Python install. Kept for contributors +who prefer it. Docker is the reproducible path; if CI results disagree +with a local native run, Docker is the source of truth. + +## References + +- [`../Pymodbus/README.md`](../Pymodbus/README.md) — same fixture, native launcher +- [`docs/drivers/Modbus-Test-Fixture.md`](../../../docs/drivers/Modbus-Test-Fixture.md) — coverage map + gap inventory +- [`docs/v2/dev-environment.md`](../../../docs/v2/dev-environment.md) §Docker fixtures — full fixture inventory diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/docker-compose.yml b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/docker-compose.yml new file mode 100644 index 0000000..149ebd5 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/docker-compose.yml @@ -0,0 +1,79 @@ +# Modbus integration-test fixture — pymodbus simulator. +# +# One service per profile. Bring up only the profile a test class needs; +# they all bind :5020 on the host so can't run concurrently. The compose +# `profiles:` feature gates which service spins up via `--profile `. +# +# Usage: +# docker compose --profile standard up +# docker compose --profile dl205 up +# docker compose --profile mitsubishi up +# docker compose --profile s7_1500 up +services: + standard: + profiles: ["standard"] + build: + context: . + dockerfile: Dockerfile + image: otopcua-pymodbus:3.13.0 + container_name: otopcua-pymodbus-standard + restart: "no" + ports: + - "5020:5020" + command: [ + "pymodbus.simulator", + "--modbus_server", "srv", + "--modbus_device", "dev", + "--json_file", "/fixtures/standard.json" + ] + + dl205: + profiles: ["dl205"] + image: otopcua-pymodbus:3.13.0 + build: + context: . + dockerfile: Dockerfile + container_name: otopcua-pymodbus-dl205 + restart: "no" + ports: + - "5020:5020" + command: [ + "pymodbus.simulator", + "--modbus_server", "srv", + "--modbus_device", "dev", + "--json_file", "/fixtures/dl205.json" + ] + + mitsubishi: + profiles: ["mitsubishi"] + image: otopcua-pymodbus:3.13.0 + build: + context: . + dockerfile: Dockerfile + container_name: otopcua-pymodbus-mitsubishi + restart: "no" + ports: + - "5020:5020" + command: [ + "pymodbus.simulator", + "--modbus_server", "srv", + "--modbus_device", "dev", + "--json_file", "/fixtures/mitsubishi.json" + ] + + s7_1500: + profiles: ["s7_1500"] + image: otopcua-pymodbus:3.13.0 + build: + context: . + dockerfile: Dockerfile + container_name: otopcua-pymodbus-s7_1500 + restart: "no" + ports: + - "5020:5020" + command: [ + "pymodbus.simulator", + "--modbus_server", "srv", + "--modbus_device", "dev", + "--json_file", "/fixtures/s7_1500.json" + ] diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/profiles/dl205.json b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/profiles/dl205.json new file mode 100644 index 0000000..f39abd2 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/profiles/dl205.json @@ -0,0 +1,111 @@ +{ + "_comment": "DL205.json — DirectLOGIC DL205/DL260 quirk simulator. Models docs/v2/dl205.md as concrete register values. NOTE: pymodbus rejects unknown keys at device-list / setup level; explanatory comments live at top-level _comment + in README + git. Inline _quirk keys WITHIN individual register entries are accepted by pymodbus 3.13.0 (it only validates addr / value / action / parameters per entry). Each quirky uint16 is a pre-computed raw 16-bit value; pymodbus serves it verbatim. shared blocks=true matches DL series memory model. write list mirrors each seeded block — pymodbus rejects sweeping write ranges that include undefined cells.", + + "server_list": { + "srv": { + "comm": "tcp", + "host": "0.0.0.0", + "port": 5020, + "framer": "socket", + "device_id": 1 + } + }, + + "device_list": { + "dev": { + "setup": { + "co size": 16384, + "di size": 8192, + "hr size": 16384, + "ir size": 1024, + "shared blocks": true, + "type exception": false, + "defaults": { + "value": {"bits": 0, "uint16": 0, "uint32": 0, "float32": 0.0, "string": " "}, + "action": {"bits": null, "uint16": null, "uint32": null, "float32": null, "string": null} + } + }, + "invalid": [], + "write": [ + [0, 0], + [200, 209], + [1024, 1024], + [1040, 1042], + [1056, 1057], + [1072, 1072], + [1280, 1282], + [1343, 1343], + [1407, 1407], + [1, 1], + [128, 128], + [192, 192], + [250, 250], + [8448, 8448] + ], + + "uint16": [ + {"_quirk": "V0 marker. HR[0]=0xCAFE proves register 0 is valid on DL205/DL260 (rejects-register-0 was a DL05/DL06 relative-mode artefact). 0xCAFE = 51966.", + "addr": 0, "value": 51966}, + + {"_quirk": "Scratch HR range 200..209 — mirrors the standard.json scratch range so the smoke test (DL205Profile.SmokeHoldingRegister=200) round-trips identically against either profile.", + "addr": 200, "value": 0}, + {"addr": 201, "value": 0}, + {"addr": 202, "value": 0}, + {"addr": 203, "value": 0}, + {"addr": 204, "value": 0}, + {"addr": 205, "value": 0}, + {"addr": 206, "value": 0}, + {"addr": 207, "value": 0}, + {"addr": 208, "value": 0}, + {"addr": 209, "value": 0}, + + {"_quirk": "V2000 marker. V2000 octal = decimal 1024 = PDU 0x0400. Marker 0x2000 = 8192.", + "addr": 1024, "value": 8192}, + + {"_quirk": "V40400 marker. V40400 octal = decimal 8448 = PDU 0x2100 (NOT register 0). Marker 0x4040 = 16448.", + "addr": 8448, "value": 16448}, + + {"_quirk": "String 'Hello' first char in LOW byte. HR[0x410] = 'H'(0x48) lo + 'e'(0x65) hi = 0x6548 = 25928.", + "addr": 1040, "value": 25928}, + {"_quirk": "String 'Hello' second char-pair: 'l'(0x6C) lo + 'l'(0x6C) hi = 0x6C6C = 27756.", + "addr": 1041, "value": 27756}, + {"_quirk": "String 'Hello' third char-pair: 'o'(0x6F) lo + null(0x00) hi = 0x006F = 111.", + "addr": 1042, "value": 111}, + + {"_quirk": "Float32 1.5f in CDAB word order. IEEE 754 1.5 = 0x3FC00000. CDAB = low word first: HR[0x420]=0x0000, HR[0x421]=0x3FC0=16320.", + "addr": 1056, "value": 0}, + {"_quirk": "Float32 1.5f CDAB high word.", + "addr": 1057, "value": 16320}, + + {"_quirk": "BCD register. Decimal 1234 stored as BCD nibbles 0x1234 = 4660. NOT binary 1234 (= 0x04D2).", + "addr": 1072, "value": 4660}, + + {"_quirk": "FC03 cap test marker — first cell of a 128-register span the FC03 cap test reads. Other cells in the span aren't seeded explicitly, so reads of HR[1283..1342] / 1344..1406 return the default 0; the seeded markers at 1280, 1281, 1282, 1343, 1407 prove the span boundaries.", + "addr": 1280, "value": 0}, + {"addr": 1281, "value": 1}, + {"addr": 1282, "value": 2}, + {"addr": 1343, "value": 63}, + {"addr": 1407, "value": 127} + ], + + "bits": [ + {"_quirk": "X-input bank marker cell. X0 -> DI 0 conflicts with uint16 V0 at cell 0, so this marker covers X20 octal (= decimal 16 = DI 16 = cell 1 bit 0). X20=ON, X23 octal (DI 19 = cell 1 bit 3)=ON -> cell 1 value = 0b00001001 = 9.", + "addr": 1, "value": 9}, + + {"_quirk": "Y-output bank marker cell. pymodbus's simulator maps Modbus FC01/02/05 bit-addresses to cell index = bit_addr / 16; so Modbus coil 2048 lives at cell 128 bit 0. Y0=ON (bit 0), Y1=OFF (bit 1), Y2=ON (bit 2) -> value=0b00000101=5 proves DL260 mapping Y0 -> coil 2048.", + "addr": 128, "value": 5}, + + {"_quirk": "C-relay bank marker cell. Modbus coil 3072 -> cell 192 bit 0. C0=ON (bit 0), C1=OFF (bit 1), C2=ON (bit 2) -> value=5 proves DL260 mapping C0 -> coil 3072.", + "addr": 192, "value": 5}, + + {"_quirk": "Scratch cell for coil 4000..4015 write round-trip tests. Cell 250 holds Modbus coils 4000-4015; all bits start at 0 and tests set specific bits via FC05.", + "addr": 250, "value": 0} + ], + + "uint32": [], + "float32": [], + "string": [], + "repeat": [] + } + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/profiles/mitsubishi.json b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/profiles/mitsubishi.json new file mode 100644 index 0000000..53fa56c --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/profiles/mitsubishi.json @@ -0,0 +1,83 @@ +{ + "_comment": "mitsubishi.json -- Mitsubishi MELSEC Modbus TCP quirk simulator covering QJ71MT91, iQ-R, iQ-F/FX5U, and FX3U-ENET-P502 behaviors documented in docs/v2/mitsubishi.md. MELSEC CPUs store multi-word values in CDAB order (opposite of S7 ABCD, same family as DL260). The Modbus-module 'Modbus Device Assignment Parameter' block is per-site, so this profile models one *representative* assignment mapping D-register D0..D1023 -> HR 0..1023, M-relay M0..M511 -> coil 0..511, X-input X0..X15 -> DI 0..15 (X-addresses are HEX on Q/L/iQ-R, so X10 = decimal 16; on FX/iQ-F they're OCTAL like DL260). pymodbus bit-address semantics are the same as dl205.json and s7_1500.json (FC01/02/05/15 address N maps to cell index N/16).", + + "server_list": { + "srv": { + "comm": "tcp", + "host": "0.0.0.0", + "port": 5020, + "framer": "socket", + "device_id": 1 + } + }, + + "device_list": { + "dev": { + "setup": { + "co size": 4096, + "di size": 4096, + "hr size": 4096, + "ir size": 1024, + "shared blocks": true, + "type exception": false, + "defaults": { + "value": {"bits": 0, "uint16": 0, "uint32": 0, "float32": 0.0, "string": " "}, + "action": {"bits": null, "uint16": null, "uint32": null, "float32": null, "string": null} + } + }, + "invalid": [], + "write": [ + [0, 0], + [10, 10], + [100, 101], + [200, 209], + [300, 301], + [500, 500] + ], + + "uint16": [ + {"_quirk": "D0 fingerprint marker. MELSEC D0 is the first data register; Modbus Device Assignment typically maps D0..D1023 -> HR 0..1023. 0x1234 is the fingerprint operators set in GX Works to prove the mapping parameter block is in effect.", + "addr": 0, "value": 4660}, + + {"_quirk": "Scratch HR range 200..209 -- mirrors the dl205/s7_1500/standard scratch range so smoke tests (MitsubishiProfile.SmokeHoldingRegister=200) round-trip identically against any profile.", + "addr": 200, "value": 0}, + {"addr": 201, "value": 0}, + {"addr": 202, "value": 0}, + {"addr": 203, "value": 0}, + {"addr": 204, "value": 0}, + {"addr": 205, "value": 0}, + {"addr": 206, "value": 0}, + {"addr": 207, "value": 0}, + {"addr": 208, "value": 0}, + {"addr": 209, "value": 0}, + + {"_quirk": "Float32 1.5f in CDAB word order (MELSEC Q/L/iQ-R/iQ-F default, same as DL260). HR[100]=0x0000=0 low word, HR[101]=0x3FC0=16320 high word. Decode with ByteOrder.WordSwap returns 1.5f; BigEndian decode returns a denormal.", + "addr": 100, "value": 0}, + {"addr": 101, "value": 16320}, + + {"_quirk": "Int32 0x12345678 in CDAB word order. HR[300]=0x5678=22136 low word, HR[301]=0x1234=4660 high word. Contrasts with the S7 profile's ABCD encoding at the same address.", + "addr": 300, "value": 22136}, + {"addr": 301, "value": 4660}, + + {"_quirk": "D10 = decimal 1234 stored as BINARY (NOT BCD like DL205). 0x04D2 = 1234 decimal. Caller reading with Bcd16 data type would decode this as binary 1234's BCD nibbles which are non-BCD and throw InvalidDataException -- proves MELSEC is binary-by-default, opposite of DL205's BCD-by-default quirk.", + "addr": 10, "value": 1234}, + + {"_quirk": "Modbus Device Assignment boundary marker. HR[500] represents the last register in an assigned D-range D500. Beyond this (HR[501..4095]) would be Illegal Data Address on a real QJ71MT91 with this specific parameter block; pymodbus returns default 0 because its shared cell array has space -- real-PLC parity is documented in docs/v2/mitsubishi.md §device-assignment, not enforced here.", + "addr": 500, "value": 500} + ], + + "bits": [ + {"_quirk": "M-relay marker cell at cell 32 = Modbus coil 512 = MELSEC M512 (coils 0..15 collide with the D0 uint16 marker cell, so we place the M marker above that). Cell 32 bit 0 = 1 and bit 2 = 1 (value = 0b101 = 5) = M512=ON, M513=OFF, M514=ON. Matches the Y0/Y2 marker pattern in dl205 and s7_1500 profiles.", + "addr": 32, "value": 5}, + + {"_quirk": "X-input marker cell at cell 33 = Modbus DI 528 (= MELSEC X210 hex on Q/L/iQ-R). Cell 33 bit 0 = 1 and bit 3 = 1 (value = 0x9 = 9). Chosen above cell 1 so it doesn't collide with any uint16 D-register. Proves the hex-parsing X-input helper on Q/L/iQ-R family; FX/iQ-F families use octal X-addresses tested separately.", + "addr": 33, "value": 9} + ], + + "uint32": [], + "float32": [], + "string": [], + "repeat": [] + } + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/profiles/s7_1500.json b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/profiles/s7_1500.json new file mode 100644 index 0000000..d4f8a2b --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/profiles/s7_1500.json @@ -0,0 +1,77 @@ +{ + "_comment": "s7_1500.json -- Siemens SIMATIC S7-1500 + MB_SERVER quirk simulator. Models docs/v2/s7.md behaviors as concrete register values. Unlike DL260 (CDAB word order default) or Mitsubishi (CDAB default), S7 MB_SERVER uses ABCD word order by default because Siemens native CPU types are big-endian top-to-bottom both within the register pair and byte pair. This profile exists so the driver's S7 profile default ByteOrder.BigEndian can be validated end-to-end. pymodbus bit-address semantics are the same as dl205.json (FC01/02/05/15 address X maps to cell index X/16); seed bits at the appropriate cell-indexed positions.", + + "server_list": { + "srv": { + "comm": "tcp", + "host": "0.0.0.0", + "port": 5020, + "framer": "socket", + "device_id": 1 + } + }, + + "device_list": { + "dev": { + "setup": { + "co size": 4096, + "di size": 4096, + "hr size": 4096, + "ir size": 1024, + "shared blocks": true, + "type exception": false, + "defaults": { + "value": {"bits": 0, "uint16": 0, "uint32": 0, "float32": 0.0, "string": " "}, + "action": {"bits": null, "uint16": null, "uint32": null, "float32": null, "string": null} + } + }, + "invalid": [], + "write": [ + [0, 0], + [25, 25], + [100, 101], + [200, 209], + [300, 301] + ], + + "uint16": [ + {"_quirk": "DB1 header marker. On an S7-1500 with MB_SERVER pointing at DB1, operators often reserve DB1.DBW0 for a fingerprint word so clients can verify they're talking to the right DB. 0xABCD = 43981.", + "addr": 0, "value": 43981}, + + {"_quirk": "Scratch HR range 200..209 -- mirrors the standard.json scratch range so the smoke test (S7_1500Profile.SmokeHoldingRegister=200) round-trips identically against either profile.", + "addr": 200, "value": 0}, + {"addr": 201, "value": 0}, + {"addr": 202, "value": 0}, + {"addr": 203, "value": 0}, + {"addr": 204, "value": 0}, + {"addr": 205, "value": 0}, + {"addr": 206, "value": 0}, + {"addr": 207, "value": 0}, + {"addr": 208, "value": 0}, + {"addr": 209, "value": 0}, + + {"_quirk": "Float32 1.5f in ABCD word order (Siemens big-endian default, OPPOSITE of DL260 CDAB). IEEE-754 1.5 = 0x3FC00000. ABCD = high word first: HR[100]=0x3FC0=16320, HR[101]=0x0000=0.", + "addr": 100, "value": 16320}, + {"_quirk": "Float32 1.5f ABCD low word.", + "addr": 101, "value": 0}, + + {"_quirk": "Int32 0x12345678 in ABCD word order. HR[300]=0x1234=4660, HR[301]=0x5678=22136. Demonstrates the contrast with DL260 CDAB Int32 encoding.", + "addr": 300, "value": 4660}, + {"addr": 301, "value": 22136} + ], + + "bits": [ + {"_quirk": "Coil bank marker cell. S7 MB_SERVER doesn't fix coil addresses; this simulates a user-wired DB where coil 400 (=bit 0 of cell 25) represents a latched digital output. Cell 25 bit 0 = 1 proves the wire-format round-trip works for coils on S7 profile.", + "addr": 25, "value": 1}, + + {"_quirk": "Discrete-input bank marker cell. DI 500 (=bit 0 of cell 31) = 1. Like coils, discrete inputs on S7 MB_SERVER are per-site; we assert the end-to-end FC02 path only.", + "addr": 31, "value": 1} + ], + + "uint32": [], + "float32": [], + "string": [], + "repeat": [] + } + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/profiles/standard.json b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/profiles/standard.json new file mode 100644 index 0000000..5d9b63a --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/profiles/standard.json @@ -0,0 +1,97 @@ +{ + "_comment": "Standard.json — generic Modbus TCP server for the integration suite. See ../README.md. NOTE: pymodbus rejects unknown keys at device-list / setup level; explanatory comments live in the README + git history. Layout: HR[0..31]=address-as-value, HR[100]=auto-increment, HR[200..209]=scratch, coils 1024..1055=alternating, coils 1100..1109=scratch. Coils live at 1024+ because pymodbus stores all 4 standard tables in ONE underlying cell array — bits and uint16 at the same address conflict (each cell can only be typed once).", + + "server_list": { + "srv": { + "comm": "tcp", + "host": "0.0.0.0", + "port": 5020, + "framer": "socket", + "device_id": 1 + } + }, + + "device_list": { + "dev": { + "setup": { + "co size": 2048, + "di size": 2048, + "hr size": 2048, + "ir size": 2048, + "shared blocks": true, + "type exception": false, + "defaults": { + "value": {"bits": 0, "uint16": 0, "uint32": 0, "float32": 0.0, "string": " "}, + "action": {"bits": null, "uint16": null, "uint32": null, "float32": null, "string": null} + } + }, + "invalid": [], + "write": [ + [0, 31], + [100, 100], + [200, 209], + [1024, 1055], + [1100, 1109] + ], + + "uint16": [ + {"addr": 0, "value": 0}, {"addr": 1, "value": 1}, + {"addr": 2, "value": 2}, {"addr": 3, "value": 3}, + {"addr": 4, "value": 4}, {"addr": 5, "value": 5}, + {"addr": 6, "value": 6}, {"addr": 7, "value": 7}, + {"addr": 8, "value": 8}, {"addr": 9, "value": 9}, + {"addr": 10, "value": 10}, {"addr": 11, "value": 11}, + {"addr": 12, "value": 12}, {"addr": 13, "value": 13}, + {"addr": 14, "value": 14}, {"addr": 15, "value": 15}, + {"addr": 16, "value": 16}, {"addr": 17, "value": 17}, + {"addr": 18, "value": 18}, {"addr": 19, "value": 19}, + {"addr": 20, "value": 20}, {"addr": 21, "value": 21}, + {"addr": 22, "value": 22}, {"addr": 23, "value": 23}, + {"addr": 24, "value": 24}, {"addr": 25, "value": 25}, + {"addr": 26, "value": 26}, {"addr": 27, "value": 27}, + {"addr": 28, "value": 28}, {"addr": 29, "value": 29}, + {"addr": 30, "value": 30}, {"addr": 31, "value": 31}, + + {"addr": 100, "value": 0, + "action": "increment", + "parameters": {"minval": 0, "maxval": 65535}}, + + {"addr": 200, "value": 0}, {"addr": 201, "value": 0}, + {"addr": 202, "value": 0}, {"addr": 203, "value": 0}, + {"addr": 204, "value": 0}, {"addr": 205, "value": 0}, + {"addr": 206, "value": 0}, {"addr": 207, "value": 0}, + {"addr": 208, "value": 0}, {"addr": 209, "value": 0} + ], + + "bits": [ + {"addr": 1024, "value": 1}, {"addr": 1025, "value": 0}, + {"addr": 1026, "value": 1}, {"addr": 1027, "value": 0}, + {"addr": 1028, "value": 1}, {"addr": 1029, "value": 0}, + {"addr": 1030, "value": 1}, {"addr": 1031, "value": 0}, + {"addr": 1032, "value": 1}, {"addr": 1033, "value": 0}, + {"addr": 1034, "value": 1}, {"addr": 1035, "value": 0}, + {"addr": 1036, "value": 1}, {"addr": 1037, "value": 0}, + {"addr": 1038, "value": 1}, {"addr": 1039, "value": 0}, + {"addr": 1040, "value": 1}, {"addr": 1041, "value": 0}, + {"addr": 1042, "value": 1}, {"addr": 1043, "value": 0}, + {"addr": 1044, "value": 1}, {"addr": 1045, "value": 0}, + {"addr": 1046, "value": 1}, {"addr": 1047, "value": 0}, + {"addr": 1048, "value": 1}, {"addr": 1049, "value": 0}, + {"addr": 1050, "value": 1}, {"addr": 1051, "value": 0}, + {"addr": 1052, "value": 1}, {"addr": 1053, "value": 0}, + {"addr": 1054, "value": 1}, {"addr": 1055, "value": 0}, + + {"addr": 1100, "value": 0}, {"addr": 1101, "value": 0}, + {"addr": 1102, "value": 0}, {"addr": 1103, "value": 0}, + {"addr": 1104, "value": 0}, {"addr": 1105, "value": 0}, + {"addr": 1106, "value": 0}, {"addr": 1107, "value": 0}, + {"addr": 1108, "value": 0}, {"addr": 1109, "value": 0} + ], + + "uint32": [], + "float32": [], + "string": [], + "repeat": [] + } + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.csproj b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.csproj index 81c5eae..2c2ceaf 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.csproj +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.csproj @@ -25,6 +25,7 @@ + diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/Dockerfile b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/Dockerfile new file mode 100644 index 0000000..59e2b49 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/Dockerfile @@ -0,0 +1,24 @@ +# python-snap7 S7 server container for the S7 integration suite. +# +# python-snap7 wraps the upstream snap7 C library; the pip install pulls +# platform-specific binaries automatically on Debian-based images. No build +# step needed — unlike ab_server which needs compiling from source. +FROM python:3.12-slim-bookworm + +LABEL org.opencontainers.image.source="https://github.com/dohertj2/lmxopcua" \ + org.opencontainers.image.description="python-snap7 S7 simulator for OtOpcUa S7 driver integration tests" + +RUN pip install --no-cache-dir "python-snap7>=2.0" + +WORKDIR /fixtures + +# server.py is the Python shim that loads a JSON profile + starts the +# snap7.server.Server; profiles/ carries the seed definitions. +COPY server.py /fixtures/ +COPY profiles/ /fixtures/ + +EXPOSE 1102 + +# -u for unbuffered stdout so `docker logs` tails the "seeded DB…" +# diagnostics without a buffer-flush delay. +CMD ["python", "-u", "/fixtures/server.py", "/fixtures/s7_1500.json", "--port", "1102"] diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/README.md b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/README.md new file mode 100644 index 0000000..9a8c443 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/README.md @@ -0,0 +1,100 @@ +# S7 integration-test fixture — python-snap7 (Docker) + +[python-snap7](https://github.com/gijzelaerr/python-snap7) `Server` class +wrapped in a pinned `python:3.12-slim-bookworm` image. Docker is the +primary launcher; the native-Python fallback under +[`../PythonSnap7/`](../PythonSnap7/) is kept for contributors who prefer +to avoid Docker locally. + +| 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\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 +``` + +## 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\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) 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). + +## 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 + +See [`docs/drivers/S7-Test-Fixture.md`](../../../docs/drivers/S7-Test-Fixture.md) +for the full coverage map. + +## Native-Python fallback + +[`../PythonSnap7/`](../PythonSnap7/) has the same `server.py` + profile +JSON launched via local Python install (`pip install python-snap7`). +Kept for contributors who prefer native. + +## References + +- [python-snap7 GitHub](https://github.com/gijzelaerr/python-snap7) +- [`../PythonSnap7/README.md`](../PythonSnap7/README.md) — native launcher +- [`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 diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/docker-compose.yml b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/docker-compose.yml new file mode 100644 index 0000000..03bb9e7 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/docker-compose.yml @@ -0,0 +1,20 @@ +# S7 integration-test fixture — python-snap7 server. +# +# One service per profile (only s7_1500 ships today; add S7-1200 / S7-300 +# as new profile JSONs drop into profiles/). All bind :1102 on the host; +# run one at a time. +# +# Usage: +# docker compose --profile s7_1500 up +services: + s7_1500: + profiles: ["s7_1500"] + build: + context: . + dockerfile: Dockerfile + image: otopcua-python-snap7:1.0 + container_name: otopcua-python-snap7-s7_1500 + restart: "no" + ports: + - "1102:1102" + command: ["python", "-u", "/fixtures/server.py", "/fixtures/s7_1500.json", "--port", "1102"] diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/profiles/s7_1500.json b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/profiles/s7_1500.json new file mode 100644 index 0000000..20d8955 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/profiles/s7_1500.json @@ -0,0 +1,35 @@ +{ + "_description": "S7-1500 profile — single DB1 (1024 bytes) + MB (256 bytes) with well-known seeds at named offsets for the smoke + byte-order + string tests. Big-endian Siemens wire order throughout.", + "areas": [ + { + "area": "DB", + "index": 1, + "size": 1024, + "seeds": [ + { "_desc": "DB1.DBW0 — read-back probe, S7Driver default ProbeAddress target is MW0; this shadows it", + "offset": 0, "type": "u16", "value": 4242 }, + { "_desc": "DB1.DBW10 — i16 smoke value for SmokeI16 read path", + "offset": 10, "type": "i16", "value": -12345 }, + { "_desc": "DB1.DBD20 — i32 smoke value for SmokeI32 read path", + "offset": 20, "type": "i32", "value": 1234567890 }, + { "_desc": "DB1.DBD30 — f32 smoke value for SmokeF32 read path (IEEE-754 big-endian)", + "offset": 30, "type": "f32", "value": 3.14159 }, + { "_desc": "DB1.DBX50.3 — bool bit at byte-50 bit-3 for SmokeBool read path", + "offset": 50, "type": "bool", "value": true, "bit": 3 }, + { "_desc": "DB1.DBW100 — scratch for write-then-read round-trip tests; seeded 0", + "offset": 100, "type": "u16", "value": 0 }, + { "_desc": "DB1.STRING[200] — S7 string 'Hello' (max 32, cur 5)", + "offset": 200, "type": "ascii", "value": "Hello", "max_len": 32 } + ] + }, + { + "area": "MK", + "index": 0, + "size": 256, + "seeds": [ + { "_desc": "MW0 — probe target for S7ProbeOptions.ProbeAddress default", + "offset": 0, "type": "u16", "value": 1 } + ] + } + ] +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/server.py b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/server.py new file mode 100644 index 0000000..ce1824b --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/server.py @@ -0,0 +1,150 @@ +"""python-snap7 S7 server for integration tests. + +Reads a JSON profile from argv[1], allocates bytearrays for each declared area +(DB / MB / EB / AB), poke-seeds values at declared offsets, then starts the +snap7 Server on the configured port + blocks until Ctrl+C. Shape intentionally +mirrors the pymodbus `serve.ps1 + *.json` pattern one directory over so +someone familiar with the Modbus fixture can read this without re-learning. + +The snap7.server.Server class is the MIT-licensed S7 PLC emulator wrapped by +python-snap7 (https://github.com/gijzelaerr/python-snap7). Its own docstring +admits "protocol compliance testing rather than full industrial-grade +functionality" — good enough for ISO-on-TCP wire-level round-trip but NOT +for S7-1500 Optimized-DB symbolic access, SCL variant-specific behaviour, or +PG/OP/S7-Basic session differentiation. +""" + +from __future__ import annotations + +import argparse +import ctypes +import json +import signal +import sys +import time +from pathlib import Path + +# python-snap7 installs as `snap7` package; Server class lives under `snap7.server`. +import snap7 +from snap7.type import SrvArea + + +# Map JSON area names → SrvArea enum values. PE = inputs (I/E), PA = outputs +# (Q/A), MK = memory (M), DB = data blocks, TM = timers, CT = counters. +AREA_MAP: dict[str, int] = { + "PE": SrvArea.PE, + "PA": SrvArea.PA, + "MK": SrvArea.MK, + "DB": SrvArea.DB, + "TM": SrvArea.TM, + "CT": SrvArea.CT, +} + + +def seed_buffer(buf: bytearray, seeds: list[dict]) -> None: + """Poke seed values into the area buffer at declared byte offsets. + + Each seed is {"offset": int, "type": str, "value": int|float|bool|str} + where type ∈ {u8, i8, u16, i16, u32, i32, f32, bool, ascii}. Endianness is + big-endian (Siemens wire format). + """ + for seed in seeds: + off = int(seed["offset"]) + t = seed["type"] + v = seed["value"] + if t == "u8": + buf[off] = int(v) & 0xFF + elif t == "i8": + buf[off] = int(v) & 0xFF + elif t == "u16": + buf[off:off + 2] = int(v).to_bytes(2, "big", signed=False) + elif t == "i16": + buf[off:off + 2] = int(v).to_bytes(2, "big", signed=True) + elif t == "u32": + buf[off:off + 4] = int(v).to_bytes(4, "big", signed=False) + elif t == "i32": + buf[off:off + 4] = int(v).to_bytes(4, "big", signed=True) + elif t == "f32": + import struct + buf[off:off + 4] = struct.pack(">f", float(v)) + elif t == "bool": + bit = int(seed.get("bit", 0)) + if bool(v): + buf[off] |= (1 << bit) + else: + buf[off] &= ~(1 << bit) & 0xFF + elif t == "ascii": + # Siemens STRING type: byte 0 = max length, byte 1 = current length, + # bytes 2+ = payload. Seeds supply the payload text; we fill max/cur. + payload = str(v).encode("ascii") + max_len = int(seed.get("max_len", 254)) + buf[off] = max_len + buf[off + 1] = len(payload) + buf[off + 2:off + 2 + len(payload)] = payload + else: + raise ValueError(f"Unknown seed type '{t}'") + + +def main() -> int: + parser = argparse.ArgumentParser(description="python-snap7 S7 server for integration tests") + parser.add_argument("profile", help="Path to profile JSON") + parser.add_argument("--port", type=int, default=1102, help="TCP port (default 1102 non-privileged)") + args = parser.parse_args() + + profile_path = Path(args.profile) + if not profile_path.is_file(): + print(f"profile not found: {profile_path}", file=sys.stderr) + return 1 + + with profile_path.open() as f: + profile = json.load(f) + + server = snap7.server.Server() + # Keep bytearray refs alive for the server's lifetime — snap7 doesn't copy + # the buffer, it takes a pointer. Letting GC collect would corrupt reads. + buffers: list[bytearray] = [] + + for area_decl in profile.get("areas", []): + area_name = area_decl["area"] + if area_name not in AREA_MAP: + print(f"unknown area '{area_name}' (expected one of {list(AREA_MAP)})", file=sys.stderr) + return 1 + index = int(area_decl.get("index", 0)) # DB number for DB area, 0 for MK/PE/PA + size = int(area_decl["size"]) + buf = bytearray(size) + seed_buffer(buf, area_decl.get("seeds", [])) + buffers.append(buf) + # register_area takes (area, index, c-array); we wrap the bytearray + # into a ctypes char array so the native lib can take &buf[0]. + arr_type = ctypes.c_char * size + arr = arr_type.from_buffer(buf) + server.register_area(AREA_MAP[area_name], index, arr) + print(f" seeded {area_name}{index} size={size} seeds={len(area_decl.get('seeds', []))}") + + port = int(args.port) + print(f"Starting python-snap7 server on TCP {port} (Ctrl+C to stop)") + server.start(tcp_port=port) + + stop = {"sig": False} + def _handle(*_a): + stop["sig"] = True + signal.signal(signal.SIGINT, _handle) + try: + signal.signal(signal.SIGTERM, _handle) + except Exception: + pass # SIGTERM not on all platforms + + try: + while not stop["sig"]: + time.sleep(0.25) + finally: + print("stopping python-snap7 server") + try: + server.stop() + except Exception: + pass + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.csproj b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.csproj index e91d714..276a9f8 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.csproj +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.csproj @@ -25,6 +25,7 @@ +