From 0e1dcc119e4e028870792ba63a8f12b3347c65b3 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 20 Apr 2026 12:27:44 -0400 Subject: [PATCH] =?UTF-8?q?Remove=20native-launcher=20fallbacks=20for=20th?= =?UTF-8?q?e=20four=20Dockerized=20fixtures=20=E2=80=94=20Docker=20is=20th?= =?UTF-8?q?e=20only=20supported=20path=20for=20Modbus=20/=20S7=20/=20AB=20?= =?UTF-8?q?CIP=20/=20OpcUaClient=20integration.=20Native=20paths=20stay=20?= =?UTF-8?q?in=20place=20only=20where=20Docker=20isn't=20compatible=20(Gala?= =?UTF-8?q?xy:=20MXAccess=20COM=20+=20Windows-only;=20TwinCAT:=20Beckhoff?= =?UTF-8?q?=20runtime=20vs=20Hyper-V;=20FOCAS:=20closed-source=20Fanuc=20F?= =?UTF-8?q?wlib32.dll;=20AB=20Legacy:=20PCCC=20has=20no=20OSS=20simulator)?= =?UTF-8?q?.=20Simplifies=20the=20fixture=20landscape=20+=20removes=20the?= =?UTF-8?q?=20"which=20path=20do=20I=20run"=20ambiguity;=20removes=20two?= =?UTF-8?q?=20full=20native-launcher=20directories=20+=20the=20AB=20CIP=20?= =?UTF-8?q?native-spawn=20path;=20removes=20the=20parallel=20profile-as-CL?= =?UTF-8?q?I-arg-builder=20code=20from=20AbServerFixture.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Modbus — deletes tests/.../Modbus.IntegrationTests/Pymodbus/ (serve.ps1, standard.json, dl205.json, mitsubishi.json, s7_1500.json, README.md). Profile JSONs live only under Docker/profiles/ now. Docker/README.md loses its "Native-Python fallback" section; docs/drivers/Modbus-Test-Fixture.md "What the fixture is" bullet flipped from "primary launcher is Docker, native fallback under Pymodbus/" to "Docker is the only supported launch path". S7 — deletes tests/.../S7.IntegrationTests/PythonSnap7/ (server.py, s7_1500.json, serve.ps1, README.md). Docker/README.md loses "Native-Python fallback"; docs/drivers/S7-Test-Fixture.md updated to match. AB CIP — the biggest simplification because the native-binary spawn had the most code. AbServerFixture.cs rewrites: drops Process management (no more Process _proc + Kill/WaitForExit), drops LocateBinary() PATH lookup, drops the IAsyncLifetime initialize-spawns-server behavior. Fixture is now a thin TCP probe against localhost:44818 (or AB_SERVER_ENDPOINT override) — same shape as Snap7ServerFixture / ModbusSimulatorFixture / OpcPlcFixture. IsServerAvailable() simplifies to a single 500 ms probe. AbServerProfile.cs drops AbServerPlcArg + SeedTags + BuildCliArgs + ToCliSpec + the entire AbServerSeedTag record — the compose file is the canonical source of truth for which tags + which --plc mode each family gets; the profile record now carries just Family + ComposeProfile (matches the docker-compose service key) + Notes. KnownProfiles.ForFamily + .All stay for tests that iterate families. AbServerProfileTests.cs rewrites to match: drops BuildCliArgs_* + ToCliSpec_* + SeedTags_* tests; keeps the family-coverage contract tests + verifies the ComposeProfile strings match compose-file service names (a typo in either surfaces as a unit-test failure, not a silent "wrong family booted" at runtime). Docker/README.md loses "Native-binary fallback" section; docs/drivers/AbServer-Test-Fixture.md "What the fixture is" flipped to Docker-only with clearer skip rules. dev-environment.md §Docker fixtures — the "Native fallbacks" subsection goes away; replaced with a one-line note that Docker is the only supported path for these four fixtures + a fresh clone needs Docker Desktop and nothing else. Verified: whole-solution build 0 errors, AB CIP profile unit tests 6/6, AB CIP Docker smoke 4/4 (all family theory rows), S7 Docker smoke 3/3. Container lifecycle clean. The deleted native code surface was already redundant — every fixture the native paths served is now covered by Docker; keeping them invited drift between the two paths (the original AB CIP native profile had three undetected bugs per the #162 commit message: case-sensitive --plc, bracket tag notation, --path=1,0 requirement — noise the Docker path now avoids by never running the buggy code). Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/drivers/AbServer-Test-Fixture.md | 28 ++- docs/drivers/Modbus-Test-Fixture.md | 10 +- docs/drivers/S7-Test-Fixture.md | 12 +- docs/v2/dev-environment.md | 10 +- .../AbServerFixture.cs | 179 ++++++------------ .../AbServerProfile.cs | 121 ++---------- .../AbServerProfileTests.cs | 72 ++----- .../Docker/README.md | 30 +-- .../Docker/README.md | 20 +- .../Pymodbus/README.md | 163 ---------------- .../Pymodbus/dl205.json | 111 ----------- .../Pymodbus/mitsubishi.json | 83 -------- .../Pymodbus/s7_1500.json | 77 -------- .../Pymodbus/serve.ps1 | 60 ------ .../Pymodbus/standard.json | 97 ---------- ...pcUa.Driver.Modbus.IntegrationTests.csproj | 1 - .../Docker/README.md | 14 +- .../PythonSnap7/README.md | 110 ----------- .../PythonSnap7/s7_1500.json | 35 ---- .../PythonSnap7/serve.ps1 | 56 ------ .../PythonSnap7/server.py | 150 --------------- ....OtOpcUa.Driver.S7.IntegrationTests.csproj | 1 - 22 files changed, 133 insertions(+), 1307 deletions(-) delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Pymodbus/README.md delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Pymodbus/dl205.json delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Pymodbus/mitsubishi.json delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Pymodbus/s7_1500.json delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Pymodbus/serve.ps1 delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Pymodbus/standard.json delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/PythonSnap7/README.md delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/PythonSnap7/s7_1500.json delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/PythonSnap7/serve.ps1 delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/PythonSnap7/server.py diff --git a/docs/drivers/AbServer-Test-Fixture.md b/docs/drivers/AbServer-Test-Fixture.md index afe77a1..d0530fa 100644 --- a/docs/drivers/AbServer-Test-Fixture.md +++ b/docs/drivers/AbServer-Test-Fixture.md @@ -13,24 +13,22 @@ quirk. UDT / alarm / quirk behavior is verified only by unit tests with - **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. +- **Launcher**: Docker (only supported path). `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` TCP-probes `127.0.0.1:44818` at + collection init + records a skip reason when unreachable. Tests skip + via `[AbServerFact]` / `[AbServerTheory]` which check the same probe. - **Profiles**: `KnownProfiles.{ControlLogix, CompactLogix, Micro800, GuardLogix}` - in `AbServerProfile.cs`. Each composes a CLI arg list + seed-tag set; the - `Docker/docker-compose.yml` `command:` entries mirror those args 1:1. + in `AbServerProfile.cs` — thin Family + ComposeProfile + Notes records; + the compose file is the canonical source of truth for which tags get + seeded + which `--plc` mode the simulator boots in. - **Tests**: one smoke, `AbCipReadSmokeTests.Driver_reads_seeded_DInt_from_ab_server`, 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. +- **Endpoint override**: `AB_SERVER_ENDPOINT=host:port` points the + fixture at a real PLC instead of the local container. ## What it actually covers diff --git a/docs/drivers/Modbus-Test-Fixture.md b/docs/drivers/Modbus-Test-Fixture.md index f1e41d3..47e01ad 100644 --- a/docs/drivers/Modbus-Test-Fixture.md +++ b/docs/drivers/Modbus-Test-Fixture.md @@ -11,18 +11,16 @@ shaped (neither is a Modbus-side concept). ## What the fixture is -- **Simulator**: `pymodbus` (Python, BSD) — primary launcher is a pinned - Docker image at +- **Simulator**: `pymodbus` (Python, BSD) launched as a pinned Docker + container at `tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/`. - Native-Python fallback under `Pymodbus/` is kept for contributors who - don't want Docker. + Docker is the only supported launch path. - **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. + Profile JSONs live under `Docker/profiles/` and are baked into the image. - **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 diff --git a/docs/drivers/S7-Test-Fixture.md b/docs/drivers/S7-Test-Fixture.md index 1e75ced..c5039fd 100644 --- a/docs/drivers/S7-Test-Fixture.md +++ b/docs/drivers/S7-Test-Fixture.md @@ -17,12 +17,12 @@ session types, PUT/GET-disabled enforcement — all need real hardware. `tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/` stands up a 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). +`python-snap7>=2.0`). Docker is the only supported launch path. +`Snap7ServerFixture` probes the port at collection init + skips with a +clear message when unreachable (matches the pymodbus pattern). +`server.py` (baked into the image under `Docker/`) 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 6d49756..f168536 100644 --- a/docs/v2/dev-environment.md +++ b/docs/v2/dev-environment.md @@ -178,13 +178,9 @@ real PLC instead of the simulator: - `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. +No native launchers — Docker is the only supported path for these +fixtures. A fresh clone needs Docker Desktop and nothing else; fixture +TCP probes skip tests cleanly when the container isn't running. See each driver's `docs/drivers/*-Test-Fixture.md` for the full coverage map + gap inventory. 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 ba3a830..fe17ff9 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbServerFixture.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbServerFixture.cs @@ -1,158 +1,91 @@ -using System.Diagnostics; +using System.Net.Sockets; using Xunit; using Xunit.Sdk; namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests; /// -/// Shared fixture that starts libplctag's ab_server simulator in the background for -/// the duration of an integration test collection. The fixture takes an -/// (see ) so each AB family — ControlLogix, -/// CompactLogix, Micro800, GuardLogix — starts the simulator with the right --plc -/// mode + preseed tag set. Binary is expected on PATH; CI resolves that via a job step -/// that downloads the pinned Windows build from libplctag GitHub Releases before -/// dotnet test — see docs/v2/test-data-sources.md §2.CI for the exact step. +/// Reachability probe for the ab_server Docker container (libplctag's CIP +/// simulator built via Docker/Dockerfile) or any real AB PLC the +/// AB_SERVER_ENDPOINT env var points at. Parses +/// AB_SERVER_ENDPOINT (default localhost:44818) + TCP-connects +/// once at fixture construction. Tests skip via +/// / when the port isn't live, so +/// dotnet test stays green on a fresh clone without Docker running. +/// Matches the / Snap7ServerFixture / +/// OpcPlcFixture shape. /// /// -/// ab_server is a C binary shipped in libplctag's repo (MIT). On developer -/// workstations it's built once from source and placed on PATH; on CI the workflow file -/// fetches a version-pinned prebuilt + stages it. Tests skip (via -/// ) when the binary is not on PATH so a fresh clone -/// without the simulator still gets a green unit-test run. -/// -/// Per-family profiles live in . When a test wants a -/// specific family, instantiate the fixture with that profile — either via a -/// derived type or by constructing directly in a -/// parametric test (the latter is used below for the smoke suite). +/// Docker is the only supported launch path — no native-binary spawn + no +/// PATH lookup. Bring the container up before dotnet test: +/// docker compose -f Docker/docker-compose.yml --profile controllogix up. /// public sealed class AbServerFixture : IAsyncLifetime { - private Process? _proc; + private const string EndpointEnvVar = "AB_SERVER_ENDPOINT"; - /// The profile the simulator was started with. Same instance the driver-side options should use. + /// The profile this fixture instance represents. Parallel family smoke tests + /// instantiate the fixture with the profile matching their compose-file service. public AbServerProfile Profile { get; } - public int Port { get; } - public bool IsAvailable { get; private set; } - public AbServerFixture() : this(KnownProfiles.ControlLogix, AbServerProfile.DefaultPort) { } + public string Host { get; } = "127.0.0.1"; + public int Port { get; } = AbServerProfile.DefaultPort; - public AbServerFixture(AbServerProfile profile) : this(profile, AbServerProfile.DefaultPort) { } + public AbServerFixture() : this(KnownProfiles.ControlLogix) { } - public AbServerFixture(AbServerProfile profile, int port) + public AbServerFixture(AbServerProfile profile) { Profile = profile ?? throw new ArgumentNullException(nameof(profile)); - Port = port; + + // Endpoint override applies to both host + port — targeting a real PLC at + // non-default host or port shouldn't need fixture changes. + if (Environment.GetEnvironmentVariable(EndpointEnvVar) is { Length: > 0 } raw) + { + var parts = raw.Split(':', 2); + Host = parts[0]; + if (parts.Length == 2 && int.TryParse(parts[1], out var p)) Port = p; + } } - public ValueTask InitializeAsync() => InitializeAsync(default); - public ValueTask DisposeAsync() => DisposeAsync(default); + public ValueTask InitializeAsync() => ValueTask.CompletedTask; + public ValueTask DisposeAsync() => ValueTask.CompletedTask; - public async ValueTask InitializeAsync(CancellationToken cancellationToken) + /// + /// true when ab_server is reachable at this fixture's Host/Port. Used by + /// / + /// to decide whether to skip tests on a fresh clone without a running container. + /// + public static bool IsServerAvailable() => + TcpProbe(ResolveHost(), ResolvePort()); + + private static string ResolveHost() => + Environment.GetEnvironmentVariable(EndpointEnvVar)?.Split(':', 2)[0] ?? "127.0.0.1"; + + private static int ResolvePort() { - // 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; - return; - } - IsAvailable = true; - - _proc = new Process - { - StartInfo = new ProcessStartInfo - { - FileName = binary, - Arguments = Profile.BuildCliArgs(Port), - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true, - }, - }; - _proc.Start(); - - // Give the server a moment to accept its listen socket before tests try to connect. - await Task.Delay(500, cancellationToken).ConfigureAwait(false); + var raw = Environment.GetEnvironmentVariable(EndpointEnvVar); + if (raw is null) return AbServerProfile.DefaultPort; + var parts = raw.Split(':', 2); + return parts.Length == 2 && int.TryParse(parts[1], out var p) ? p : AbServerProfile.DefaultPort; } - /// One-shot TCP probe; 500 ms budget so a missing server fails the probe fast. + /// One-shot TCP probe; 500 ms budget so a missing container fails the probe fast. private static bool TcpProbe(string host, int port) { try { - using var client = new System.Net.Sockets.TcpClient(); + using var client = new TcpClient(); var task = client.ConnectAsync(host, port); return task.Wait(TimeSpan.FromMilliseconds(500)) && client.Connected; } catch { return false; } } - - public ValueTask DisposeAsync(CancellationToken cancellationToken) - { - try - { - if (_proc is { HasExited: false }) - { - _proc.Kill(entireProcessTree: true); - _proc.WaitForExit(5_000); - } - } - catch { /* best-effort cleanup */ } - _proc?.Dispose(); - 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 - /// simply skip rather than fail. - /// - public static string? LocateBinary() - { - var names = new[] { "ab_server.exe", "ab_server" }; - var path = Environment.GetEnvironmentVariable("PATH") ?? ""; - foreach (var dir in path.Split(Path.PathSeparator)) - { - foreach (var name in names) - { - var candidate = Path.Combine(dir, name); - if (File.Exists(candidate)) return candidate; - } - } - return null; - } } /// -/// [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. +/// [Fact]-equivalent that skips when ab_server isn't reachable — accepts a +/// live Docker listener on localhost:44818 or an AB_SERVER_ENDPOINT +/// override pointing at a real PLC. /// public sealed class AbServerFactAttribute : FactAttribute { @@ -161,15 +94,15 @@ public sealed class AbServerFactAttribute : FactAttribute 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."; + "or set AB_SERVER_ENDPOINT to a real PLC."; } } /// /// [Theory]-equivalent with the same availability rules as /// . Pair with -/// [MemberData(nameof(KnownProfiles.All))]-style providers to run one theory row -/// per family. +/// [MemberData(nameof(KnownProfiles.All))]-style providers to run one theory +/// row per family. /// public sealed class AbServerTheoryAttribute : TheoryAttribute { @@ -178,6 +111,6 @@ public sealed class AbServerTheoryAttribute : TheoryAttribute 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."; + "or set AB_SERVER_ENDPOINT to a real PLC."; } } diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbServerProfile.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbServerProfile.cs index 8d74e37..e8d2f1d 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbServerProfile.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbServerProfile.cs @@ -3,130 +3,51 @@ using ZB.MOM.WW.OtOpcUa.Driver.AbCip; namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests; /// -/// Per-family provisioning profile for the ab_server simulator. Instead of hard-coding -/// one fixture shape + one set of CLI args, each integration test picks a profile matching the -/// family it wants to exercise — ControlLogix / CompactLogix / Micro800 / GuardLogix. The -/// profile composes the CLI arg list passed to ab_server + the tag-definition set the -/// driver uses to address the simulator's pre-provisioned tags. +/// Per-family marker for the ab_server Docker compose profile a given test +/// targets. The compose file (Docker/docker-compose.yml) is the canonical +/// source of truth for which tags a family seeds + which --plc mode the +/// simulator boots in; this record just ties a family enum to operator-facing +/// notes so fixture + test code can filter / branch by family. /// -/// OtOpcUa driver family this profile targets. Drives -/// + driver-side connection-parameter profile -/// (ConnectionSize, unconnected-only, etc.) per decision #9. -/// The value passed to ab_server --plc <arg>. Some families -/// map 1:1 (ControlLogix → "controllogix"); Micro800/GuardLogix fall back to the family whose -/// CIP behavior ab_server emulates most faithfully (see per-profile Notes). -/// Tags to preseed on the simulator via --tag <name>:<type>[:<size>] -/// flags. Each entry becomes one CLI arg; the driver-side -/// list references the same names so tests can read/write without walking the @tags surface -/// first. -/// Operator-facing description of what the profile covers + any quirks. +/// OtOpcUa driver family this profile targets. +/// The docker compose --profile name that brings +/// this family's ab_server up. Matches the service key in the compose file. +/// Operator-facing description of coverage + any quirks. public sealed record AbServerProfile( AbCipPlcFamily Family, - string AbServerPlcArg, - IReadOnlyList SeedTags, + string ComposeProfile, string Notes) { - /// Default port — every profile uses the same so parallel-runs-of-different-families - /// would conflict (deliberately — one simulator per test collection is the model). + /// Default ab_server port — matches the compose-file port-map + the + /// CIP / EtherNet/IP standard. public const int DefaultPort = 44818; - - /// Compose the full ab_server CLI arg string for - /// . - public string BuildCliArgs(int port) - { - var parts = new List - { - "--port", port.ToString(), - "--plc", AbServerPlcArg, - }; - foreach (var tag in SeedTags) - { - parts.Add("--tag"); - parts.Add(tag.ToCliSpec()); - } - return string.Join(' ', parts); - } -} - -/// One tag the simulator pre-creates. ab_server spec format: -/// <name>:<type>[:<array_size>]. -public sealed record AbServerSeedTag(string Name, string AbServerType, int? ArraySize = null) -{ - public string ToCliSpec() => ArraySize is { } n ? $"{Name}:{AbServerType}:{n}" : $"{Name}:{AbServerType}"; } /// Canonical profiles covering every AB CIP family shipped in PRs 9–12. public static class KnownProfiles { - /// - /// ControlLogix — the widest-coverage family: full CIP capabilities, generous connection - /// size, @tags controller-walk supported. Tag shape covers atomic types + a Program-scoped - /// tag so the Symbol-Object decoder's scope-split path is exercised. - /// public static readonly AbServerProfile ControlLogix = new( Family: AbCipPlcFamily.ControlLogix, - AbServerPlcArg: "controllogix", - SeedTags: new AbServerSeedTag[] - { - new("TestDINT", "DINT"), - new("TestREAL", "REAL"), - new("TestBOOL", "BOOL"), - new("TestSINT", "SINT"), - new("TestString","STRING"), - new("TestArray", "DINT", ArraySize: 16), - }, - Notes: "Widest-coverage profile — PR 9 baseline. UDTs live in PR 6-shipped Template Object tests; ab_server lacks full UDT emulation."); + ComposeProfile: "controllogix", + Notes: "Widest-coverage profile — PR 9 baseline. UDTs unit-tested via golden Template Object buffers; ab_server lacks full UDT emulation."); - /// - /// CompactLogix — narrower ConnectionSize quirk exercised here. ab_server doesn't - /// enforce the narrower limit itself; the driver-side profile caps it + this simulator - /// honors whatever the client asks for. Tag set is a subset of ControlLogix. - /// public static readonly AbServerProfile CompactLogix = new( Family: AbCipPlcFamily.CompactLogix, - AbServerPlcArg: "compactlogix", - SeedTags: new AbServerSeedTag[] - { - new("TestDINT", "DINT"), - new("TestREAL", "REAL"), - new("TestBOOL", "BOOL"), - }, - Notes: "Narrower ConnectionSize than ControlLogix — driver-side profile caps it per PR 10. Tag set mirrors the CompactLogix atomic subset."); + ComposeProfile: "compactlogix", + Notes: "ab_server doesn't enforce the narrower ConnectionSize; driver-side profile caps it per PR 10."); - /// - /// Micro800 — unconnected-only family. ab_server has no explicit micro800 plc mode so - /// we fall back to the nearest CIP-compatible emulation (controllogix) + document the - /// discrepancy. Driver-side path enforcement (empty routing path, unconnected-only - /// sessions) is exercised in the unit suite; this integration profile smoke-tests that - /// reads work end-to-end against the unconnected path. - /// public static readonly AbServerProfile Micro800 = new( Family: AbCipPlcFamily.Micro800, - AbServerPlcArg: "controllogix", // ab_server lacks dedicated micro800 mode — see Notes - SeedTags: new AbServerSeedTag[] - { - new("TestDINT", "DINT"), - new("TestREAL", "REAL"), - }, - Notes: "ab_server has no --plc micro800 — falls back to controllogix emulation. Driver side still enforces empty path + unconnected-only per PR 11. Real Micro800 coverage requires a 2080 on a lab rig."); + ComposeProfile: "micro800", + Notes: "--plc=Micro800 mode (unconnected-only, empty path). Driver-side enforcement verified in the unit suite."); - /// - /// GuardLogix — safety-capable ControlLogix variant with ViewOnly safety tags. ab_server - /// doesn't emulate the safety subsystem; we preseed a safety-suffixed name (_S) so - /// the driver's read-only classification path is exercised against a real tag. - /// public static readonly AbServerProfile GuardLogix = new( Family: AbCipPlcFamily.GuardLogix, - AbServerPlcArg: "controllogix", - SeedTags: new AbServerSeedTag[] - { - new("TestDINT", "DINT"), - new("SafetyDINT_S", "DINT"), // _S-suffixed → driver classifies as safety-ViewOnly per PR 12 - }, - Notes: "ab_server has no safety subsystem — this profile emulates the tag-naming contract. Real safety-lock behavior requires a physical GuardLogix 1756-L8xS rig."); + ComposeProfile: "guardlogix", + Notes: "ab_server has no safety subsystem — _S-suffixed seed tag triggers driver-side ViewOnly classification only."); public static IReadOnlyList All { get; } = - new[] { ControlLogix, CompactLogix, Micro800, GuardLogix }; + [ControlLogix, CompactLogix, Micro800, GuardLogix]; public static AbServerProfile ForFamily(AbCipPlcFamily family) => All.FirstOrDefault(p => p.Family == family) diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbServerProfileTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbServerProfileTests.cs index 06557d4..14646ad 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbServerProfileTests.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbServerProfileTests.cs @@ -5,61 +5,23 @@ using ZB.MOM.WW.OtOpcUa.Driver.AbCip; namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests; /// -/// Pure-unit tests for the profile → CLI arg composition. Runs without ab_server -/// on PATH so CI without the binary still exercises these contracts + catches any -/// profile-definition drift (e.g. a typo in --plc mapping would silently make the -/// simulator boot with the wrong family). +/// Pure-unit tests for the profile catalog. Verifies +/// stays in sync with + with the compose-file service +/// names — a typo in either would surface as a test failure rather than a silent +/// "wrong family booted" at runtime. Runs without Docker, so CI without the +/// container still exercises these contracts. /// [Trait("Category", "Unit")] public sealed class AbServerProfileTests { - [Fact] - public void BuildCliArgs_Emits_Port_And_Plc_And_TagFlags() - { - var profile = new AbServerProfile( - Family: AbCipPlcFamily.ControlLogix, - AbServerPlcArg: "controllogix", - SeedTags: new AbServerSeedTag[] - { - new("A", "DINT"), - new("B", "REAL"), - }, - Notes: "test"); - - profile.BuildCliArgs(44818).ShouldBe("--port 44818 --plc controllogix --tag A:DINT --tag B:REAL"); - } - - [Fact] - public void BuildCliArgs_NoSeedTags_Emits_Just_Port_And_Plc() - { - var profile = new AbServerProfile( - AbCipPlcFamily.ControlLogix, "controllogix", [], "empty"); - - profile.BuildCliArgs(5000).ShouldBe("--port 5000 --plc controllogix"); - } - - [Fact] - public void AbServerSeedTag_ArraySize_FormatsAsThirdSegment() - { - new AbServerSeedTag("TestArray", "DINT", ArraySize: 16) - .ToCliSpec().ShouldBe("TestArray:DINT:16"); - } - - [Fact] - public void AbServerSeedTag_NoArraySize_TwoSegments() - { - new AbServerSeedTag("TestScalar", "REAL") - .ToCliSpec().ShouldBe("TestScalar:REAL"); - } - [Theory] [InlineData(AbCipPlcFamily.ControlLogix, "controllogix")] [InlineData(AbCipPlcFamily.CompactLogix, "compactlogix")] - [InlineData(AbCipPlcFamily.Micro800, "controllogix")] // falls back — ab_server lacks dedicated mode - [InlineData(AbCipPlcFamily.GuardLogix, "controllogix")] // falls back — ab_server lacks safety subsystem - public void KnownProfiles_ForFamily_Returns_Expected_AbServerPlcArg(AbCipPlcFamily family, string expected) + [InlineData(AbCipPlcFamily.Micro800, "micro800")] + [InlineData(AbCipPlcFamily.GuardLogix, "guardlogix")] + public void KnownProfiles_ForFamily_Returns_Expected_ComposeProfile(AbCipPlcFamily family, string expected) { - KnownProfiles.ForFamily(family).AbServerPlcArg.ShouldBe(expected); + KnownProfiles.ForFamily(family).ComposeProfile.ShouldBe(expected); } [Fact] @@ -71,20 +33,8 @@ public sealed class AbServerProfileTests } [Fact] - public void KnownProfiles_ControlLogix_Includes_AllAtomicTypes() + public void DefaultPort_Matches_EtherNetIP_Standard() { - var tags = KnownProfiles.ControlLogix.SeedTags.Select(t => t.AbServerType).ToHashSet(); - tags.ShouldContain("DINT"); - tags.ShouldContain("REAL"); - tags.ShouldContain("BOOL"); - tags.ShouldContain("SINT"); - tags.ShouldContain("STRING"); - } - - [Fact] - public void KnownProfiles_GuardLogix_SeedsSafetySuffixedTag() - { - KnownProfiles.GuardLogix.SeedTags - .ShouldContain(t => t.Name.EndsWith("_S"), "GuardLogix profile must seed at least one _S-suffixed tag for safety-classification coverage."); + AbServerProfile.DefaultPort.ShouldBe(44818); } } 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 index 65a4c04..e63a0ab 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker/README.md +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker/README.md @@ -2,11 +2,11 @@ [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. +endpoint over EtherNet/IP. Docker is the only supported launch path; +`ab_server` ships as a source-only tool under libplctag's +`src/tools/ab_server/` so the Dockerfile's multi-stage build is the only +reproducible way to get a working binary across developer boxes. A fresh +clone needs Docker Desktop and nothing else. | File | Purpose | |---|---| @@ -41,8 +41,7 @@ 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. +- Override with `AB_SERVER_ENDPOINT=host:port` to point at a real PLC. ## Run the integration tests @@ -53,11 +52,10 @@ 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. +`AbServerFixture` TCP-probes `localhost:44818` at collection init + +records a skip reason when unreachable, so tests stay green on a fresh +clone without the container running. Tests use `[AbServerFact]` / +`[AbServerTheory]` which check the same probe. ## What each family seeds @@ -84,14 +82,6 @@ place means updating both. 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) 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 index a9879b1..bffc51b 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/README.md +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/README.md @@ -1,11 +1,11 @@ -# Modbus integration-test fixture — pymodbus simulator (Docker) +# Modbus integration-test fixture — pymodbus simulator 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. +[`pymodbus`](https://pymodbus.readthedocs.io/) simulator running as a +pinned Docker container. One image, per-profile service in compose, same +port binding (`5020`) regardless of which profile is live. Docker is the +only supported launch path — a fresh clone needs Docker Desktop and +nothing else. | File | Purpose | |---|---| @@ -61,15 +61,7 @@ dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests 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/Pymodbus/README.md b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Pymodbus/README.md deleted file mode 100644 index 98352c4..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Pymodbus/README.md +++ /dev/null @@ -1,163 +0,0 @@ -# pymodbus simulator profiles - -Two JSON-config profiles for pymodbus's `ModbusSimulatorServer`. Replaces the -ModbusPal `.xmpp` profiles that lived here in PR 42 — pymodbus is headless, -maintained, semantic about register layout, and pip-installable on Windows. - -| File | What it simulates | Test category | -|---|---|---| -| [`standard.json`](standard.json) | Generic Modbus TCP server — HR[0..31] = address-as-value, HR[100] declarative auto-increment via `"action": "increment"`, alternating coils, scratch ranges for write tests. | `Trait=Standard` | -| [`dl205.json`](dl205.json) | AutomationDirect DirectLOGIC DL205 / DL260 quirks per [`docs/v2/dl205.md`](../../../docs/v2/dl205.md): low-byte-first string packing, CDAB Float32, BCD numerics, V-memory address markers, Y/C coil mappings. Inline `_quirk` comments per register name the behavior. | `Trait=DL205` | - -Both bind TCP **5020** (pymodbus convention; sidesteps the Windows admin -requirement for privileged port 502). The integration-test fixture -(`ModbusSimulatorFixture`) defaults to `localhost:5020` to match — override -via `MODBUS_SIM_ENDPOINT` to point at a real PLC on its native port 502. - -Run only **one profile at a time** (they share TCP 5020). - -## Install - -```powershell -pip install "pymodbus[simulator]==3.13.0" -``` - -The `[simulator]` extra pulls in `aiohttp` for the optional web UI / REST API. -Pinned to 3.13.0 for reproducibility — avoid 4.x dev releases until stabilized. -Requires Python ≥ 3.10. Windows Firewall will prompt on first bind; allow -Private network. - -## Run - -Foreground (Ctrl+C to stop). Use the `serve.ps1` wrapper: - -```powershell -.\serve.ps1 -Profile standard -.\serve.ps1 -Profile dl205 -``` - -Or invoke pymodbus directly: - -```powershell -pymodbus.simulator ` - --modbus_server srv ` - --modbus_device dev ` - --json_file .\standard.json ` - --http_port 8080 -``` - -Web UI at `http://localhost:8080` lets you inspect + poke registers manually. -Pass `--no_http` (or `-HttpPort 0` to `serve.ps1`) to disable. - -## Run the integration tests - -In a separate shell, with the simulator running: - -```powershell -cd C:\Users\dohertj2\Desktop\lmxopcua -dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests -``` - -Tests auto-skip with a clear `SkipReason` if `localhost:5020` isn't reachable -within 2 seconds. Filter by trait when both profiles' tests coexist: - -```powershell -dotnet test ... --filter "Trait=Standard" -dotnet test ... --filter "Trait=DL205" -``` - -## What's encoded in each profile - -### standard.json - -- HR[0..31]: each register's value equals its address. Easy mental map. -- HR[100]: `"action": "increment"` ticks 0..65535 on every register access — drives subscribe-and-receive tests so they have a register that changes without a write. -- HR[200..209]: scratch range for write-roundtrip tests. -- Coils[0..31]: alternating on/off (even=on). -- Coils[100..109]: scratch. -- All addresses 0..1023 are writable (`"write": [[0, 1023]]`). - -### dl205.json (per `docs/v2/dl205.md`) - -| HR address | Quirk demonstrated | Raw value | Decoded | -|---|---|---|---| -| `0` (V0) | Register 0 is valid (rejects-register-0 rumour disproved) | `51966` (0xCAFE) | marker | -| `1024` (V2000 octal) | V-memory octal-to-decimal mapping | `8192` (0x2000) | marker | -| `8448` (V40400 octal) | V40400 → PDU 0x2100 (NOT register 0) | `16448` (0x4040) | marker | -| `1040..1042` | String "Hello" packed first-char-low-byte | `25928, 27756, 111` | `"Hello"` | -| `1056..1057` | Float32 1.5f in CDAB word order | `0, 16320` | `1.5f` | -| `1072` | Decimal 1234 in BCD encoding | `4660` (0x1234) | `1234` | -| `1280..1407` | 128-register block (FC03 cap = 128 above spec's 125) | first/last/mid markers; rest defaults to 0 | for FC03 cap test | - -| Coil address | Quirk demonstrated | -|---|---| -| `2048` | Y0 maps to coil 2048 (DL260 layout) | -| `3072` | C0 maps to coil 3072 (DL260 layout) | -| `4000..4007` | Scratch C-relay range for write-roundtrip tests | - -The DL260 X-input markers (FC02 discrete inputs) **are not encoded separately** -because the profile uses `shared blocks: true` (matches DL series memory -model) — coils/DI/HR/IR overlay the same word address space. Tests that -target FC02 against this profile end up reading the same bit positions as -the coils they share with. - -## What's IN pymodbus that wasn't in ModbusPal - -- **All four standard tables** (HR, IR, coils, DI) configurable via `co size` / `di size` / `hr size` / `ir size` setup keys. -- **Per-register raw uint16 seeding** — `{"addr": 1040, "value": 25928}` puts exactly that 16-bit value on the wire. No interpretation. -- **Built-in actions**: `increment`, `random`, `timestamp`, `reset`, `uptime` for declarative dynamic registers. No Python script alongside the config required. -- **Custom actions** — point `--custom_actions_module` at a `.py` file exposing callables to express anything more complex (per-second wall-clock ticks, BCD synthesis, etc.). -- **Headless** — pure CLI process, no Java, no Swing. Pip-installable. Plays well with CI runners. -- **Web UI / REST API** — `--http_port 8080` adds an aiohttp server for live inspection. Optional. -- **Maintained** — current stable 3.13.0 (April 2026), active development on 4.0 dev branch. - -## Trade-offs vs the hand-authored ModbusPal profiles - -- pymodbus's built-in `float32` type stores in pymodbus's word order; for explicit DL205 CDAB control we seed two raw `uint16` entries instead. Documented inline in `dl205.json`. -- `increment` action ticks per-access, not wall-clock. A 250ms-poll integration test sees variation either way; for strict 1Hz cadence add `--custom_actions_module my_actions.py` with a `time.time()`-based callable. -- `dl205.json` uses `shared blocks: true` because it matches DL series memory model; `standard.json` uses `shared blocks: false` so coils and HR address spaces are independent (more like a textbook PLC). - -## File format reference - -```json -{ - "server_list": { - "": { - "comm": "tcp", - "host": "0.0.0.0", - "port": 5020, - "framer": "socket", - "device_id": 1 - } - }, - "device_list": { - "": { - "setup": { - "co size": N, "di size": N, "hr size": N, "ir size": N, - "shared blocks": false, - "type exception": false, - "defaults": { "value": {...}, "action": {...} } - }, - "invalid": [], - "write": [[, ]], - "bits": [{"addr": N, "value": 0|1}], - "uint16": [{"addr": N, "value": <0..65535>, "action"?: "increment", "parameters"?: {...}}], - "uint32": [{"addr": N, "value": }], - "float32": [{"addr": N, "value": }], - "string": [{"addr": N, "value": ""}], - "repeat": [] - } - } -} -``` - -The CLI args `--modbus_server --modbus_device ` -pick which entries the simulator binds. - -## References - -- [pymodbus on PyPI](https://pypi.org/project/pymodbus/) — install, version pin -- [Simulator config docs](https://pymodbus.readthedocs.io/en/dev/source/library/simulator/config.html) — full schema reference -- [Simulator REST API](https://pymodbus.readthedocs.io/en/latest/source/library/simulator/restapi.html) — for the optional web UI -- [`docs/v2/dl205.md`](../../../docs/v2/dl205.md) — what each DL205 profile entry simulates -- [`docs/v2/modbus-test-plan.md`](../../../docs/v2/modbus-test-plan.md) — the `DL205_` test naming convention diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Pymodbus/dl205.json b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Pymodbus/dl205.json deleted file mode 100644 index f39abd2..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Pymodbus/dl205.json +++ /dev/null @@ -1,111 +0,0 @@ -{ - "_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/Pymodbus/mitsubishi.json b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Pymodbus/mitsubishi.json deleted file mode 100644 index 53fa56c..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Pymodbus/mitsubishi.json +++ /dev/null @@ -1,83 +0,0 @@ -{ - "_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/Pymodbus/s7_1500.json b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Pymodbus/s7_1500.json deleted file mode 100644 index d4f8a2b..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Pymodbus/s7_1500.json +++ /dev/null @@ -1,77 +0,0 @@ -{ - "_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/Pymodbus/serve.ps1 b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Pymodbus/serve.ps1 deleted file mode 100644 index 056f8a5..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Pymodbus/serve.ps1 +++ /dev/null @@ -1,60 +0,0 @@ -<# -.SYNOPSIS - Launches the pymodbus simulator with one of the integration-test profiles - (Standard or DL205). Foreground process — Ctrl+C to stop. - -.PARAMETER Profile - Which simulator profile to run: 'standard' or 'dl205'. Both bind TCP 5020 by - default so they can't run simultaneously on the same box. - -.PARAMETER HttpPort - Port for pymodbus's optional web UI / REST API. Default 8080. Pass 0 to - disable (passes --no_http). - -.EXAMPLE - .\serve.ps1 -Profile standard - Starts the standard server on TCP 5020 with web UI on 8080. - -.EXAMPLE - .\serve.ps1 -Profile dl205 -HttpPort 0 - Starts the DL205 server on TCP 5020, no web UI. -#> -[CmdletBinding()] -param( - [Parameter(Mandatory)] [ValidateSet('standard', 'dl205', 's7_1500', 'mitsubishi')] [string]$Profile, - [int]$HttpPort = 8080 -) - -$ErrorActionPreference = 'Stop' -$here = $PSScriptRoot - -# Confirm pymodbus.simulator is on PATH — clearer message than the -# 'CommandNotFoundException' dotnet style. -$cmd = Get-Command pymodbus.simulator -ErrorAction SilentlyContinue -if (-not $cmd) { - Write-Error "pymodbus.simulator not found. Install with: pip install 'pymodbus[simulator]==3.13.0'" - exit 1 -} - -$jsonFile = Join-Path $here "$Profile.json" -if (-not (Test-Path $jsonFile)) { - Write-Error "Profile config not found: $jsonFile" - exit 1 -} - -$args = @( - '--modbus_server', 'srv', - '--modbus_device', 'dev', - '--json_file', $jsonFile -) - -if ($HttpPort -gt 0) { - $args += @('--http_port', $HttpPort) - Write-Host "Web UI will be at http://localhost:$HttpPort" -} else { - $args += '--no_http' -} - -Write-Host "Starting pymodbus simulator: profile=$Profile TCP=localhost:5020" -Write-Host "Ctrl+C to stop." -& pymodbus.simulator @args diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Pymodbus/standard.json b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Pymodbus/standard.json deleted file mode 100644 index 5d9b63a..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Pymodbus/standard.json +++ /dev/null @@ -1,97 +0,0 @@ -{ - "_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 2c2ceaf..b8272f8 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 @@ -24,7 +24,6 @@ - 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 index 9a8c443..95d0898 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/README.md +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/README.md @@ -1,10 +1,9 @@ -# S7 integration-test fixture — python-snap7 (Docker) +# 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 -primary launcher; the native-Python fallback under -[`../PythonSnap7/`](../PythonSnap7/) is kept for contributors who prefer -to avoid Docker locally. +only supported launch path — a fresh clone needs Docker Desktop and +nothing else. | File | Purpose | |---|---| @@ -86,15 +85,8 @@ Not exercised here — needs a lab rig: 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/PythonSnap7/README.md b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/PythonSnap7/README.md deleted file mode 100644 index 1a38810..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/PythonSnap7/README.md +++ /dev/null @@ -1,110 +0,0 @@ -# python-snap7 server profiles - -JSON-driven seed profiles for `snap7.server.Server` from -[python-snap7](https://github.com/gijzelaerr/python-snap7) (MIT). Shape -mirrors the pymodbus profiles under -`tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Pymodbus/` — a -PowerShell launcher + per-family JSON + a Python shim that the launcher -exec's. - -| File | What it seeds | Test category | -|---|---|---| -| [`s7_1500.json`](s7_1500.json) | DB1 (1024 bytes) with smoke values at known offsets (i16 @ DBW10, i32 @ DBD20, f32 @ DBD30, bool @ DBX50.3, scratch word @ DBW100, STRING "Hello" @ 200) + MB (256 bytes) with probe marker at MW0. | `Trait=Integration, Device=S7_1500` | - -Default port **1102** (non-privileged; sidesteps Windows Firewall prompt + -Linux's root-required bind on port 102). The fixture -(`Snap7ServerFixture`) defaults to `localhost:1102`. Override via -`S7_SIM_ENDPOINT` to point at a real S7 CPU on port 102. The S7 driver -threads `_options.Port` through to S7netplus's 5-arg `Plc` ctor, so the -non-standard port works end-to-end. - -## Install - -```powershell -pip install "python-snap7>=2.0" -``` - -`python-snap7` wraps the upstream `snap7` C library; the install pulls -platform-specific binaries automatically. Requires Python ≥ 3.10. -Windows Firewall will prompt on first bind; allow Private network. - -## Run - -Foreground (Ctrl+C to stop): - -```powershell -.\serve.ps1 -Profile s7_1500 -``` - -Non-default port: - -```powershell -.\serve.ps1 -Profile s7_1500 -Port 102 -``` - -Or invoke the Python shim directly: - -```powershell -python .\server.py .\s7_1500.json --port 1102 -``` - -## Run the integration tests - -In a separate shell with the simulator running: - -```powershell -cd C:\Users\dohertj2\Desktop\lmxopcua -dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests -``` - -Tests auto-skip with a clear `SkipReason` when `localhost:1102` isn't -reachable within 2 seconds. - -## What's encoded in `s7_1500.json` - -| Address | Type | Seed | Purpose | -|---|---|---|---| -| `DB1.DBW0` | u16 | `4242` | read-back probe | -| `DB1.DBW10` | i16 | `-12345` | smoke i16 read | -| `DB1.DBD20` | i32 | `1234567890` | smoke i32 read | -| `DB1.DBD30` | f32 | `3.14159` | smoke f32 read (big-endian) | -| `DB1.DBX50.3` | bool | `true` | smoke bool read at bit 3 | -| `DB1.DBW100` | u16 | `0` | scratch for write-then-read | -| `DB1.STRING[200]` | S7 STRING | `"Hello"` (max 32, cur 5) | smoke string read | -| `MW0` | u16 | `1` | `S7ProbeOptions.ProbeAddress` default | - -Seed types supported by `server.py`: `u8`, `i8`, `u16`, `i16`, `u32`, -`i32`, `f32`, `bool` (with `"bit": 0..7`), `ascii` (S7 STRING type with -configurable `max_len`). - -## Known limitations (python-snap7 upstream) - -The `snap7.server.Server` docstring admits: - -> "Legacy S7 server implementation. Emulates a Siemens S7 PLC for testing -> and development purposes. [...] pure Python emulator implementation that -> simulates PLC behaviour for protocol compliance testing rather than -> full industrial-grade functionality." - -What that means in practice — things this fixture does NOT cover: - -- **S7-1500 Optimized-DB symbolic access** — the real S7-1500 with TIA Portal - optimization enabled uses symbolic addressing that's wire-incompatible with - absolute DB addressing. Our driver targets non-optimized DBs; so does - snap7's server. Rig test required to verify against an Optimized CPU. -- **PG / OP / S7-Basic session types** — S7netplus uses OP session; the - simulator accepts whatever session type is requested, unlike real CPUs - that allocate session slots differently. -- **SCL variant-specific behaviour** — e.g. S7-1200 missing certain PDU - types, S7-300's older handshake, S7-400 multi-CPU racks with non-zero - slot. Simulator collapses all into one generic CPU emulation. -- **PUT/GET-disabled-by-default** — real S7-1200/1500 CPUs refuse reads - when PUT/GET is off in TIA Portal hardware config; the driver maps that - to `BadDeviceFailure`. Simulator has no such toggle + always accepts. - -## References - -- [python-snap7 GitHub](https://github.com/gijzelaerr/python-snap7) — source + install -- [snap7.server API](https://python-snap7.readthedocs.io/en/latest/API/server.html) — `Server` class reference -- [`docs/drivers/S7-Test-Fixture.md`](../../../docs/drivers/S7-Test-Fixture.md) — coverage map + gap inventory -- [`docs/v2/s7.md`](../../../docs/v2/s7.md) — driver-side addressing + family notes diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/PythonSnap7/s7_1500.json b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/PythonSnap7/s7_1500.json deleted file mode 100644 index 20d8955..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/PythonSnap7/s7_1500.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "_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/PythonSnap7/serve.ps1 b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/PythonSnap7/serve.ps1 deleted file mode 100644 index a6af910..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/PythonSnap7/serve.ps1 +++ /dev/null @@ -1,56 +0,0 @@ -<# -.SYNOPSIS - Launches the python-snap7 S7 server with one of the integration-test - profiles. Foreground process — Ctrl+C to stop. Mirrors the pymodbus - `serve.ps1` wrapper in tests\...\Modbus.IntegrationTests\Pymodbus\. - -.PARAMETER Profile - Which profile JSON to load: currently only 's7_1500' ships. Additional - families (S7-1200, S7-300) can drop in as new JSON files alongside. - -.PARAMETER Port - TCP port to bind. Default 1102 (non-privileged; matches - Snap7ServerFixture default endpoint). Pass 102 to match S7 standard — - requires root on Linux + triggers Windows Firewall prompt. - -.EXAMPLE - .\serve.ps1 -Profile s7_1500 - -.EXAMPLE - .\serve.ps1 -Profile s7_1500 -Port 102 -#> -[CmdletBinding()] -param( - [Parameter(Mandatory)] [ValidateSet('s7_1500')] [string]$Profile, - [int]$Port = 1102 -) - -$ErrorActionPreference = 'Stop' -$here = $PSScriptRoot - -# python-snap7 installs the `snap7` Python package; we call via `python -m` -# or via the server.py shim in this folder. Shim path is simpler to diagnose. -$python = Get-Command python -ErrorAction SilentlyContinue -if (-not $python) { $python = Get-Command py -ErrorAction SilentlyContinue } -if (-not $python) { - Write-Error "python not found on PATH. Install Python 3.10+ and 'pip install python-snap7'." - exit 1 -} - -# Verify python-snap7 is installed so failures surface here, not in a -# confusing ImportError from server.py. -& $python.Source -c "import snap7.server" 2>$null -if ($LASTEXITCODE -ne 0) { - Write-Error "python-snap7 not importable. Install with: pip install 'python-snap7>=2.0'" - exit 1 -} - -$jsonFile = Join-Path $here "$Profile.json" -if (-not (Test-Path $jsonFile)) { - Write-Error "Profile config not found: $jsonFile" - exit 1 -} - -Write-Host "Starting python-snap7 server: profile=$Profile TCP=localhost:$Port" -Write-Host "Ctrl+C to stop." -& $python.Source (Join-Path $here "server.py") $jsonFile --port $Port diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/PythonSnap7/server.py b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/PythonSnap7/server.py deleted file mode 100644 index ce1824b..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/PythonSnap7/server.py +++ /dev/null @@ -1,150 +0,0 @@ -"""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 276a9f8..159fa77 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 @@ -24,7 +24,6 @@ -