diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx
index b022300..32d6b5a 100644
--- a/ZB.MOM.WW.OtOpcUa.slnx
+++ b/ZB.MOM.WW.OtOpcUa.slnx
@@ -24,6 +24,7 @@
+
diff --git a/docs/v2/modbus-test-plan.md b/docs/v2/modbus-test-plan.md
index 3301ae0..ada85f4 100644
--- a/docs/v2/modbus-test-plan.md
+++ b/docs/v2/modbus-test-plan.md
@@ -6,8 +6,10 @@ routing against a textbook Modbus server. That's necessary but not sufficient: r
populations disagree with the spec in small, device-specific ways, and a driver that
passes textbook tests can still misbehave against actual equipment.
-This doc is the harness-and-quirks playbook. It's what gets wired up in the
-`tests/Driver.Modbus.IntegrationTests` project when we ship that (PR 26 candidate).
+This doc is the harness-and-quirks playbook. The project it describes lives at
+`tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/` — scaffolded in PR 30 with
+the simulator fixture, DL205 profile stub, and one write/read smoke test. Each
+confirmed DL205 quirk lands in a follow-up PR as a named test in that project.
## Harness
@@ -94,10 +96,13 @@ vendors get promoted into driver defaults or opt-in options:
## Next concrete PRs
-- **PR 26 — Integration test project + DL205 profile scaffold**: creates
- `tests/Driver.Modbus.IntegrationTests`, imports the ModbusPal profile (or
- generates it from JSON), adds the fixture with skip-when-unreachable, plus
- one smoke test that reads a register. No DL205-specific assertions yet — that
- waits for the user to validate each quirk.
-- **PR 27+**: one PR per confirmed DL205 quirk, landing the named test + any
- driver-side adjustment (e.g., retry on dropped TxId) needed to pass it.
+- **PR 30 — Integration test project + DL205 profile scaffold** — **DONE**.
+ Shipped `tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests` with
+ `ModbusSimulatorFixture` (TCP-probe, skips with a clear `SkipReason` when the
+ endpoint is unreachable), `DL205/DL205Profile.cs` (tag map stub — one
+ writable holding register at address 100), and `DL205/DL205SmokeTests.cs`
+ (write-then-read round-trip). `ModbusPal/` directory holds the README
+ pointing at the to-be-committed `DL205.xmpp` profile.
+- **PR 31+**: one PR per confirmed DL205 quirk, landing the named test + any
+ driver-side adjustment (e.g., retry on dropped TxId) needed to pass it. Drop
+ the `DL205.xmpp` profile into `ModbusPal/` alongside the first quirk PR.
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/DL205/DL205Profile.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/DL205/DL205Profile.cs
new file mode 100644
index 0000000..97b5cc3
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/DL205/DL205Profile.cs
@@ -0,0 +1,45 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.DL205;
+
+///
+/// Tag map for the AutomationDirect DL205 device class. Mirrors what the ModbusPal
+/// .xmpp profile in ModbusPal/DL205.xmpp exposes (or the real PLC, when
+/// is pointed at one).
+///
+///
+/// This is the scaffold — each tag is deliberately generic so the smoke test has stable
+/// addresses to read. Device-specific quirk tests (word order, max-register, register-zero
+/// access, etc.) will land in their own test classes alongside this profile as the user
+/// validates each behavior in ModbusPal; see docs/v2/modbus-test-plan.md §per-device
+/// quirk catalog for the checklist.
+///
+public static class DL205Profile
+{
+ /// Holding register the smoke test reads. Address 100 sidesteps the DL205
+ /// register-zero quirk (pending confirmation) — see modbus-test-plan.md.
+ public const ushort SmokeHoldingRegister = 100;
+
+ /// Expected value the ModbusPal profile seeds into register 100. When running
+ /// against a real DL205 (or a ModbusPal profile where this register is writable), the smoke
+ /// test seeds this value first, then reads it back.
+ public const short SmokeHoldingValue = 1234;
+
+ public static ModbusDriverOptions BuildOptions(string host, int port) => new()
+ {
+ Host = host,
+ Port = port,
+ UnitId = 1,
+ Timeout = TimeSpan.FromSeconds(2),
+ Tags =
+ [
+ new ModbusTagDefinition(
+ Name: "DL205_Smoke_HReg100",
+ 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/DL205/DL205SmokeTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/DL205/DL205SmokeTests.cs
new file mode 100644
index 0000000..d1dd22c
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/DL205/DL205SmokeTests.cs
@@ -0,0 +1,53 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.DL205;
+
+///
+/// End-to-end smoke against the DL205 ModbusPal profile (or a real DL205 when
+/// MODBUS_SIM_ENDPOINT points at one). Drives the full
+/// + real stack — no fake transport. Success proves the
+/// driver can initialize against the simulator, write a known value, and read it back
+/// with the correct status and value, which is the baseline every device-quirk test
+/// builds on.
+///
+///
+/// Device-specific quirk tests (word order, max-register, register-zero access, exception
+/// code translation, etc.) land as separate test classes in this directory as each quirk
+/// is validated in ModbusPal. Keep this smoke test deliberately narrow — any deviation
+/// the driver hits beyond "happy-path FC16 + FC03 round-trip" belongs in its own named
+/// test so filtering by device class (--filter DisplayName~DL205) surfaces the
+/// quirk-specific failure mode.
+///
+[Collection(ModbusSimulatorCollection.Name)]
+[Trait("Category", "Integration")]
+[Trait("Device", "DL205")]
+public sealed class DL205SmokeTests(ModbusSimulatorFixture sim)
+{
+ [Fact]
+ public async Task DL205_roundtrip_write_then_read_of_holding_register()
+ {
+ if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
+
+ var options = DL205Profile.BuildOptions(sim.Host, sim.Port);
+ await using var driver = new ModbusDriver(options, driverInstanceId: "dl205-smoke");
+ await driver.InitializeAsync(driverConfigJson: "{}", TestContext.Current.CancellationToken);
+
+ // Write first so the test is self-contained — ModbusPal's default register bank is
+ // zeroed at simulator start, and tests must not depend on prior-test state per the
+ // test-plan conventions.
+ var writeResults = await driver.WriteAsync(
+ [new(FullReference: "DL205_Smoke_HReg100", Value: (short)DL205Profile.SmokeHoldingValue)],
+ TestContext.Current.CancellationToken);
+ writeResults.Count.ShouldBe(1);
+ writeResults[0].StatusCode.ShouldBe(0u, "write must succeed against the ModbusPal DL205 profile");
+
+ var readResults = await driver.ReadAsync(
+ ["DL205_Smoke_HReg100"],
+ TestContext.Current.CancellationToken);
+ readResults.Count.ShouldBe(1);
+ readResults[0].StatusCode.ShouldBe(0u);
+ readResults[0].Value.ShouldBe((short)DL205Profile.SmokeHoldingValue);
+ }
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ModbusPal/README.md b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ModbusPal/README.md
new file mode 100644
index 0000000..c2fcdbd
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ModbusPal/README.md
@@ -0,0 +1,30 @@
+# ModbusPal simulator profiles
+
+Drop device-specific `.xmpp` profiles here. The integration tests connect to the
+endpoint in `MODBUS_SIM_ENDPOINT` (default `localhost:502`) and expect the
+simulator to already be running — tests do not launch ModbusPal themselves,
+because its Java GUI + JRE requirement is heavier than the harness is worth.
+
+## Getting started
+
+1. Download ModbusPal from SourceForge (`modbuspal.jar`).
+2. `java -jar modbuspal.jar` to launch the GUI.
+3. Load a profile from this directory (or configure one manually) and start the
+ simulator on TCP port 502.
+4. `dotnet test tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests` — tests
+ auto-skip with a clear `SkipReason` if the TCP probe at the configured
+ endpoint fails within 2 seconds.
+
+## Profile files
+
+- `DL205.xmpp` — _to be added_ — register map reflecting the AutomationDirect
+ DL205 quirks tracked in `docs/v2/modbus-test-plan.md`. The scaffolded smoke
+ test in `DL205/DL205SmokeTests.cs` needs holding register 100 writable and
+ present; a minimal ModbusPal profile with a single holding-register bank at
+ address 100 is sufficient.
+
+## Environment variables
+
+- `MODBUS_SIM_ENDPOINT` — override the simulator endpoint. Accepts `host:port`;
+ defaults to `localhost:502`. Useful when pointing the suite at a real PLC on
+ the bench.
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ModbusSimulatorFixture.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ModbusSimulatorFixture.cs
new file mode 100644
index 0000000..5f55e2d
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ModbusSimulatorFixture.cs
@@ -0,0 +1,66 @@
+using System.Net.Sockets;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests;
+
+///
+/// Reachability probe for a Modbus TCP simulator (ModbusPal or a real PLC). Parses
+/// MODBUS_SIM_ENDPOINT (default localhost:502) and TCP-connects once at
+/// fixture construction. Each test checks and calls
+/// Assert.Skip when the endpoint was unreachable, so a dev box without a running
+/// simulator still passes `dotnet test` cleanly — matches the Galaxy live-smoke pattern in
+/// GalaxyRepositoryLiveSmokeTests.
+///
+///
+///
+/// Do NOT keep the probe socket open for the life of the fixture. The probe is a
+/// one-shot liveness check; tests open their own transports (the real
+/// ) against the same endpoint. Sharing a socket
+/// across tests would serialize them on a single TCP stream.
+///
+///
+/// The fixture is a collection fixture so the reachability probe runs once per test
+/// session, not per test — checking every test would waste several seconds against a
+/// firewalled endpoint that times out each attempt.
+///
+///
+public sealed class ModbusSimulatorFixture : IAsyncDisposable
+{
+ private const string DefaultEndpoint = "localhost:502";
+ private const string EndpointEnvVar = "MODBUS_SIM_ENDPOINT";
+
+ public string Host { get; }
+ public int Port { get; }
+ public string? SkipReason { get; }
+
+ public ModbusSimulatorFixture()
+ {
+ var raw = Environment.GetEnvironmentVariable(EndpointEnvVar) ?? DefaultEndpoint;
+ var parts = raw.Split(':', 2);
+ Host = parts[0];
+ Port = parts.Length == 2 && int.TryParse(parts[1], out var p) ? p : 502;
+
+ try
+ {
+ using var client = new TcpClient();
+ var task = client.ConnectAsync(Host, Port);
+ if (!task.Wait(TimeSpan.FromSeconds(2)) || !client.Connected)
+ {
+ SkipReason = $"Modbus simulator at {Host}:{Port} did not accept a TCP connection within 2s. " +
+ $"Start ModbusPal (or override {EndpointEnvVar}) and re-run.";
+ }
+ }
+ catch (Exception ex)
+ {
+ SkipReason = $"Modbus simulator at {Host}:{Port} unreachable: {ex.GetType().Name}: {ex.Message}. " +
+ $"Start ModbusPal (or override {EndpointEnvVar}) and re-run.";
+ }
+ }
+
+ public ValueTask DisposeAsync() => ValueTask.CompletedTask;
+}
+
+[Xunit.CollectionDefinition(Name)]
+public sealed class ModbusSimulatorCollection : Xunit.ICollectionFixture
+{
+ public const string Name = "ModbusSimulator";
+}
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
new file mode 100644
index 0000000..0d192b5
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.csproj
@@ -0,0 +1,36 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+ true
+ ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+