Remove native-launcher fallbacks for the four Dockerized fixtures — Docker is the only supported path for Modbus / S7 / AB CIP / OpcUaClient integration. Native paths stay in place only where Docker isn't compatible (Galaxy: MXAccess COM + Windows-only; TwinCAT: Beckhoff runtime vs Hyper-V; FOCAS: closed-source Fanuc Fwlib32.dll; AB Legacy: PCCC has no OSS simulator). Simplifies the fixture landscape + removes the "which path do I run" ambiguity; removes two full native-launcher directories + the AB CIP native-spawn path; removes the parallel profile-as-CLI-arg-builder code from AbServerFixture.

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) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-04-20 12:27:44 -04:00
parent 27d135bd59
commit 0e1dcc119e
22 changed files with 133 additions and 1307 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 <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.

View File

@@ -1,158 +1,91 @@
using System.Diagnostics;
using System.Net.Sockets;
using Xunit;
using Xunit.Sdk;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests;
/// <summary>
/// Shared fixture that starts libplctag's <c>ab_server</c> simulator in the background for
/// the duration of an integration test collection. The fixture takes an
/// <see cref="AbServerProfile"/> (see <see cref="KnownProfiles"/>) so each AB family — ControlLogix,
/// CompactLogix, Micro800, GuardLogix — starts the simulator with the right <c>--plc</c>
/// 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
/// <c>dotnet test</c> — see <c>docs/v2/test-data-sources.md §2.CI</c> for the exact step.
/// Reachability probe for the <c>ab_server</c> Docker container (libplctag's CIP
/// simulator built via <c>Docker/Dockerfile</c>) or any real AB PLC the
/// <c>AB_SERVER_ENDPOINT</c> env var points at. Parses
/// <c>AB_SERVER_ENDPOINT</c> (default <c>localhost:44818</c>) + TCP-connects
/// once at fixture construction. Tests skip via <see cref="AbServerFactAttribute"/>
/// / <see cref="AbServerTheoryAttribute"/> when the port isn't live, so
/// <c>dotnet test</c> stays green on a fresh clone without Docker running.
/// Matches the <see cref="ModbusSimulatorFixture"/> / <c>Snap7ServerFixture</c> /
/// <c>OpcPlcFixture</c> shape.
/// </summary>
/// <remarks>
/// <para><c>ab_server</c> 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
/// <see cref="AbServerFactAttribute"/>) when the binary is not on PATH so a fresh clone
/// without the simulator still gets a green unit-test run.</para>
///
/// <para>Per-family profiles live in <see cref="KnownProfiles"/>. When a test wants a
/// specific family, instantiate the fixture with that profile — either via a
/// <see cref="IClassFixture{TFixture}"/> derived type or by constructing directly in a
/// parametric test (the latter is used below for the smoke suite).</para>
/// Docker is the only supported launch path — no native-binary spawn + no
/// PATH lookup. Bring the container up before <c>dotnet test</c>:
/// <c>docker compose -f Docker/docker-compose.yml --profile controllogix up</c>.
/// </remarks>
public sealed class AbServerFixture : IAsyncLifetime
{
private Process? _proc;
private const string EndpointEnvVar = "AB_SERVER_ENDPOINT";
/// <summary>The profile the simulator was started with. Same instance the driver-side options should use.</summary>
/// <summary>The profile this fixture instance represents. Parallel family smoke tests
/// instantiate the fixture with the profile matching their compose-file service.</summary>
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)
/// <summary>
/// <c>true</c> when ab_server is reachable at this fixture's Host/Port. Used by
/// <see cref="AbServerFactAttribute"/> / <see cref="AbServerTheoryAttribute"/>
/// to decide whether to skip tests on a fresh clone without a running container.
/// </summary>
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;
}
/// <summary>One-shot TCP probe; 500 ms budget so a missing server fails the probe fast.</summary>
/// <summary>One-shot TCP probe; 500 ms budget so a missing container fails the probe fast.</summary>
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;
}
/// <summary>
/// <c>true</c> when the AB CIP integration path has a live target: a Docker-run
/// container bound to <c>127.0.0.1:44818</c>, an <c>AB_SERVER_ENDPOINT</c> env
/// override, or <c>ab_server</c> on <c>PATH</c> (native spawn fallback).
/// </summary>
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;
}
/// <summary>
/// Locate <c>ab_server</c> on PATH. Returns <c>null</c> when missing — tests that
/// depend on it should use <see cref="AbServerFactAttribute"/> so CI runs without the binary
/// simply skip rather than fail.
/// </summary>
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;
}
}
/// <summary>
/// <c>[Fact]</c>-equivalent that skips when neither the Docker container nor the
/// native binary is available. Accepts: (a) a running listener on
/// <c>localhost:44818</c> (the Dockerized fixture's bind), or (b) <c>ab_server</c> on
/// <c>PATH</c> for the native-spawn fallback, or (c) an explicit
/// <c>AB_SERVER_ENDPOINT</c> env var pointing at a real PLC.
/// <c>[Fact]</c>-equivalent that skips when ab_server isn't reachable — accepts a
/// live Docker listener on <c>localhost:44818</c> or an <c>AB_SERVER_ENDPOINT</c>
/// override pointing at a real PLC.
/// </summary>
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.";
}
}
/// <summary>
/// <c>[Theory]</c>-equivalent with the same availability rules as
/// <see cref="AbServerFactAttribute"/>. Pair with
/// <c>[MemberData(nameof(KnownProfiles.All))]</c>-style providers to run one theory row
/// per family.
/// <c>[MemberData(nameof(KnownProfiles.All))]</c>-style providers to run one theory
/// row per family.
/// </summary>
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.";
}
}

View File

@@ -3,130 +3,51 @@ using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests;
/// <summary>
/// Per-family provisioning profile for the <c>ab_server</c> 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 <c>ab_server</c> + the tag-definition set the
/// driver uses to address the simulator's pre-provisioned tags.
/// Per-family marker for the <c>ab_server</c> Docker compose profile a given test
/// targets. The compose file (<c>Docker/docker-compose.yml</c>) is the canonical
/// source of truth for which tags a family seeds + which <c>--plc</c> 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.
/// </summary>
/// <param name="Family">OtOpcUa driver family this profile targets. Drives
/// <see cref="AbCipDeviceOptions.PlcFamily"/> + driver-side connection-parameter profile
/// (ConnectionSize, unconnected-only, etc.) per decision #9.</param>
/// <param name="AbServerPlcArg">The value passed to <c>ab_server --plc &lt;arg&gt;</c>. 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).</param>
/// <param name="SeedTags">Tags to preseed on the simulator via <c>--tag &lt;name&gt;:&lt;type&gt;[:&lt;size&gt;]</c>
/// flags. Each entry becomes one CLI arg; the driver-side <see cref="AbCipTagDefinition"/>
/// list references the same names so tests can read/write without walking the @tags surface
/// first.</param>
/// <param name="Notes">Operator-facing description of what the profile covers + any quirks.</param>
/// <param name="Family">OtOpcUa driver family this profile targets.</param>
/// <param name="ComposeProfile">The <c>docker compose --profile</c> name that brings
/// this family's ab_server up. Matches the service key in the compose file.</param>
/// <param name="Notes">Operator-facing description of coverage + any quirks.</param>
public sealed record AbServerProfile(
AbCipPlcFamily Family,
string AbServerPlcArg,
IReadOnlyList<AbServerSeedTag> SeedTags,
string ComposeProfile,
string Notes)
{
/// <summary>Default port — every profile uses the same so parallel-runs-of-different-families
/// would conflict (deliberately — one simulator per test collection is the model).</summary>
/// <summary>Default ab_server port — matches the compose-file port-map + the
/// CIP / EtherNet/IP standard.</summary>
public const int DefaultPort = 44818;
/// <summary>Compose the full <c>ab_server</c> CLI arg string for
/// <see cref="System.Diagnostics.ProcessStartInfo.Arguments"/>.</summary>
public string BuildCliArgs(int port)
{
var parts = new List<string>
{
"--port", port.ToString(),
"--plc", AbServerPlcArg,
};
foreach (var tag in SeedTags)
{
parts.Add("--tag");
parts.Add(tag.ToCliSpec());
}
return string.Join(' ', parts);
}
}
/// <summary>One tag the simulator pre-creates. ab_server spec format:
/// <c>&lt;name&gt;:&lt;type&gt;[:&lt;array_size&gt;]</c>.</summary>
public sealed record AbServerSeedTag(string Name, string AbServerType, int? ArraySize = null)
{
public string ToCliSpec() => ArraySize is { } n ? $"{Name}:{AbServerType}:{n}" : $"{Name}:{AbServerType}";
}
/// <summary>Canonical profiles covering every AB CIP family shipped in PRs 912.</summary>
public static class KnownProfiles
{
/// <summary>
/// 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.
/// </summary>
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.");
/// <summary>
/// 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.
/// </summary>
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.");
/// <summary>
/// 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.
/// </summary>
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.");
/// <summary>
/// GuardLogix — safety-capable ControlLogix variant with ViewOnly safety tags. ab_server
/// doesn't emulate the safety subsystem; we preseed a safety-suffixed name (<c>_S</c>) so
/// the driver's read-only classification path is exercised against a real tag.
/// </summary>
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<AbServerProfile> All { get; } =
new[] { ControlLogix, CompactLogix, Micro800, GuardLogix };
[ControlLogix, CompactLogix, Micro800, GuardLogix];
public static AbServerProfile ForFamily(AbCipPlcFamily family) =>
All.FirstOrDefault(p => p.Family == family)

View File

@@ -5,61 +5,23 @@ using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests;
/// <summary>
/// Pure-unit tests for the profile → CLI arg composition. Runs without <c>ab_server</c>
/// on PATH so CI without the binary still exercises these contracts + catches any
/// profile-definition drift (e.g. a typo in <c>--plc</c> mapping would silently make the
/// simulator boot with the wrong family).
/// Pure-unit tests for the profile catalog. Verifies <see cref="KnownProfiles"/>
/// stays in sync with <see cref="AbCipPlcFamily"/> + 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.
/// </summary>
[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);
}
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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": {
"<server-name>": {
"comm": "tcp",
"host": "0.0.0.0",
"port": 5020,
"framer": "socket",
"device_id": 1
}
},
"device_list": {
"<device-name>": {
"setup": {
"co size": N, "di size": N, "hr size": N, "ir size": N,
"shared blocks": false,
"type exception": false,
"defaults": { "value": {...}, "action": {...} }
},
"invalid": [],
"write": [[<from>, <to>]],
"bits": [{"addr": N, "value": 0|1}],
"uint16": [{"addr": N, "value": <0..65535>, "action"?: "increment", "parameters"?: {...}}],
"uint32": [{"addr": N, "value": <int>}],
"float32": [{"addr": N, "value": <float>}],
"string": [{"addr": N, "value": "<text>"}],
"repeat": []
}
}
}
```
The CLI args `--modbus_server <server-name> --modbus_device <device-name>`
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_<behavior>` test naming convention

View File

@@ -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": []
}
}
}

View File

@@ -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": []
}
}
}

View File

@@ -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": []
}
}
}

View File

@@ -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

View File

@@ -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": []
}
}
}

View File

@@ -24,7 +24,6 @@
</ItemGroup>
<ItemGroup>
<None Update="Pymodbus\**\*" CopyToOutputDirectory="PreserveNewest"/>
<None Update="Docker\**\*" CopyToOutputDirectory="PreserveNewest"/>
<None Update="DL205\**\*" CopyToOutputDirectory="PreserveNewest"/>
<None Update="S7\**\*" CopyToOutputDirectory="PreserveNewest"/>

View File

@@ -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

View File

@@ -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

View File

@@ -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 }
]
}
]
}

View File

@@ -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

View File

@@ -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())

View File

@@ -24,7 +24,6 @@
</ItemGroup>
<ItemGroup>
<None Update="PythonSnap7\**\*" CopyToOutputDirectory="PreserveNewest"/>
<None Update="Docker\**\*" CopyToOutputDirectory="PreserveNewest"/>
</ItemGroup>