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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <arg></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 <name>:<type>[:<size>]</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><name>:<type>[:<array_size>]</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 9–12.</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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -24,7 +24,6 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="PythonSnap7\**\*" CopyToOutputDirectory="PreserveNewest"/>
|
||||
<None Update="Docker\**\*" CopyToOutputDirectory="PreserveNewest"/>
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user