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:
@@ -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"/>
|
||||
|
||||
Reference in New Issue
Block a user