diff --git a/_p54.json b/_p54.json
new file mode 100644
index 0000000..8120253
--- /dev/null
+++ b/_p54.json
@@ -0,0 +1 @@
+{"title":"Phase 3 PR 54 -- Siemens S7 Modbus TCP quirks research doc","body":"## Summary\n\nAdds `docs/v2/s7.md` (485 lines) covering Siemens SIMATIC S7 family Modbus TCP behavior. Mirrors the `docs/v2/dl205.md` template for future per-quirk implementation PRs.\n\n## Key findings for the implementation track\n\n- **No fixed memory map** — every S7 Modbus server is user-wired via `MB_SERVER`/`MODBUSCP`/`MODBUSPN` library blocks. Driver must accept per-site config, not assume a vendor layout.\n- **MB_SERVER requires non-optimized DBs** (STATUS `0x8383` if optimized). Most common field bug.\n- **Word order default = ABCD** (opposite of DL260). Driver's S7 profile default must be `ByteOrder.BigEndian`, not `WordSwap`.\n- **One port per MB_SERVER instance** — multi-client requires parallel FBs on 503/504/… Most clients assume port 502 multiplexes (wrong on S7).\n- **CP 343-1 Lean is server-only**, requires the `2XV9450-1MB00` license.\n- **FC20/21/22/23/43 all return Illegal Function** on every S7 variant — driver must not attempt FC23 bulk-read optimization for S7.\n- **STOP-mode behavior non-deterministic** across firmware bands — treat both read/write STOP-mode responses as unavailable.\n\nTwo items flagged as unconfirmed rumour (V2.0+ float byte-order claim, STOP-mode caching location).\n\nNo code, no tests — implementation lands in PRs 56+.\n\n## Test plan\n- [x] Doc renders as markdown\n- [x] 31 citations present\n- [x] Section structure matches dl205.md template","head":"phase-3-pr54-s7-research-doc","base":"v2"}
diff --git a/_p55.json b/_p55.json
new file mode 100644
index 0000000..00c2066
--- /dev/null
+++ b/_p55.json
@@ -0,0 +1 @@
+{"title":"Phase 3 PR 55 -- Mitsubishi MELSEC Modbus TCP quirks research doc","body":"## Summary\n\nAdds `docs/v2/mitsubishi.md` (451 lines) covering MELSEC Q/L/iQ-R/iQ-F/FX3U Modbus TCP behavior. Mirrors `docs/v2/dl205.md` template for per-quirk implementation PRs.\n\n## Key findings for the implementation track\n\n- **Module naming trap** — `QJ71MB91` is SERIAL RTU, not TCP. TCP module is `QJ71MT91`. Surface clearly in driver docs.\n- **No canonical mapping** — per-site 'Modbus Device Assignment Parameter' block (up to 16 entries). Treat mapping as runtime config.\n- **X/Y hex vs octal depends on family** — Q/L/iQ-R use HEX (X20 = decimal 32); FX/iQ-F use OCTAL (X20 = decimal 16). Helper must take a family selector.\n- **Word order CDAB default** across all MELSEC families (opposite of Siemens S7). Driver Mitsubishi profile default: `ByteOrder.WordSwap`.\n- **D-registers binary by default** (opposite of DL205's BCD default). Caller opts in to `Bcd16`/`Bcd32` when ladder uses BCD.\n- **FX5U needs firmware ≥ 1.060** for Modbus TCP server — older is client-only.\n- **FX3U-ENET vs FX3U-ENET-P502 vs FX3U-ENET-ADP** — only the middle one binds port 502; the last has no Modbus at all. Common operator mis-purchase.\n- **QJ71MT91 does NOT support FC22 / FC23** — iQ-R / iQ-F do. Bulk-read optimization must gate on capability.\n- **STOP-mode writes configurable** on Q/L/iQ-R/iQ-F (default accept), always rejected on FX3U-ENET.\n\nThree unconfirmed rumours flagged separately.\n\nNo code, no tests — implementation lands in PRs 58+.\n\n## Test plan\n- [x] Doc renders as markdown\n- [x] 17 citations present\n- [x] Per-model test naming matrix included (`Mitsubishi_QJ71MT91_*`, `Mitsubishi_FX5U_*`, `Mitsubishi_FX3U_ENET_*`, shared `Mitsubishi_Common_*`)","head":"phase-3-pr55-mitsubishi-research-doc","base":"v2"}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Pymodbus/s7_1500.json b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Pymodbus/s7_1500.json
new file mode 100644
index 0000000..d4f8a2b
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Pymodbus/s7_1500.json
@@ -0,0 +1,77 @@
+{
+ "_comment": "s7_1500.json -- Siemens SIMATIC S7-1500 + MB_SERVER quirk simulator. Models docs/v2/s7.md behaviors as concrete register values. Unlike DL260 (CDAB word order default) or Mitsubishi (CDAB default), S7 MB_SERVER uses ABCD word order by default because Siemens native CPU types are big-endian top-to-bottom both within the register pair and byte pair. This profile exists so the driver's S7 profile default ByteOrder.BigEndian can be validated end-to-end. pymodbus bit-address semantics are the same as dl205.json (FC01/02/05/15 address X maps to cell index X/16); seed bits at the appropriate cell-indexed positions.",
+
+ "server_list": {
+ "srv": {
+ "comm": "tcp",
+ "host": "0.0.0.0",
+ "port": 5020,
+ "framer": "socket",
+ "device_id": 1
+ }
+ },
+
+ "device_list": {
+ "dev": {
+ "setup": {
+ "co size": 4096,
+ "di size": 4096,
+ "hr size": 4096,
+ "ir size": 1024,
+ "shared blocks": true,
+ "type exception": false,
+ "defaults": {
+ "value": {"bits": 0, "uint16": 0, "uint32": 0, "float32": 0.0, "string": " "},
+ "action": {"bits": null, "uint16": null, "uint32": null, "float32": null, "string": null}
+ }
+ },
+ "invalid": [],
+ "write": [
+ [0, 0],
+ [25, 25],
+ [100, 101],
+ [200, 209],
+ [300, 301]
+ ],
+
+ "uint16": [
+ {"_quirk": "DB1 header marker. On an S7-1500 with MB_SERVER pointing at DB1, operators often reserve DB1.DBW0 for a fingerprint word so clients can verify they're talking to the right DB. 0xABCD = 43981.",
+ "addr": 0, "value": 43981},
+
+ {"_quirk": "Scratch HR range 200..209 -- mirrors the standard.json scratch range so the smoke test (S7_1500Profile.SmokeHoldingRegister=200) round-trips identically against either profile.",
+ "addr": 200, "value": 0},
+ {"addr": 201, "value": 0},
+ {"addr": 202, "value": 0},
+ {"addr": 203, "value": 0},
+ {"addr": 204, "value": 0},
+ {"addr": 205, "value": 0},
+ {"addr": 206, "value": 0},
+ {"addr": 207, "value": 0},
+ {"addr": 208, "value": 0},
+ {"addr": 209, "value": 0},
+
+ {"_quirk": "Float32 1.5f in ABCD word order (Siemens big-endian default, OPPOSITE of DL260 CDAB). IEEE-754 1.5 = 0x3FC00000. ABCD = high word first: HR[100]=0x3FC0=16320, HR[101]=0x0000=0.",
+ "addr": 100, "value": 16320},
+ {"_quirk": "Float32 1.5f ABCD low word.",
+ "addr": 101, "value": 0},
+
+ {"_quirk": "Int32 0x12345678 in ABCD word order. HR[300]=0x1234=4660, HR[301]=0x5678=22136. Demonstrates the contrast with DL260 CDAB Int32 encoding.",
+ "addr": 300, "value": 4660},
+ {"addr": 301, "value": 22136}
+ ],
+
+ "bits": [
+ {"_quirk": "Coil bank marker cell. S7 MB_SERVER doesn't fix coil addresses; this simulates a user-wired DB where coil 400 (=bit 0 of cell 25) represents a latched digital output. Cell 25 bit 0 = 1 proves the wire-format round-trip works for coils on S7 profile.",
+ "addr": 25, "value": 1},
+
+ {"_quirk": "Discrete-input bank marker cell. DI 500 (=bit 0 of cell 31) = 1. Like coils, discrete inputs on S7 MB_SERVER are per-site; we assert the end-to-end FC02 path only.",
+ "addr": 31, "value": 1}
+ ],
+
+ "uint32": [],
+ "float32": [],
+ "string": [],
+ "repeat": []
+ }
+ }
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Pymodbus/serve.ps1 b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Pymodbus/serve.ps1
index 6cb9195..056f8a5 100644
--- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Pymodbus/serve.ps1
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Pymodbus/serve.ps1
@@ -21,7 +21,7 @@
#>
[CmdletBinding()]
param(
- [Parameter(Mandatory)] [ValidateSet('standard', 'dl205')] [string]$Profile,
+ [Parameter(Mandatory)] [ValidateSet('standard', 'dl205', 's7_1500', 'mitsubishi')] [string]$Profile,
[int]$HttpPort = 8080
)
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/S7/S7_1500Profile.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/S7/S7_1500Profile.cs
new file mode 100644
index 0000000..db2004c
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/S7/S7_1500Profile.cs
@@ -0,0 +1,44 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.S7;
+
+///
+/// Tag map for the Siemens SIMATIC S7-1500 device class with the MB_SERVER library
+/// block mapping HR[0..] to DB1.DBW0+. Mirrors s7_1500.json in Pymodbus/.
+///
+///
+/// Unlike DL205, S7 has no fixed Modbus memory map — every site wires MB_SERVER to a
+/// different DB. The profile here models the *default* user layout documented in
+/// docs/v2/s7.md §per-model-matrix: DB1.DBW0 = fingerprint marker, a scratch HR
+/// range 200..209 for write-roundtrip tests, and ABCD-order Float32 / Int32 markers at
+/// HR[100..101] and HR[300..301] to prove the driver's S7 profile default is correct.
+///
+public static class S7_1500Profile
+{
+ ///
+ /// Scratch HR the smoke test writes + reads. Address 200 mirrors the DL205 /
+ /// standard scratch range so one smoke test pattern works across all device profiles.
+ ///
+ public const ushort SmokeHoldingRegister = 200;
+
+ /// Value the smoke test writes then reads back.
+ public const short SmokeHoldingValue = 4321;
+
+ public static ModbusDriverOptions BuildOptions(string host, int port) => new()
+ {
+ Host = host,
+ Port = port,
+ UnitId = 1,
+ Timeout = TimeSpan.FromSeconds(2),
+ Tags =
+ [
+ new ModbusTagDefinition(
+ Name: "Smoke_HReg200",
+ Region: ModbusRegion.HoldingRegisters,
+ Address: SmokeHoldingRegister,
+ DataType: ModbusDataType.Int16,
+ Writable: true),
+ ],
+ // Disable the background probe loop — integration tests drive reads explicitly and
+ // the probe would race with assertions.
+ Probe = new ModbusProbeOptions { Enabled = false },
+ };
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/S7/S7_1500SmokeTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/S7/S7_1500SmokeTests.cs
new file mode 100644
index 0000000..8d89d42
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/S7/S7_1500SmokeTests.cs
@@ -0,0 +1,54 @@
+using Shouldly;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.S7;
+
+///
+/// End-to-end smoke against the S7-1500 MB_SERVER pymodbus profile (or a real
+/// S7-1500 + MB_SERVER deployment when MODBUS_SIM_ENDPOINT points at one). Drives
+/// the full + real stack —
+/// no fake transport. Success proves the driver initializes against the S7 simulator,
+/// writes a known value, and reads it back with the correct status and value, which is
+/// the baseline every S7-specific test (PR 57+) builds on.
+///
+///
+/// S7-specific quirk tests (MB_SERVER requires non-optimized DBs, ABCD word order
+/// default, port-per-connection, FC23 Illegal Function, STOP-mode behaviour, etc.) land
+/// as separate test classes in this directory as each quirk is validated in pymodbus.
+/// Keep this smoke test deliberately narrow — filtering by device class
+/// (--filter DisplayName~S7) should surface the quirk-specific failure mode when
+/// something goes wrong, not a blanket smoke failure that could mean anything.
+///
+[Collection(ModbusSimulatorCollection.Name)]
+[Trait("Category", "Integration")]
+[Trait("Device", "S7")]
+public sealed class S7_1500SmokeTests(ModbusSimulatorFixture sim)
+{
+ [Fact]
+ public async Task S7_1500_roundtrip_write_then_read_of_holding_register()
+ {
+ if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
+ if (!string.Equals(Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE"), "s7_1500",
+ StringComparison.OrdinalIgnoreCase))
+ {
+ Assert.Skip("MODBUS_SIM_PROFILE != s7_1500 — skipping (other profiles don't seed the S7 scratch range identically).");
+ }
+
+ var options = S7_1500Profile.BuildOptions(sim.Host, sim.Port);
+ await using var driver = new ModbusDriver(options, driverInstanceId: "s7-smoke");
+ await driver.InitializeAsync(driverConfigJson: "{}", TestContext.Current.CancellationToken);
+
+ var writeResults = await driver.WriteAsync(
+ [new(FullReference: "Smoke_HReg200", Value: (short)S7_1500Profile.SmokeHoldingValue)],
+ TestContext.Current.CancellationToken);
+ writeResults.Count.ShouldBe(1);
+ writeResults[0].StatusCode.ShouldBe(0u, "write must succeed against the S7-1500 MB_SERVER profile");
+
+ var readResults = await driver.ReadAsync(
+ ["Smoke_HReg200"],
+ TestContext.Current.CancellationToken);
+ readResults.Count.ShouldBe(1);
+ readResults[0].StatusCode.ShouldBe(0u);
+ readResults[0].Value.ShouldBe((short)S7_1500Profile.SmokeHoldingValue);
+ }
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.csproj b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.csproj
index 0b0dad0..cbf763c 100644
--- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.csproj
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.csproj
@@ -26,6 +26,7 @@
+