chore: organize solution into module folders (Core/Server/Drivers/Client/Tooling)

Group all 69 projects into category subfolders under src/ and tests/ so the
Rider Solution Explorer mirrors the module structure. Folders: Core, Server,
Drivers (with a nested Driver CLIs subfolder), Client, Tooling.

- Move every project folder on disk with git mv (history preserved as renames).
- Recompute relative paths in 57 .csproj files: cross-category ProjectReferences,
  the lib/ HintPath+None refs in Driver.Historian.Wonderware, and the external
  mxaccessgw refs in Driver.Galaxy and its test project.
- Rebuild ZB.MOM.WW.OtOpcUa.slnx with nested solution folders.
- Re-prefix project paths in functional scripts (e2e, compliance, smoke SQL,
  integration, install).

Build green (0 errors); unit tests pass. Docs left for a separate pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-17 01:55:28 -04:00
parent 69f02fed7f
commit a25593a9c6
1044 changed files with 365 additions and 343 deletions

View File

@@ -0,0 +1,108 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests;
/// <summary>
/// #138 e2e coverage for the new addressing grammar (#136-#144) against a live pymodbus
/// simulator. Skips when the simulator is unreachable so the suite stays portable on dev
/// boxes without Docker. Coverage is breadth-first — one round-trip per major feature
/// (Modicon parse, suffix grammar, byte order, array, multi-unit) — rather than exhaustive,
/// because the unit tests already pin the per-call semantics; the integration tests prove
/// the wire glue agrees with the simulator end-to-end.
/// </summary>
[Trait("Category", "Integration")]
[Collection("ModbusSimulator")]
public sealed class AddressingGrammarTests
{
private readonly ModbusSimulatorFixture _sim;
public AddressingGrammarTests(ModbusSimulatorFixture sim) => _sim = sim;
private async Task<ModbusDriver> NewDriverAsync(params ModbusTagDefinition[] tags)
{
var opts = new ModbusDriverOptions
{
Host = _sim.Host, Port = _sim.Port, Tags = tags,
Probe = new ModbusProbeOptions { Enabled = false },
};
var drv = new ModbusDriver(opts, "addressing-e2e");
await drv.InitializeAsync("{}", CancellationToken.None);
return drv;
}
[Fact]
public async Task Modicon_5_And_6_Digit_Both_Map_To_Same_Wire_Offset()
{
if (_sim.SkipReason is not null) Assert.Skip(_sim.SkipReason);
// Both tags target HR offset 0 via different grammar forms; reads should be equivalent.
var t5 = new ModbusTagDefinition("Five", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16);
var t6 = new ModbusTagDefinition("Six", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16);
var drv = await NewDriverAsync(t5, t6);
var values = await drv.ReadAsync(["Five", "Six"], CancellationToken.None);
values[0].StatusCode.ShouldBe(values[1].StatusCode);
values[0].Value.ShouldBe(values[1].Value);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Float32_With_CDAB_Roundtrips_Through_Wire()
{
if (_sim.SkipReason is not null) Assert.Skip(_sim.SkipReason);
var tag = new ModbusTagDefinition("Tank", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Float32,
ByteOrder: ModbusByteOrder.WordSwap);
var drv = await NewDriverAsync(tag);
await drv.WriteAsync([new WriteRequest("Tank", 12.5f)], CancellationToken.None);
var read = await drv.ReadAsync(["Tank"], CancellationToken.None);
read[0].Value.ShouldBe(12.5f);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Int16_Array_Reads_Surface_As_Typed_Array()
{
if (_sim.SkipReason is not null) Assert.Skip(_sim.SkipReason);
var tag = new ModbusTagDefinition("Levels", ModbusRegion.HoldingRegisters, 200, ModbusDataType.Int16, ArrayCount: 5);
var drv = await NewDriverAsync(tag);
await drv.WriteAsync([new WriteRequest("Levels", new short[] { 10, 20, 30, 40, 50 })], CancellationToken.None);
var read = await drv.ReadAsync(["Levels"], CancellationToken.None);
var arr = read[0].Value.ShouldBeOfType<short[]>();
arr.ShouldBe(new short[] { 10, 20, 30, 40, 50 });
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Block_Read_Coalescing_Reduces_PDU_Count_End_To_End()
{
if (_sim.SkipReason is not null) Assert.Skip(_sim.SkipReason);
// Sanity check that the simulator accepts the larger PDU coalescing produces.
var t1 = new ModbusTagDefinition("T1", ModbusRegion.HoldingRegisters, 300, ModbusDataType.Int16);
var t2 = new ModbusTagDefinition("T2", ModbusRegion.HoldingRegisters, 302, ModbusDataType.Int16);
var t3 = new ModbusTagDefinition("T3", ModbusRegion.HoldingRegisters, 304, ModbusDataType.Int16);
var opts = new ModbusDriverOptions
{
Host = _sim.Host, Port = _sim.Port, Tags = [t1, t2, t3], MaxReadGap = 5,
Probe = new ModbusProbeOptions { Enabled = false },
};
var drv = new ModbusDriver(opts, "addressing-e2e");
await drv.InitializeAsync("{}", CancellationToken.None);
var read = await drv.ReadAsync(["T1", "T2", "T3"], CancellationToken.None);
read.Count.ShouldBe(3);
// All three should read Good (the simulator accepts a 5-register coalesced read).
read.All(v => v.StatusCode == 0u).ShouldBeTrue("coalesced read must succeed against the simulator");
await drv.ShutdownAsync(CancellationToken.None);
}
}

View File

@@ -0,0 +1,56 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.DL205;
/// <summary>
/// Verifies DL205/DL260 binary-coded-decimal register handling against the
/// <c>dl205.json</c> pymodbus profile. HR[1072] = 0x1234 on the profile represents
/// decimal 1234 (BCD nibbles). Reading it as <see cref="ModbusDataType.Int16"/> would
/// return 0x1234 = 4660; the <see cref="ModbusDataType.Bcd16"/> path decodes 1234.
/// </summary>
[Collection(ModbusSimulatorCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Device", "DL205")]
public sealed class DL205BcdQuirkTests(ModbusSimulatorFixture sim)
{
[Fact]
public async Task DL205_BCD16_decodes_HR1072_as_decimal_1234()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
if (!string.Equals(Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE"), "dl205",
StringComparison.OrdinalIgnoreCase))
{
Assert.Skip("MODBUS_SIM_PROFILE != dl205 — skipping (standard profile does not seed HR[1072]).");
}
var options = new ModbusDriverOptions
{
Host = sim.Host,
Port = sim.Port,
UnitId = 1,
Timeout = TimeSpan.FromSeconds(2),
Tags =
[
new ModbusTagDefinition("DL205_Count_Bcd",
ModbusRegion.HoldingRegisters, Address: 1072,
DataType: ModbusDataType.Bcd16, Writable: false),
new ModbusTagDefinition("DL205_Count_Int16",
ModbusRegion.HoldingRegisters, Address: 1072,
DataType: ModbusDataType.Int16, Writable: false),
],
Probe = new ModbusProbeOptions { Enabled = false },
};
await using var driver = new ModbusDriver(options, driverInstanceId: "dl205-bcd");
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
var results = await driver.ReadAsync(["DL205_Count_Bcd", "DL205_Count_Int16"],
TestContext.Current.CancellationToken);
results[0].StatusCode.ShouldBe(0u);
results[0].Value.ShouldBe(1234, "DL205 BCD register 0x1234 represents decimal 1234 per the DirectLOGIC convention");
results[1].StatusCode.ShouldBe(0u);
results[1].Value.ShouldBe((short)0x1234, "same register read as Int16 returns the raw 0x1234 = 4660 value — proves BCD path is distinct");
}
}

View File

@@ -0,0 +1,109 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.DL205;
/// <summary>
/// Verifies DL260 I/O-memory coil mappings against the <c>dl205.json</c> pymodbus profile.
/// DirectLOGIC Y-outputs and C-relays are exposed to Modbus as FC01/FC05 coils, but at
/// non-zero base addresses that confuse operators used to "Y0 is the first coil". The sim
/// seeds Y0 → coil 2048 = ON and C0 → coil 3072 = ON as fixed markers.
/// </summary>
[Collection(ModbusSimulatorCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Device", "DL205")]
public sealed class DL205CoilMappingTests(ModbusSimulatorFixture sim)
{
[Fact]
public async Task DL260_Y0_maps_to_coil_2048()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
if (!string.Equals(Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE"), "dl205",
StringComparison.OrdinalIgnoreCase))
{
Assert.Skip("MODBUS_SIM_PROFILE != dl205 — skipping.");
}
var coil = DirectLogicAddress.YOutputToCoil("Y0");
coil.ShouldBe((ushort)2048);
var options = BuildOptions(sim, [
new ModbusTagDefinition("DL260_Y0",
ModbusRegion.Coils, Address: coil,
DataType: ModbusDataType.Bool, Writable: false),
]);
await using var driver = new ModbusDriver(options, driverInstanceId: "dl205-y0");
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
var results = await driver.ReadAsync(["DL260_Y0"], TestContext.Current.CancellationToken);
results[0].StatusCode.ShouldBe(0u);
results[0].Value.ShouldBe(true, "dl205.json seeds coil 2048 (Y0) = ON");
}
[Fact]
public async Task DL260_C0_maps_to_coil_3072()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
if (!string.Equals(Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE"), "dl205",
StringComparison.OrdinalIgnoreCase))
{
Assert.Skip("MODBUS_SIM_PROFILE != dl205 — skipping.");
}
var coil = DirectLogicAddress.CRelayToCoil("C0");
coil.ShouldBe((ushort)3072);
var options = BuildOptions(sim, [
new ModbusTagDefinition("DL260_C0",
ModbusRegion.Coils, Address: coil,
DataType: ModbusDataType.Bool, Writable: false),
]);
await using var driver = new ModbusDriver(options, driverInstanceId: "dl205-c0");
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
var results = await driver.ReadAsync(["DL260_C0"], TestContext.Current.CancellationToken);
results[0].StatusCode.ShouldBe(0u);
results[0].Value.ShouldBe(true, "dl205.json seeds coil 3072 (C0) = ON");
}
[Fact]
public async Task DL260_scratch_Crelay_supports_write_then_read()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
if (!string.Equals(Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE"), "dl205",
StringComparison.OrdinalIgnoreCase))
{
Assert.Skip("MODBUS_SIM_PROFILE != dl205 — skipping.");
}
// Scratch C-relay at coil 4000 (per dl205.json _quirk note) is writable. Write=true then
// read back to confirm FC05 round-trip works against the DL-mapped coil bank.
var options = BuildOptions(sim, [
new ModbusTagDefinition("DL260_C_Scratch",
ModbusRegion.Coils, Address: 4000,
DataType: ModbusDataType.Bool, Writable: true),
]);
await using var driver = new ModbusDriver(options, driverInstanceId: "dl205-cscratch");
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
var writeResults = await driver.WriteAsync(
[new(FullReference: "DL260_C_Scratch", Value: true)],
TestContext.Current.CancellationToken);
writeResults[0].StatusCode.ShouldBe(0u);
var readResults = await driver.ReadAsync(["DL260_C_Scratch"], TestContext.Current.CancellationToken);
readResults[0].StatusCode.ShouldBe(0u);
readResults[0].Value.ShouldBe(true);
}
private static ModbusDriverOptions BuildOptions(ModbusSimulatorFixture sim, IReadOnlyList<ModbusTagDefinition> tags)
=> new()
{
Host = sim.Host,
Port = sim.Port,
UnitId = 1,
Timeout = TimeSpan.FromSeconds(2),
Tags = tags,
Probe = new ModbusProbeOptions { Enabled = false },
};
}

View File

@@ -0,0 +1,53 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.DL205;
/// <summary>
/// Verifies the driver's Modbus-exception → OPC UA StatusCode translation end-to-end
/// against the dl205.json pymodbus profile. pymodbus returns exception 02 (Illegal Data
/// Address) for reads outside the configured register ranges, matching real DL205/DL260
/// firmware behavior per <c>docs/v2/dl205.md</c> §exception-codes. The driver must surface
/// that as <c>BadOutOfRange</c> (0x803C0000) — not <c>BadInternalError</c> — so the
/// operator sees a tag-config diagnosis instead of a generic driver-fault message.
/// </summary>
[Collection(ModbusSimulatorCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Device", "DL205")]
public sealed class DL205ExceptionCodeTests(ModbusSimulatorFixture sim)
{
[Fact]
public async Task DL205_FC03_at_unmapped_register_returns_BadOutOfRange()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
if (!string.Equals(Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE"), "dl205",
StringComparison.OrdinalIgnoreCase))
{
Assert.Skip("MODBUS_SIM_PROFILE != dl205 — skipping.");
}
// Address 16383 is the last cell of hr-size=16384 in dl205.json; address 16384 is
// beyond the configured HR range. pymodbus validates and returns exception 02
// (Illegal Data Address).
var options = new ModbusDriverOptions
{
Host = sim.Host,
Port = sim.Port,
UnitId = 1,
Timeout = TimeSpan.FromSeconds(2),
Tags =
[
new ModbusTagDefinition("Unmapped",
ModbusRegion.HoldingRegisters, Address: 16383,
DataType: ModbusDataType.UInt16, Writable: false),
],
Probe = new ModbusProbeOptions { Enabled = false },
};
await using var driver = new ModbusDriver(options, driverInstanceId: "dl205-exc");
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
var results = await driver.ReadAsync(["Unmapped"], TestContext.Current.CancellationToken);
results[0].StatusCode.ShouldBe(0x803C0000u,
"DL205 returns exception 02 for an FC03 at an unmapped register; driver must translate to BadOutOfRange (not BadInternalError)");
}
}

View File

@@ -0,0 +1,64 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.DL205;
/// <summary>
/// Verifies DL205/DL260 CDAB word ordering for 32-bit floats against the
/// <c>dl205.json</c> pymodbus profile. DirectLOGIC stores IEEE-754 singles with the low
/// word at the lower register address (CDAB) rather than the high word (ABCD). Reading
/// <c>HR[1056..1057]</c> with <see cref="ModbusByteOrder.BigEndian"/> produces a tiny
/// denormal (~5.74e-41) instead of the intended 1.5f — a silent "value is 0" bug in the
/// field unless the caller opts into <see cref="ModbusByteOrder.WordSwap"/>.
/// </summary>
[Collection(ModbusSimulatorCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Device", "DL205")]
public sealed class DL205FloatCdabQuirkTests(ModbusSimulatorFixture sim)
{
[Fact]
public async Task DL205_Float32_CDAB_decodes_1_5f_from_HR1056()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
if (!string.Equals(Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE"), "dl205",
StringComparison.OrdinalIgnoreCase))
{
Assert.Skip("MODBUS_SIM_PROFILE != dl205 — skipping (standard profile does not seed HR[1056..1057]).");
}
var options = new ModbusDriverOptions
{
Host = sim.Host,
Port = sim.Port,
UnitId = 1,
Timeout = TimeSpan.FromSeconds(2),
Tags =
[
new ModbusTagDefinition("DL205_Float_CDAB",
ModbusRegion.HoldingRegisters, Address: 1056,
DataType: ModbusDataType.Float32, Writable: false,
ByteOrder: ModbusByteOrder.WordSwap),
// Control: same address, BigEndian — proves the default decode produces garbage.
new ModbusTagDefinition("DL205_Float_ABCD",
ModbusRegion.HoldingRegisters, Address: 1056,
DataType: ModbusDataType.Float32, Writable: false,
ByteOrder: ModbusByteOrder.BigEndian),
],
Probe = new ModbusProbeOptions { Enabled = false },
};
await using var driver = new ModbusDriver(options, driverInstanceId: "dl205-cdab");
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
var results = await driver.ReadAsync(["DL205_Float_CDAB", "DL205_Float_ABCD"],
TestContext.Current.CancellationToken);
results[0].StatusCode.ShouldBe(0u);
results[0].Value.ShouldBe(1.5f, "DL205 Float32 with WordSwap (CDAB) must decode HR[1056..1057] as 1.5f");
// The BigEndian read of the same wire bytes should differ — not asserting the exact
// denormal value (that couples the test to IEEE-754 bit math) but the two decodes MUST
// disagree, otherwise the word-order flag is a no-op.
results[1].StatusCode.ShouldBe(0u);
results[1].Value.ShouldNotBe(1.5f);
}
}

View File

@@ -0,0 +1,49 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.DL205;
/// <summary>
/// Tag map for the AutomationDirect DL205 device class. Mirrors what the pymodbus
/// <c>dl205.json</c> profile in <c>Docker/profiles/dl205.json</c> exposes (or the real PLC, when
/// <see cref="ModbusSimulatorFixture"/> is pointed at one).
/// </summary>
/// <remarks>
/// 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 pymodbus; see <c>docs/v2/modbus-test-plan.md</c> §per-device
/// quirk catalog for the checklist.
/// </remarks>
public static class DL205Profile
{
/// <summary>
/// Holding register the smoke test writes + reads. Address 200 is the first cell of the
/// scratch HR range in both <c>Docker/profiles/standard.json</c> (HR[200..209] = 0) and
/// <c>Docker/profiles/dl205.json</c> (HR[4096..4103] added in PR 43 for the same purpose), so
/// the smoke test runs identically against either simulator profile. Originally
/// targeted HR[100] — moved to HR[200] when the standard profile claimed HR[100] as
/// the auto-incrementing register that drives subscribe-and-receive tests.
/// </summary>
public const ushort SmokeHoldingRegister = 200;
/// <summary>Value the smoke test writes then reads back to assert round-trip integrity.</summary>
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: "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 },
};
}

View File

@@ -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;
/// <summary>
/// End-to-end smoke against the DL205 ModbusPal profile (or a real DL205 when
/// <c>MODBUS_SIM_ENDPOINT</c> points at one). Drives the full <see cref="ModbusDriver"/>
/// + real <see cref="ModbusTcpTransport"/> 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.
/// </summary>
/// <remarks>
/// 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 (<c>--filter DisplayName~DL205</c>) surfaces the
/// quirk-specific failure mode.
/// </remarks>
[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: "Smoke_HReg200", 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(
["Smoke_HReg200"],
TestContext.Current.CancellationToken);
readResults.Count.ShouldBe(1);
readResults[0].StatusCode.ShouldBe(0u);
readResults[0].Value.ShouldBe((short)DL205Profile.SmokeHoldingValue);
}
}

View File

@@ -0,0 +1,81 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.DL205;
/// <summary>
/// Verifies the DL205/DL260 low-byte-first ASCII string packing quirk against the
/// <c>dl205.json</c> pymodbus profile. Standard Modbus packs the first char of each pair
/// in the high byte of the register; DirectLOGIC packs it in the low byte instead. Without
/// <see cref="ModbusStringByteOrder.LowByteFirst"/> the driver decodes "eHllo" garbage
/// even though the bytes on the wire are identical.
/// </summary>
/// <remarks>
/// <para>
/// Requires the dl205 profile (<c>docker compose -f Docker/docker-compose.yml --profile dl205 up</c>). The standard
/// profile does not seed HR[1040..1042] with string bytes, so running this against the
/// standard profile returns <c>"\0\0\0\0\0"</c> and the test fails. Skip when the env
/// var <c>MODBUS_SIM_PROFILE</c> is not set to <c>dl205</c>.
/// </para>
/// </remarks>
[Collection(ModbusSimulatorCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Device", "DL205")]
public sealed class DL205StringQuirkTests(ModbusSimulatorFixture sim)
{
[Fact]
public async Task DL205_string_low_byte_first_decodes_Hello_from_HR1040()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
if (!string.Equals(Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE"), "dl205",
StringComparison.OrdinalIgnoreCase))
{
Assert.Skip("MODBUS_SIM_PROFILE != dl205 — skipping (standard profile does not seed HR[1040..1042]).");
}
var options = new ModbusDriverOptions
{
Host = sim.Host,
Port = sim.Port,
UnitId = 1,
Timeout = TimeSpan.FromSeconds(2),
Tags =
[
new ModbusTagDefinition(
Name: "DL205_Hello_Low",
Region: ModbusRegion.HoldingRegisters,
Address: 1040,
DataType: ModbusDataType.String,
Writable: false,
StringLength: 5,
StringByteOrder: ModbusStringByteOrder.LowByteFirst),
// Control: same address, HighByteFirst, to prove the driver would have decoded
// garbage without the quirk flag.
new ModbusTagDefinition(
Name: "DL205_Hello_High",
Region: ModbusRegion.HoldingRegisters,
Address: 1040,
DataType: ModbusDataType.String,
Writable: false,
StringLength: 5,
StringByteOrder: ModbusStringByteOrder.HighByteFirst),
],
Probe = new ModbusProbeOptions { Enabled = false },
};
await using var driver = new ModbusDriver(options, driverInstanceId: "dl205-string");
await driver.InitializeAsync(driverConfigJson: "{}", TestContext.Current.CancellationToken);
var results = await driver.ReadAsync(["DL205_Hello_Low", "DL205_Hello_High"],
TestContext.Current.CancellationToken);
results.Count.ShouldBe(2);
results[0].StatusCode.ShouldBe(0u);
results[0].Value.ShouldBe("Hello", "DL205 low-byte-first ordering must produce 'Hello' from HR[1040..1042]");
// The high-byte-first read of the same wire bytes should differ — not asserting the
// exact garbage string (that would couple the test to the ASCII byte math) but the two
// decodes MUST disagree, otherwise the quirk flag is a no-op.
results[1].StatusCode.ShouldBe(0u);
results[1].Value.ShouldNotBe("Hello");
}
}

View File

@@ -0,0 +1,91 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.DL205;
/// <summary>
/// Verifies the DL205/DL260 V-memory octal addressing quirk end-to-end: use
/// <see cref="DirectLogicAddress.UserVMemoryToPdu"/> to translate <c>V2000</c> octal into
/// the Modbus PDU address actually dispatched, then read the marker the dl205.json profile
/// placed at that address. HR[0x0400] = 0x2000 proves the translation was performed
/// correctly — a naïve caller treating "V2000" as decimal 2000 would read HR[2000] (which
/// the profile leaves at 0) and miss the marker entirely.
/// </summary>
[Collection(ModbusSimulatorCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Device", "DL205")]
public sealed class DL205VMemoryQuirkTests(ModbusSimulatorFixture sim)
{
[Fact]
public async Task DL205_V2000_user_memory_resolves_to_PDU_0x0400_marker()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
if (!string.Equals(Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE"), "dl205",
StringComparison.OrdinalIgnoreCase))
{
Assert.Skip("MODBUS_SIM_PROFILE != dl205 — skipping (standard profile does not seed V-memory markers).");
}
var pdu = DirectLogicAddress.UserVMemoryToPdu("V2000");
pdu.ShouldBe((ushort)0x0400);
var options = new ModbusDriverOptions
{
Host = sim.Host,
Port = sim.Port,
UnitId = 1,
Timeout = TimeSpan.FromSeconds(2),
Tags =
[
new ModbusTagDefinition("DL205_V2000",
ModbusRegion.HoldingRegisters, Address: pdu,
DataType: ModbusDataType.UInt16, Writable: false),
],
Probe = new ModbusProbeOptions { Enabled = false },
};
await using var driver = new ModbusDriver(options, driverInstanceId: "dl205-vmem");
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
var results = await driver.ReadAsync(["DL205_V2000"], TestContext.Current.CancellationToken);
results[0].StatusCode.ShouldBe(0u);
results[0].Value.ShouldBe((ushort)0x2000, "dl205.json seeds HR[0x0400] with marker 0x2000 (= V2000 value)");
}
[Fact]
public async Task DL205_V40400_system_memory_resolves_to_PDU_0x2100_marker()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
if (!string.Equals(Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE"), "dl205",
StringComparison.OrdinalIgnoreCase))
{
Assert.Skip("MODBUS_SIM_PROFILE != dl205 — skipping.");
}
// V40400 is system memory on DL260 / H2-ECOM100 absolute mode; it does NOT follow the
// simple octal-to-decimal formula (40400 octal = 16640 decimal, which would read HR[0x4100]).
// The CPU places the system bank at PDU 0x2100 instead. Proving the helper routes there.
var pdu = DirectLogicAddress.SystemVMemoryToPdu(0);
pdu.ShouldBe((ushort)0x2100);
var options = new ModbusDriverOptions
{
Host = sim.Host,
Port = sim.Port,
UnitId = 1,
Timeout = TimeSpan.FromSeconds(2),
Tags =
[
new ModbusTagDefinition("DL205_V40400",
ModbusRegion.HoldingRegisters, Address: pdu,
DataType: ModbusDataType.UInt16, Writable: false),
],
Probe = new ModbusProbeOptions { Enabled = false },
};
await using var driver = new ModbusDriver(options, driverInstanceId: "dl205-sysv");
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
var results = await driver.ReadAsync(["DL205_V40400"], TestContext.Current.CancellationToken);
results[0].StatusCode.ShouldBe(0u);
results[0].Value.ShouldBe((ushort)0x4040, "dl205.json seeds HR[0x2100] with marker 0x4040 (= V40400 value)");
}
}

View File

@@ -0,0 +1,71 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.DL205;
/// <summary>
/// Verifies the DL260 X-input discrete-input mapping against the <c>dl205.json</c>
/// pymodbus profile. X-inputs are FC02 discrete-input-only (Modbus doesn't allow writes
/// to discrete inputs), and the DirectLOGIC convention is X0 → DI 0 with octal offsets
/// for subsequent addresses. The sim seeds X20 octal (= DI 16) = ON so the test can
/// prove the helper routes through to the right cell.
/// </summary>
/// <remarks>
/// X0 / X1 / …X17 octal all share cell 0 (DI 0-15 → cell 0 bits 0-15) which conflicts
/// with the V0 uint16 marker; we can't seed both types at cell 0 under shared-blocks
/// semantics. So the test uses X20 octal (first address beyond the cell-0 boundary) which
/// lands cleanly at cell 1 bit 0 and leaves the V0 register-zero quirk intact.
/// </remarks>
[Collection(ModbusSimulatorCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Device", "DL205")]
public sealed class DL205XInputTests(ModbusSimulatorFixture sim)
{
[Fact]
public async Task DL260_X20_octal_maps_to_DiscreteInput_16_and_reads_ON()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
if (!string.Equals(Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE"), "dl205",
StringComparison.OrdinalIgnoreCase))
{
Assert.Skip("MODBUS_SIM_PROFILE != dl205 — skipping.");
}
// X20 octal = decimal 16 = DI 16 per the DL260 convention (X-inputs start at DI 0).
var di = DirectLogicAddress.XInputToDiscrete("X20");
di.ShouldBe((ushort)16);
var options = BuildOptions(sim, [
new ModbusTagDefinition("DL260_X20",
ModbusRegion.DiscreteInputs, Address: di,
DataType: ModbusDataType.Bool, Writable: false),
// Unpopulated-X control: pymodbus returns 0 (not exception) for any bit in the
// configured DI range that wasn't explicitly seeded — per docs/v2/dl205.md
// "Reading a non-populated X input ... returns zero, not an exception".
new ModbusTagDefinition("DL260_X21_off",
ModbusRegion.DiscreteInputs, Address: DirectLogicAddress.XInputToDiscrete("X21"),
DataType: ModbusDataType.Bool, Writable: false),
]);
await using var driver = new ModbusDriver(options, driverInstanceId: "dl205-xinput");
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
var results = await driver.ReadAsync(["DL260_X20", "DL260_X21_off"], TestContext.Current.CancellationToken);
results[0].StatusCode.ShouldBe(0u);
results[0].Value.ShouldBe(true, "dl205.json seeds cell 1 bit 0 (X20 octal = DI 16) = ON");
results[1].StatusCode.ShouldBe(0u, "unpopulated X inputs must read cleanly — DL260 does NOT raise an exception");
results[1].Value.ShouldBe(false);
}
private static ModbusDriverOptions BuildOptions(ModbusSimulatorFixture sim, IReadOnlyList<ModbusTagDefinition> tags)
=> new()
{
Host = sim.Host,
Port = sim.Port,
UnitId = 1,
Timeout = TimeSpan.FromSeconds(2),
Tags = tags,
Probe = new ModbusProbeOptions { Enabled = false },
};
}

View File

@@ -0,0 +1,33 @@
# pymodbus simulator container for the Modbus integration suite.
#
# Pinned base + package version so the fixture surface is reproducible —
# matches the version referenced in docs/drivers/Modbus-Test-Fixture.md.
FROM python:3.12-slim-bookworm
LABEL org.opencontainers.image.source="https://github.com/dohertj2/lmxopcua" \
org.opencontainers.image.description="pymodbus simulator for OtOpcUa Modbus driver integration tests"
RUN pip install --no-cache-dir "pymodbus[simulator]==3.13.0"
# Ship every profile in the image so one container can serve whichever
# family a test run needs; the compose file picks which JSON is active via
# the command override.
WORKDIR /fixtures
COPY profiles/ /fixtures/
# Standalone exception-injection server (pure Python stdlib — no pymodbus
# dependency). Speaks raw Modbus/TCP and emits arbitrary exception codes
# per rules in exception_injection.json. Drives the `exception_injection`
# compose profile. See Docker/README.md §exception injection.
COPY exception_injector.py /fixtures/
EXPOSE 5020
# Default to the standard profile; docker-compose.yml overrides per service.
# --http_port intentionally omitted; pymodbus 3.13's web UI binds on a
# container-local default we don't publish, so it's not reachable from the
# host and costs nothing.
CMD ["pymodbus.simulator", \
"--modbus_server", "srv", \
"--modbus_device", "dev", \
"--json_file", "/fixtures/standard.json"]

View File

@@ -0,0 +1,102 @@
# Modbus integration-test fixture — pymodbus simulator
The Modbus driver's integration tests talk to a
[`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 |
|---|---|
| [`Dockerfile`](Dockerfile) | `python:3.12-slim-bookworm` + `pymodbus[simulator]==3.13.0` + every profile JSON + `exception_injector.py` |
| [`docker-compose.yml`](docker-compose.yml) | One service per profile (`standard` / `dl205` / `mitsubishi` / `s7_1500` / `exception_injection`); all bind `:5020` so only one runs at a time |
| [`profiles/*.json`](profiles/) | Same seed-register definitions the native launcher uses — canonical source |
| [`exception_injector.py`](exception_injector.py) | Pure-stdlib Modbus/TCP server that emits arbitrary exception codes per rule — used by the `exception_injection` profile |
## Run
From the repo root:
```powershell
# Build + start the standard profile
docker compose -f tests\ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests\Docker\docker-compose.yml --profile standard up
# DL205 quirks
docker compose -f tests\ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests\Docker\docker-compose.yml --profile dl205 up
# Mitsubishi MELSEC quirks
docker compose -f tests\ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests\Docker\docker-compose.yml --profile mitsubishi up
# Siemens S7-1500 MB_SERVER quirks
docker compose -f tests\ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests\Docker\docker-compose.yml --profile s7_1500 up
# Exception-injection — end-to-end coverage of every Modbus exception code
# (01/02/03/04/05/06/0A/0B), not just the 02 + 03 pymodbus emits naturally
docker compose -f tests\ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests\Docker\docker-compose.yml --profile exception_injection up
```
Detached + stop:
```powershell
docker compose -f tests\...\Docker\docker-compose.yml --profile dl205 up -d
docker compose -f tests\...\Docker\docker-compose.yml --profile dl205 down
```
Only one profile binds `:5020` at a time; switch by stopping the current
service + starting another. The integration tests discriminate by a
separate `MODBUS_SIM_PROFILE` env var so they skip correctly when the
wrong profile is live.
## Endpoint
- Default: `localhost:5020`
- Override with `MODBUS_SIM_ENDPOINT` (e.g. a real PLC on `:502`).
## Run the integration tests
In a separate shell with one profile live:
```powershell
cd C:\Users\dohertj2\Desktop\lmxopcua
dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests
```
`ModbusSimulatorFixture` probes `localhost:5020` at collection init +
records a `SkipReason` when unreachable, so tests stay green on a fresh
clone without Docker running.
## Exception injection
pymodbus's simulator naturally emits only Modbus exception codes `0x02`
(Illegal Data Address, on reads outside its configured ranges) and
`0x03` (Illegal Data Value, on over-length requests). The driver's
`MapModbusExceptionToStatus` table translates eight codes: `0x01`,
`0x02`, `0x03`, `0x04`, `0x05`, `0x06`, `0x0A`, `0x0B`. Unit tests
lock the translation function; the integration side previously only
proved the wire-to-status path for `0x02`.
The `exception_injection` profile runs
[`exception_injector.py`](exception_injector.py) — a tiny standalone
Modbus/TCP server written against the Python stdlib (zero
dependencies outside what's in the base image). It speaks the wire
protocol directly (FC 01/02/03/04/05/06/15/16) and looks up each
incoming `(fc, address)` against the rules in
[`profiles/exception_injection.json`](profiles/exception_injection.json);
a matching rule makes the server reply with
`[fc | 0x80, exception_code]` instead of the normal response.
Current rules (see the JSON file for the canonical list):
- `FC03 @1000..1007` — one per exception code (`0x01`/`0x02`/`0x03`/`0x04`/`0x05`/`0x06`/`0x0A`/`0x0B`)
- `FC06 @2000..2001``0x04` Server Failure, `0x06` Server Busy (write-path coverage)
- `FC16 @3000``0x04` Server Failure (multi-register write path)
Adding more coverage is append-only: drop a new `{fc, address,
exception, description}` entry into the JSON, restart the service,
add an `[InlineData]` row in `ExceptionInjectionTests`.
## References
- [`docs/drivers/Modbus-Test-Fixture.md`](../../../docs/drivers/Modbus-Test-Fixture.md) — coverage map + gap inventory
- [`docs/v2/dev-environment.md`](../../../docs/v2/dev-environment.md) §Docker fixtures — full fixture inventory

View File

@@ -0,0 +1,100 @@
# Modbus integration-test fixture — pymodbus simulator.
#
# One service per profile. Bring up only the profile a test class needs;
# they all bind :5020 on the host so can't run concurrently. The compose
# `profiles:` feature gates which service spins up via `--profile <name>`.
#
# Usage:
# docker compose --profile standard up
# docker compose --profile dl205 up
# docker compose --profile mitsubishi up
# docker compose --profile s7_1500 up
services:
standard:
profiles: ["standard"]
build:
context: .
dockerfile: Dockerfile
image: otopcua-pymodbus:3.13.0
container_name: otopcua-pymodbus-standard
restart: "no"
ports:
- "5020:5020"
command: [
"pymodbus.simulator",
"--modbus_server", "srv",
"--modbus_device", "dev",
"--json_file", "/fixtures/standard.json"
]
dl205:
profiles: ["dl205"]
image: otopcua-pymodbus:3.13.0
build:
context: .
dockerfile: Dockerfile
container_name: otopcua-pymodbus-dl205
restart: "no"
ports:
- "5020:5020"
command: [
"pymodbus.simulator",
"--modbus_server", "srv",
"--modbus_device", "dev",
"--json_file", "/fixtures/dl205.json"
]
mitsubishi:
profiles: ["mitsubishi"]
image: otopcua-pymodbus:3.13.0
build:
context: .
dockerfile: Dockerfile
container_name: otopcua-pymodbus-mitsubishi
restart: "no"
ports:
- "5020:5020"
command: [
"pymodbus.simulator",
"--modbus_server", "srv",
"--modbus_device", "dev",
"--json_file", "/fixtures/mitsubishi.json"
]
s7_1500:
profiles: ["s7_1500"]
image: otopcua-pymodbus:3.13.0
build:
context: .
dockerfile: Dockerfile
container_name: otopcua-pymodbus-s7_1500
restart: "no"
ports:
- "5020:5020"
command: [
"pymodbus.simulator",
"--modbus_server", "srv",
"--modbus_device", "dev",
"--json_file", "/fixtures/s7_1500.json"
]
# Exception-injection profile. Runs the standalone pure-stdlib Modbus/TCP
# server shipped as exception_injector.py instead of the pymodbus
# simulator — pymodbus naturally emits only exception codes 02 + 03, and
# this profile extends integration coverage to the other codes the
# driver's MapModbusExceptionToStatus table handles (01, 04, 05, 06,
# 0A, 0B). Rules are driven by exception_injection.json.
exception_injection:
profiles: ["exception_injection"]
image: otopcua-pymodbus:3.13.0
build:
context: .
dockerfile: Dockerfile
container_name: otopcua-modbus-exception-injector
restart: "no"
ports:
- "5020:5020"
command: [
"python", "/fixtures/exception_injector.py",
"--config", "/fixtures/exception_injection.json"
]

View File

@@ -0,0 +1,261 @@
#!/usr/bin/env python3
"""
Minimal Modbus/TCP server that supports per-address + per-function-code
exception injection — the missing piece of the pymodbus simulator, which
only naturally emits exception code 02 (Illegal Data Address) via its
"invalid" list and 03 (Illegal Data Value) via spec-enforced length caps.
Integration tests against this fixture drive the driver's
`MapModbusExceptionToStatus` end-to-end over the wire for codes 01, 04,
05, 06, 0A, 0B — the ones the pymodbus simulator can't be configured to
return.
Wire protocol — straight Modbus/TCP (spec chapter 7.1):
MBAP header (7 bytes): [tx_id:u16 BE][proto=0:u16][length:u16][unit_id:u8]
then length-1 bytes of PDU. Length covers unit_id + PDU.
Supported function codes (enough for the driver's RMW + read paths):
01 Read Coils, 02 Read Discrete Inputs,
03 Read Holding Registers, 04 Read Input Registers,
05 Write Single Coil, 06 Write Single Register,
15 Write Multiple Coils, 16 Write Multiple Registers.
Config JSON schema (see exception_injection.json):
{
"listen": { "host": "0.0.0.0", "port": 5020 },
"seeds": { "hr": { "<addr>": <uint16>, ... },
"ir": { "<addr>": <uint16>, ... },
"co": { "<addr>": <0|1>, ... },
"di": { "<addr>": <0|1>, ... } },
"rules": [ { "fc": <int>, "address": <int>, "exception": <int>,
"description": "..." }, ... ]
}
Rules match on (fc, starting address). A matching rule wins and the server
responds with the PDU `[fc | 0x80, exception_code]`.
Zero runtime dependencies outside the Python stdlib so the Docker image
stays tiny.
"""
from __future__ import annotations
import argparse
import asyncio
import json
import logging
import struct
import sys
from dataclasses import dataclass
log = logging.getLogger("exception_injector")
@dataclass(frozen=True)
class Rule:
fc: int
address: int
exception: int
description: str = ""
class Store:
"""In-memory data store backing non-injected reads + writes."""
def __init__(self, seeds: dict[str, dict[str, int]]) -> None:
self.hr: dict[int, int] = {int(k): int(v) for k, v in seeds.get("hr", {}).items()}
self.ir: dict[int, int] = {int(k): int(v) for k, v in seeds.get("ir", {}).items()}
self.co: dict[int, int] = {int(k): int(v) for k, v in seeds.get("co", {}).items()}
self.di: dict[int, int] = {int(k): int(v) for k, v in seeds.get("di", {}).items()}
def read_bits(self, table: dict[int, int], addr: int, count: int) -> bytes:
"""Pack `count` bits LSB-first into the Modbus bit response body."""
bits = [table.get(addr + i, 0) & 1 for i in range(count)]
out = bytearray((count + 7) // 8)
for i, b in enumerate(bits):
if b:
out[i // 8] |= 1 << (i % 8)
return bytes(out)
def read_regs(self, table: dict[int, int], addr: int, count: int) -> bytes:
"""Pack `count` uint16 BE into the Modbus register response body."""
return b"".join(struct.pack(">H", table.get(addr + i, 0) & 0xFFFF) for i in range(count))
class Server:
EXC_ILLEGAL_FUNCTION = 0x01
EXC_ILLEGAL_DATA_ADDRESS = 0x02
EXC_ILLEGAL_DATA_VALUE = 0x03
def __init__(self, store: Store, rules: list[Rule]) -> None:
self._store = store
# Index rules by (fc, address) for O(1) lookup.
self._rules: dict[tuple[int, int], Rule] = {(r.fc, r.address): r for r in rules}
def lookup_rule(self, fc: int, address: int) -> Rule | None:
return self._rules.get((fc, address))
def exception_pdu(self, fc: int, code: int) -> bytes:
return bytes([fc | 0x80, code & 0xFF])
def handle_pdu(self, pdu: bytes) -> bytes:
if not pdu:
return self.exception_pdu(0, self.EXC_ILLEGAL_FUNCTION)
fc = pdu[0]
# Reads: FC 01/02/03/04 — [fc u8][addr u16][quantity u16]
if fc in (0x01, 0x02, 0x03, 0x04):
if len(pdu) != 5:
return self.exception_pdu(fc, self.EXC_ILLEGAL_DATA_VALUE)
addr, count = struct.unpack(">HH", pdu[1:5])
rule = self.lookup_rule(fc, addr)
if rule is not None:
log.info("inject fc=%d addr=%d -> exception 0x%02X (%s)",
fc, addr, rule.exception, rule.description)
return self.exception_pdu(fc, rule.exception)
# Spec caps — FC01/02 allow 1..2000 bits; FC03/04 allow 1..125 regs.
if fc in (0x01, 0x02):
if not 1 <= count <= 2000:
return self.exception_pdu(fc, self.EXC_ILLEGAL_DATA_VALUE)
body = self._store.read_bits(
self._store.co if fc == 0x01 else self._store.di, addr, count)
return bytes([fc, len(body)]) + body
if not 1 <= count <= 125:
return self.exception_pdu(fc, self.EXC_ILLEGAL_DATA_VALUE)
body = self._store.read_regs(
self._store.hr if fc == 0x03 else self._store.ir, addr, count)
return bytes([fc, len(body)]) + body
# FC05 — [fc u8][addr u16][value u16] where value is 0xFF00=ON or 0x0000=OFF.
if fc == 0x05:
if len(pdu) != 5:
return self.exception_pdu(fc, self.EXC_ILLEGAL_DATA_VALUE)
addr, value = struct.unpack(">HH", pdu[1:5])
rule = self.lookup_rule(fc, addr)
if rule is not None:
return self.exception_pdu(fc, rule.exception)
if value == 0xFF00:
self._store.co[addr] = 1
elif value == 0x0000:
self._store.co[addr] = 0
else:
return self.exception_pdu(fc, self.EXC_ILLEGAL_DATA_VALUE)
return pdu # FC05 echoes the request on success.
# FC06 — [fc u8][addr u16][value u16].
if fc == 0x06:
if len(pdu) != 5:
return self.exception_pdu(fc, self.EXC_ILLEGAL_DATA_VALUE)
addr, value = struct.unpack(">HH", pdu[1:5])
rule = self.lookup_rule(fc, addr)
if rule is not None:
return self.exception_pdu(fc, rule.exception)
self._store.hr[addr] = value
return pdu # FC06 echoes on success.
# FC15 — [fc u8][addr u16][count u16][byte_count u8][values...]
if fc == 0x0F:
if len(pdu) < 6:
return self.exception_pdu(fc, self.EXC_ILLEGAL_DATA_VALUE)
addr, count = struct.unpack(">HH", pdu[1:5])
rule = self.lookup_rule(fc, addr)
if rule is not None:
return self.exception_pdu(fc, rule.exception)
# Happy-path ignore-the-data, ack with standard response.
return struct.pack(">BHH", fc, addr, count)
# FC16 — [fc u8][addr u16][count u16][byte_count u8][u16 values...]
if fc == 0x10:
if len(pdu) < 6:
return self.exception_pdu(fc, self.EXC_ILLEGAL_DATA_VALUE)
addr, count = struct.unpack(">HH", pdu[1:5])
rule = self.lookup_rule(fc, addr)
if rule is not None:
return self.exception_pdu(fc, rule.exception)
byte_count = pdu[5]
data = pdu[6:6 + byte_count]
for i in range(count):
self._store.hr[addr + i] = struct.unpack(">H", data[i * 2:i * 2 + 2])[0]
return struct.pack(">BHH", fc, addr, count)
return self.exception_pdu(fc, self.EXC_ILLEGAL_FUNCTION)
async def handle_connection(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
peer = writer.get_extra_info("peername")
log.info("client connected from %s", peer)
try:
while True:
hdr = await reader.readexactly(7)
tx_id, proto, length, unit_id = struct.unpack(">HHHB", hdr)
if length < 1:
return
pdu = await reader.readexactly(length - 1)
resp = self.handle_pdu(pdu)
out = struct.pack(">HHHB", tx_id, proto, len(resp) + 1, unit_id) + resp
writer.write(out)
await writer.drain()
except asyncio.IncompleteReadError:
log.info("client %s disconnected", peer)
except Exception: # pylint: disable=broad-except
log.exception("unexpected error serving %s", peer)
finally:
try:
writer.close()
await writer.wait_closed()
except Exception: # pylint: disable=broad-except
pass
def load_config(path: str) -> tuple[Store, list[Rule], str, int]:
with open(path, "r", encoding="utf-8") as fh:
raw = json.load(fh)
listen = raw.get("listen", {})
host = listen.get("host", "0.0.0.0")
port = int(listen.get("port", 5020))
store = Store(raw.get("seeds", {}))
rules = [
Rule(
fc=int(r["fc"]),
address=int(r["address"]),
exception=int(r["exception"]),
description=str(r.get("description", "")),
)
for r in raw.get("rules", [])
]
return store, rules, host, port
async def main(argv: list[str]) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--config", required=True, help="Path to exception-injection JSON config.")
args = parser.parse_args(argv)
logging.basicConfig(level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s - %(message)s")
store, rules, host, port = load_config(args.config)
server = Server(store, rules)
listener = await asyncio.start_server(server.handle_connection, host, port)
log.info("exception-injector listening on %s:%d with %d rule(s)", host, port, len(rules))
for r in rules:
log.info(" rule: fc=%d addr=%d -> exception 0x%02X (%s)",
r.fc, r.address, r.exception, r.description)
async with listener:
await listener.serve_forever()
return 0
if __name__ == "__main__":
try:
sys.exit(asyncio.run(main(sys.argv[1:])))
except KeyboardInterrupt:
sys.exit(0)

View File

@@ -0,0 +1,111 @@
{
"_comment": "DL205.json — DirectLOGIC DL205/DL260 quirk simulator. Models docs/v2/dl205.md as concrete register values. NOTE: pymodbus rejects unknown keys at device-list / setup level; explanatory comments live at top-level _comment + in README + git. Inline _quirk keys WITHIN individual register entries are accepted by pymodbus 3.13.0 (it only validates addr / value / action / parameters per entry). Each quirky uint16 is a pre-computed raw 16-bit value; pymodbus serves it verbatim. shared blocks=true matches DL series memory model. write list mirrors each seeded block — pymodbus rejects sweeping write ranges that include undefined cells.",
"server_list": {
"srv": {
"comm": "tcp",
"host": "0.0.0.0",
"port": 5020,
"framer": "socket",
"device_id": 1
}
},
"device_list": {
"dev": {
"setup": {
"co size": 16384,
"di size": 8192,
"hr size": 16384,
"ir size": 1024,
"shared blocks": true,
"type exception": false,
"defaults": {
"value": {"bits": 0, "uint16": 0, "uint32": 0, "float32": 0.0, "string": " "},
"action": {"bits": null, "uint16": null, "uint32": null, "float32": null, "string": null}
}
},
"invalid": [],
"write": [
[0, 0],
[200, 209],
[1024, 1024],
[1040, 1042],
[1056, 1057],
[1072, 1072],
[1280, 1282],
[1343, 1343],
[1407, 1407],
[1, 1],
[128, 128],
[192, 192],
[250, 250],
[8448, 8448]
],
"uint16": [
{"_quirk": "V0 marker. HR[0]=0xCAFE proves register 0 is valid on DL205/DL260 (rejects-register-0 was a DL05/DL06 relative-mode artefact). 0xCAFE = 51966.",
"addr": 0, "value": 51966},
{"_quirk": "Scratch HR range 200..209 — mirrors the standard.json scratch range so the smoke test (DL205Profile.SmokeHoldingRegister=200) round-trips identically against either profile.",
"addr": 200, "value": 0},
{"addr": 201, "value": 0},
{"addr": 202, "value": 0},
{"addr": 203, "value": 0},
{"addr": 204, "value": 0},
{"addr": 205, "value": 0},
{"addr": 206, "value": 0},
{"addr": 207, "value": 0},
{"addr": 208, "value": 0},
{"addr": 209, "value": 0},
{"_quirk": "V2000 marker. V2000 octal = decimal 1024 = PDU 0x0400. Marker 0x2000 = 8192.",
"addr": 1024, "value": 8192},
{"_quirk": "V40400 marker. V40400 octal = decimal 8448 = PDU 0x2100 (NOT register 0). Marker 0x4040 = 16448.",
"addr": 8448, "value": 16448},
{"_quirk": "String 'Hello' first char in LOW byte. HR[0x410] = 'H'(0x48) lo + 'e'(0x65) hi = 0x6548 = 25928.",
"addr": 1040, "value": 25928},
{"_quirk": "String 'Hello' second char-pair: 'l'(0x6C) lo + 'l'(0x6C) hi = 0x6C6C = 27756.",
"addr": 1041, "value": 27756},
{"_quirk": "String 'Hello' third char-pair: 'o'(0x6F) lo + null(0x00) hi = 0x006F = 111.",
"addr": 1042, "value": 111},
{"_quirk": "Float32 1.5f in CDAB word order. IEEE 754 1.5 = 0x3FC00000. CDAB = low word first: HR[0x420]=0x0000, HR[0x421]=0x3FC0=16320.",
"addr": 1056, "value": 0},
{"_quirk": "Float32 1.5f CDAB high word.",
"addr": 1057, "value": 16320},
{"_quirk": "BCD register. Decimal 1234 stored as BCD nibbles 0x1234 = 4660. NOT binary 1234 (= 0x04D2).",
"addr": 1072, "value": 4660},
{"_quirk": "FC03 cap test marker — first cell of a 128-register span the FC03 cap test reads. Other cells in the span aren't seeded explicitly, so reads of HR[1283..1342] / 1344..1406 return the default 0; the seeded markers at 1280, 1281, 1282, 1343, 1407 prove the span boundaries.",
"addr": 1280, "value": 0},
{"addr": 1281, "value": 1},
{"addr": 1282, "value": 2},
{"addr": 1343, "value": 63},
{"addr": 1407, "value": 127}
],
"bits": [
{"_quirk": "X-input bank marker cell. X0 -> DI 0 conflicts with uint16 V0 at cell 0, so this marker covers X20 octal (= decimal 16 = DI 16 = cell 1 bit 0). X20=ON, X23 octal (DI 19 = cell 1 bit 3)=ON -> cell 1 value = 0b00001001 = 9.",
"addr": 1, "value": 9},
{"_quirk": "Y-output bank marker cell. pymodbus's simulator maps Modbus FC01/02/05 bit-addresses to cell index = bit_addr / 16; so Modbus coil 2048 lives at cell 128 bit 0. Y0=ON (bit 0), Y1=OFF (bit 1), Y2=ON (bit 2) -> value=0b00000101=5 proves DL260 mapping Y0 -> coil 2048.",
"addr": 128, "value": 5},
{"_quirk": "C-relay bank marker cell. Modbus coil 3072 -> cell 192 bit 0. C0=ON (bit 0), C1=OFF (bit 1), C2=ON (bit 2) -> value=5 proves DL260 mapping C0 -> coil 3072.",
"addr": 192, "value": 5},
{"_quirk": "Scratch cell for coil 4000..4015 write round-trip tests. Cell 250 holds Modbus coils 4000-4015; all bits start at 0 and tests set specific bits via FC05.",
"addr": 250, "value": 0}
],
"uint32": [],
"float32": [],
"string": [],
"repeat": []
}
}
}

View File

@@ -0,0 +1,34 @@
{
"_comment": "Modbus exception-injection profile — feeds exception_injector.py (not pymodbus). Rules match by (fc, address). HR[0-31] are address-as-value for the happy-path reads; HR[1000..1010] + coils[2000..2010] carry per-exception-code rules. Every code in the driver's MapModbusExceptionToStatus table that pymodbus can't naturally emit has a dedicated slot. See Docker/README.md §exception injection.",
"listen": { "host": "0.0.0.0", "port": 5020 },
"seeds": {
"hr": {
"0": 0, "1": 1, "2": 2, "3": 3,
"4": 4, "5": 5, "6": 6, "7": 7,
"8": 8, "9": 9, "10": 10, "11": 11,
"12": 12, "13": 13, "14": 14, "15": 15,
"16": 16, "17": 17, "18": 18, "19": 19,
"20": 20, "21": 21, "22": 22, "23": 23,
"24": 24, "25": 25, "26": 26, "27": 27,
"28": 28, "29": 29, "30": 30, "31": 31
}
},
"rules": [
{ "fc": 3, "address": 1000, "exception": 1, "description": "FC03 @1000 -> Illegal Function (0x01)" },
{ "fc": 3, "address": 1001, "exception": 2, "description": "FC03 @1001 -> Illegal Data Address (0x02)" },
{ "fc": 3, "address": 1002, "exception": 3, "description": "FC03 @1002 -> Illegal Data Value (0x03)" },
{ "fc": 3, "address": 1003, "exception": 4, "description": "FC03 @1003 -> Server Failure (0x04)" },
{ "fc": 3, "address": 1004, "exception": 5, "description": "FC03 @1004 -> Acknowledge (0x05)" },
{ "fc": 3, "address": 1005, "exception": 6, "description": "FC03 @1005 -> Server Busy (0x06)" },
{ "fc": 3, "address": 1006, "exception": 10, "description": "FC03 @1006 -> Gateway Path Unavailable (0x0A)" },
{ "fc": 3, "address": 1007, "exception": 11, "description": "FC03 @1007 -> Gateway Target No Response (0x0B)" },
{ "fc": 6, "address": 2000, "exception": 4, "description": "FC06 @2000 -> Server Failure (0x04, e.g. CPU in PROGRAM mode)" },
{ "fc": 6, "address": 2001, "exception": 6, "description": "FC06 @2001 -> Server Busy (0x06)" },
{ "fc": 16, "address": 3000, "exception": 4, "description": "FC16 @3000 -> Server Failure (0x04)" }
]
}

View File

@@ -0,0 +1,83 @@
{
"_comment": "mitsubishi.json -- Mitsubishi MELSEC Modbus TCP quirk simulator covering QJ71MT91, iQ-R, iQ-F/FX5U, and FX3U-ENET-P502 behaviors documented in docs/v2/mitsubishi.md. MELSEC CPUs store multi-word values in CDAB order (opposite of S7 ABCD, same family as DL260). The Modbus-module 'Modbus Device Assignment Parameter' block is per-site, so this profile models one *representative* assignment mapping D-register D0..D1023 -> HR 0..1023, M-relay M0..M511 -> coil 0..511, X-input X0..X15 -> DI 0..15 (X-addresses are HEX on Q/L/iQ-R, so X10 = decimal 16; on FX/iQ-F they're OCTAL like DL260). pymodbus bit-address semantics are the same as dl205.json and s7_1500.json (FC01/02/05/15 address N maps to cell index N/16).",
"server_list": {
"srv": {
"comm": "tcp",
"host": "0.0.0.0",
"port": 5020,
"framer": "socket",
"device_id": 1
}
},
"device_list": {
"dev": {
"setup": {
"co size": 4096,
"di size": 4096,
"hr size": 4096,
"ir size": 1024,
"shared blocks": true,
"type exception": false,
"defaults": {
"value": {"bits": 0, "uint16": 0, "uint32": 0, "float32": 0.0, "string": " "},
"action": {"bits": null, "uint16": null, "uint32": null, "float32": null, "string": null}
}
},
"invalid": [],
"write": [
[0, 0],
[10, 10],
[100, 101],
[200, 209],
[300, 301],
[500, 500]
],
"uint16": [
{"_quirk": "D0 fingerprint marker. MELSEC D0 is the first data register; Modbus Device Assignment typically maps D0..D1023 -> HR 0..1023. 0x1234 is the fingerprint operators set in GX Works to prove the mapping parameter block is in effect.",
"addr": 0, "value": 4660},
{"_quirk": "Scratch HR range 200..209 -- mirrors the dl205/s7_1500/standard scratch range so smoke tests (MitsubishiProfile.SmokeHoldingRegister=200) round-trip identically against any profile.",
"addr": 200, "value": 0},
{"addr": 201, "value": 0},
{"addr": 202, "value": 0},
{"addr": 203, "value": 0},
{"addr": 204, "value": 0},
{"addr": 205, "value": 0},
{"addr": 206, "value": 0},
{"addr": 207, "value": 0},
{"addr": 208, "value": 0},
{"addr": 209, "value": 0},
{"_quirk": "Float32 1.5f in CDAB word order (MELSEC Q/L/iQ-R/iQ-F default, same as DL260). HR[100]=0x0000=0 low word, HR[101]=0x3FC0=16320 high word. Decode with ByteOrder.WordSwap returns 1.5f; BigEndian decode returns a denormal.",
"addr": 100, "value": 0},
{"addr": 101, "value": 16320},
{"_quirk": "Int32 0x12345678 in CDAB word order. HR[300]=0x5678=22136 low word, HR[301]=0x1234=4660 high word. Contrasts with the S7 profile's ABCD encoding at the same address.",
"addr": 300, "value": 22136},
{"addr": 301, "value": 4660},
{"_quirk": "D10 = decimal 1234 stored as BINARY (NOT BCD like DL205). 0x04D2 = 1234 decimal. Caller reading with Bcd16 data type would decode this as binary 1234's BCD nibbles which are non-BCD and throw InvalidDataException -- proves MELSEC is binary-by-default, opposite of DL205's BCD-by-default quirk.",
"addr": 10, "value": 1234},
{"_quirk": "Modbus Device Assignment boundary marker. HR[500] represents the last register in an assigned D-range D500. Beyond this (HR[501..4095]) would be Illegal Data Address on a real QJ71MT91 with this specific parameter block; pymodbus returns default 0 because its shared cell array has space -- real-PLC parity is documented in docs/v2/mitsubishi.md §device-assignment, not enforced here.",
"addr": 500, "value": 500}
],
"bits": [
{"_quirk": "M-relay marker cell at cell 32 = Modbus coil 512 = MELSEC M512 (coils 0..15 collide with the D0 uint16 marker cell, so we place the M marker above that). Cell 32 bit 0 = 1 and bit 2 = 1 (value = 0b101 = 5) = M512=ON, M513=OFF, M514=ON. Matches the Y0/Y2 marker pattern in dl205 and s7_1500 profiles.",
"addr": 32, "value": 5},
{"_quirk": "X-input marker cell at cell 33 = Modbus DI 528 (= MELSEC X210 hex on Q/L/iQ-R). Cell 33 bit 0 = 1 and bit 3 = 1 (value = 0x9 = 9). Chosen above cell 1 so it doesn't collide with any uint16 D-register. Proves the hex-parsing X-input helper on Q/L/iQ-R family; FX/iQ-F families use octal X-addresses tested separately.",
"addr": 33, "value": 9}
],
"uint32": [],
"float32": [],
"string": [],
"repeat": []
}
}
}

View File

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

View File

@@ -0,0 +1,97 @@
{
"_comment": "Standard.json — generic Modbus TCP server for the integration suite. See ../README.md. NOTE: pymodbus rejects unknown keys at device-list / setup level; explanatory comments live in the README + git history. Layout: HR[0..31]=address-as-value, HR[100]=auto-increment, HR[200..209]=scratch, coils 1024..1055=alternating, coils 1100..1109=scratch. Coils live at 1024+ because pymodbus stores all 4 standard tables in ONE underlying cell array — bits and uint16 at the same address conflict (each cell can only be typed once).",
"server_list": {
"srv": {
"comm": "tcp",
"host": "0.0.0.0",
"port": 5020,
"framer": "socket",
"device_id": 1
}
},
"device_list": {
"dev": {
"setup": {
"co size": 2048,
"di size": 2048,
"hr size": 2048,
"ir size": 2048,
"shared blocks": true,
"type exception": false,
"defaults": {
"value": {"bits": 0, "uint16": 0, "uint32": 0, "float32": 0.0, "string": " "},
"action": {"bits": null, "uint16": null, "uint32": null, "float32": null, "string": null}
}
},
"invalid": [],
"write": [
[0, 31],
[100, 100],
[200, 209],
[1024, 1055],
[1100, 1109]
],
"uint16": [
{"addr": 0, "value": 0}, {"addr": 1, "value": 1},
{"addr": 2, "value": 2}, {"addr": 3, "value": 3},
{"addr": 4, "value": 4}, {"addr": 5, "value": 5},
{"addr": 6, "value": 6}, {"addr": 7, "value": 7},
{"addr": 8, "value": 8}, {"addr": 9, "value": 9},
{"addr": 10, "value": 10}, {"addr": 11, "value": 11},
{"addr": 12, "value": 12}, {"addr": 13, "value": 13},
{"addr": 14, "value": 14}, {"addr": 15, "value": 15},
{"addr": 16, "value": 16}, {"addr": 17, "value": 17},
{"addr": 18, "value": 18}, {"addr": 19, "value": 19},
{"addr": 20, "value": 20}, {"addr": 21, "value": 21},
{"addr": 22, "value": 22}, {"addr": 23, "value": 23},
{"addr": 24, "value": 24}, {"addr": 25, "value": 25},
{"addr": 26, "value": 26}, {"addr": 27, "value": 27},
{"addr": 28, "value": 28}, {"addr": 29, "value": 29},
{"addr": 30, "value": 30}, {"addr": 31, "value": 31},
{"addr": 100, "value": 0,
"action": "increment",
"parameters": {"minval": 0, "maxval": 65535}},
{"addr": 200, "value": 0}, {"addr": 201, "value": 0},
{"addr": 202, "value": 0}, {"addr": 203, "value": 0},
{"addr": 204, "value": 0}, {"addr": 205, "value": 0},
{"addr": 206, "value": 0}, {"addr": 207, "value": 0},
{"addr": 208, "value": 0}, {"addr": 209, "value": 0}
],
"bits": [
{"addr": 1024, "value": 1}, {"addr": 1025, "value": 0},
{"addr": 1026, "value": 1}, {"addr": 1027, "value": 0},
{"addr": 1028, "value": 1}, {"addr": 1029, "value": 0},
{"addr": 1030, "value": 1}, {"addr": 1031, "value": 0},
{"addr": 1032, "value": 1}, {"addr": 1033, "value": 0},
{"addr": 1034, "value": 1}, {"addr": 1035, "value": 0},
{"addr": 1036, "value": 1}, {"addr": 1037, "value": 0},
{"addr": 1038, "value": 1}, {"addr": 1039, "value": 0},
{"addr": 1040, "value": 1}, {"addr": 1041, "value": 0},
{"addr": 1042, "value": 1}, {"addr": 1043, "value": 0},
{"addr": 1044, "value": 1}, {"addr": 1045, "value": 0},
{"addr": 1046, "value": 1}, {"addr": 1047, "value": 0},
{"addr": 1048, "value": 1}, {"addr": 1049, "value": 0},
{"addr": 1050, "value": 1}, {"addr": 1051, "value": 0},
{"addr": 1052, "value": 1}, {"addr": 1053, "value": 0},
{"addr": 1054, "value": 1}, {"addr": 1055, "value": 0},
{"addr": 1100, "value": 0}, {"addr": 1101, "value": 0},
{"addr": 1102, "value": 0}, {"addr": 1103, "value": 0},
{"addr": 1104, "value": 0}, {"addr": 1105, "value": 0},
{"addr": 1106, "value": 0}, {"addr": 1107, "value": 0},
{"addr": 1108, "value": 0}, {"addr": 1109, "value": 0}
],
"uint32": [],
"float32": [],
"string": [],
"repeat": []
}
}
}

View File

@@ -0,0 +1,122 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests;
/// <summary>
/// End-to-end verification that the driver's <c>MapModbusExceptionToStatus</c>
/// translation is wire-correct for every exception code in the mapping table —
/// not just 0x02, which is the only code the pymodbus simulator naturally emits.
/// Drives the standalone <c>exception_injector.py</c> server (<c>exception_injection</c>
/// compose profile) at each of the rule addresses in
/// <c>Docker/profiles/exception_injection.json</c> and asserts the driver surfaces
/// the expected OPC UA StatusCode.
/// </summary>
/// <remarks>
/// Why integration coverage on top of the unit tests: the unit tests prove the
/// translation function is correct; these prove the driver wires it through on
/// the read + write paths unchanged, after the MBAP header + PDU round-trip
/// (where a subtle framing bug could swallow or misclassify the exception).
/// </remarks>
[Collection(ModbusSimulatorCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Device", "ExceptionInjection")]
public sealed class ExceptionInjectionTests(ModbusSimulatorFixture sim)
{
private const uint StatusGood = 0u;
private const uint StatusBadOutOfRange = 0x803C0000u;
private const uint StatusBadNotSupported = 0x803D0000u;
private const uint StatusBadDeviceFailure = 0x80550000u;
private const uint StatusBadCommunicationError = 0x80050000u;
private void SkipUnlessInjectorLive()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
var profile = Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE");
if (!string.Equals(profile, "exception_injection", StringComparison.OrdinalIgnoreCase))
Assert.Skip("MODBUS_SIM_PROFILE != exception_injection — skipping. " +
"Start the fixture with --profile exception_injection.");
}
private async Task<IReadOnlyList<DataValueSnapshot>> ReadSingleAsync(int address, string tagName)
{
var opts = new ModbusDriverOptions
{
Host = sim.Host,
Port = sim.Port,
UnitId = 1,
Timeout = TimeSpan.FromSeconds(2),
Tags =
[
new ModbusTagDefinition(tagName,
ModbusRegion.HoldingRegisters, Address: (ushort)address,
DataType: ModbusDataType.UInt16, Writable: false),
],
Probe = new ModbusProbeOptions { Enabled = false },
};
await using var driver = new ModbusDriver(opts, driverInstanceId: "modbus-exc");
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
return await driver.ReadAsync([tagName], TestContext.Current.CancellationToken);
}
[Theory]
[InlineData(1000, StatusBadNotSupported, "exc 0x01 (Illegal Function) -> BadNotSupported")]
[InlineData(1001, StatusBadOutOfRange, "exc 0x02 (Illegal Data Address) -> BadOutOfRange")]
[InlineData(1002, StatusBadOutOfRange, "exc 0x03 (Illegal Data Value) -> BadOutOfRange")]
[InlineData(1003, StatusBadDeviceFailure, "exc 0x04 (Server Failure) -> BadDeviceFailure")]
[InlineData(1004, StatusBadDeviceFailure, "exc 0x05 (Acknowledge / long op) -> BadDeviceFailure")]
[InlineData(1005, StatusBadDeviceFailure, "exc 0x06 (Server Busy) -> BadDeviceFailure")]
[InlineData(1006, StatusBadCommunicationError, "exc 0x0A (Gateway Path Unavailable) -> BadCommunicationError")]
[InlineData(1007, StatusBadCommunicationError, "exc 0x0B (Gateway Target No Response) -> BadCommunicationError")]
public async Task FC03_read_at_injection_address_surfaces_expected_status(
int address, uint expectedStatus, string scenario)
{
SkipUnlessInjectorLive();
var results = await ReadSingleAsync(address, $"Injected_{address}");
results[0].StatusCode.ShouldBe(expectedStatus, scenario);
}
[Fact]
public async Task FC03_read_at_non_injected_address_returns_Good()
{
// Sanity: HR[0..31] are seeded with address-as-value in the profile. A read at
// one of those addresses must come back Good (0) — otherwise the injector is
// misbehaving and every other assertion in this class is uninformative.
SkipUnlessInjectorLive();
var results = await ReadSingleAsync(address: 5, tagName: "Healthy_5");
results[0].StatusCode.ShouldBe(StatusGood);
results[0].Value.ShouldBe((ushort)5);
}
[Theory]
[InlineData(2000, StatusBadDeviceFailure, "exc 0x04 on FC06 -> BadDeviceFailure (CPU in PROGRAM mode)")]
[InlineData(2001, StatusBadDeviceFailure, "exc 0x06 on FC06 -> BadDeviceFailure (Server Busy)")]
public async Task FC06_write_at_injection_address_surfaces_expected_status(
int address, uint expectedStatus, string scenario)
{
SkipUnlessInjectorLive();
var tag = $"InjectedWrite_{address}";
var opts = new ModbusDriverOptions
{
Host = sim.Host,
Port = sim.Port,
UnitId = 1,
Timeout = TimeSpan.FromSeconds(2),
Tags =
[
new ModbusTagDefinition(tag,
ModbusRegion.HoldingRegisters, Address: (ushort)address,
DataType: ModbusDataType.UInt16, Writable: true),
],
Probe = new ModbusProbeOptions { Enabled = false },
};
await using var driver = new ModbusDriver(opts, driverInstanceId: "modbus-exc-write");
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
var writes = await driver.WriteAsync(
[new WriteRequest(tag, (ushort)42)],
TestContext.Current.CancellationToken);
writes[0].StatusCode.ShouldBe(expectedStatus, scenario);
}
}

View File

@@ -0,0 +1,43 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.Mitsubishi;
/// <summary>
/// Tag map for the Mitsubishi MELSEC device class with a representative Modbus Device
/// Assignment block mapping D0..D1023 → HR[0..1023]. Mirrors the behaviors in
/// <c>mitsubishi.json</c> pymodbus profile and <c>docs/v2/mitsubishi.md</c>.
/// </summary>
/// <remarks>
/// MELSEC Modbus sites all have *different* device-assignment parameter blocks; this profile
/// models the conventional default. Per-model differences (FX5U needs firmware ≥ 1.060 for
/// Modbus server; QJ71MT91 lacks FC22/FC23; FX/iQ-F use octal X/Y while Q/L/iQ-R use hex)
/// are handled in <see cref="MelsecAddress"/> (PR 59) and the per-model test files.
/// </remarks>
public static class MitsubishiProfile
{
/// <summary>
/// Scratch HR the smoke test writes + reads. Address 200 mirrors the
/// dl205/s7_1500/standard scratch range so one smoke test pattern works across every
/// device profile the simulator supports.
/// </summary>
public const ushort SmokeHoldingRegister = 200;
/// <summary>Value the smoke test writes then reads back.</summary>
public const short SmokeHoldingValue = 7890;
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),
],
Probe = new ModbusProbeOptions { Enabled = false },
};
}

View File

@@ -0,0 +1,179 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.Mitsubishi;
/// <summary>
/// Verifies the MELSEC-family Modbus quirks against the <c>mitsubishi.json</c> pymodbus
/// profile: CDAB word order default, binary-not-BCD D-register encoding, hex X-input
/// parsing (Q/L/iQ-R), D0 fingerprint, M-relay coil mapping with bank base.
/// </summary>
/// <remarks>
/// Groups all quirks in one test class instead of per-behavior classes (unlike the DL205
/// set) because MELSEC's per-model differentiation is handled by the
/// <see cref="MelsecFamily"/> enum on the helper + <c>MODBUS_SIM_PROFILE</c> env var on
/// the fixture, rather than per-PR test classes.
/// </remarks>
[Collection(ModbusSimulatorCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Device", "Mitsubishi")]
public sealed class MitsubishiQuirkTests(ModbusSimulatorFixture sim)
{
[Fact]
public async Task Mitsubishi_D0_fingerprint_reads_0x1234()
{
if (!ShouldRun()) return;
await using var driver = await NewDriverAsync(
new ModbusTagDefinition("D0_Fingerprint",
ModbusRegion.HoldingRegisters,
Address: MelsecAddress.DRegisterToHolding("D0"),
DataType: ModbusDataType.UInt16, Writable: false));
var r = await driver.ReadAsync(["D0_Fingerprint"], TestContext.Current.CancellationToken);
r[0].StatusCode.ShouldBe(0u);
r[0].Value.ShouldBe((ushort)0x1234);
}
[Fact]
public async Task Mitsubishi_Float32_CDAB_decodes_1_5f_from_D100()
{
if (!ShouldRun()) return;
// MELSEC Q/L/iQ-R/iQ-F all store 32-bit values with CDAB word order (low word at
// lower D-register address). HR[100..101] = [0, 0x3FC0] decodes as 1.5f under
// WordSwap but as a denormal under BigEndian.
var addr = MelsecAddress.DRegisterToHolding("D100");
await using var driver = await NewDriverAsync(
new ModbusTagDefinition("D100_Float_CDAB",
ModbusRegion.HoldingRegisters, Address: addr,
DataType: ModbusDataType.Float32, Writable: false,
ByteOrder: ModbusByteOrder.WordSwap),
new ModbusTagDefinition("D100_Float_ABCD_control",
ModbusRegion.HoldingRegisters, Address: addr,
DataType: ModbusDataType.Float32, Writable: false,
ByteOrder: ModbusByteOrder.BigEndian));
var r = await driver.ReadAsync(
["D100_Float_CDAB", "D100_Float_ABCD_control"],
TestContext.Current.CancellationToken);
r[0].Value.ShouldBe(1.5f, "MELSEC stores Float32 CDAB; WordSwap decode returns 1.5f");
r[1].Value.ShouldNotBe(1.5f, "same wire with BigEndian must decode to a different value");
}
[Fact]
public async Task Mitsubishi_D10_is_binary_not_BCD()
{
if (!ShouldRun()) return;
// Counter-to-DL205: MELSEC D-registers are binary by default. D10 = 1234 decimal =
// 0x04D2. Reading as Int16 returns 1234; reading as Bcd16 would throw (nibble 0xD is
// non-BCD) — the integration test proves the Int16 decode wins.
await using var driver = await NewDriverAsync(
new ModbusTagDefinition("D10_Binary",
ModbusRegion.HoldingRegisters,
Address: MelsecAddress.DRegisterToHolding("D10"),
DataType: ModbusDataType.Int16, Writable: false));
var r = await driver.ReadAsync(["D10_Binary"], TestContext.Current.CancellationToken);
r[0].StatusCode.ShouldBe(0u);
r[0].Value.ShouldBe((short)1234, "MELSEC stores numeric D-register values in binary; 0x04D2 = 1234");
}
[Fact]
public async Task Mitsubishi_D10_as_BCD_throws_because_nibble_is_non_decimal()
{
if (!ShouldRun()) return;
// If a site configured D10 with Bcd16 data type but the ladder writes binary, the
// BCD decoder MUST reject the garbage rather than silently returning wrong decimal.
// 0x04D2 contains nibble 0xD which fails BCD validation.
await using var driver = await NewDriverAsync(
new ModbusTagDefinition("D10_WrongBcd",
ModbusRegion.HoldingRegisters,
Address: MelsecAddress.DRegisterToHolding("D10"),
DataType: ModbusDataType.Bcd16, Writable: false));
var r = await driver.ReadAsync(["D10_WrongBcd"], TestContext.Current.CancellationToken);
// ReadAsync catches the InvalidDataException from DecodeBcd and surfaces it as
// BadCommunicationError (PR 52 mapping). Non-zero status = caller sees a real
// problem and can check their tag config instead of getting silently-wrong numbers.
r[0].StatusCode.ShouldNotBe(0u, "BCD decode of binary 0x04D2 must fail loudly because nibble D is non-BCD");
}
[Fact]
public async Task Mitsubishi_QLiQR_X210_hex_maps_to_DI_528_reads_ON()
{
if (!ShouldRun()) return;
// MELSEC-Q / L / iQ-R: X addresses are hex. X210 = 0x210 = 528 decimal.
// mitsubishi.json seeds cell 33 (DI 528..543) with value 9 = bit 0 + bit 3 set.
// X210 → DI 528 → cell 33 bit 0 = 1 (ON).
var addr = MelsecAddress.XInputToDiscrete("X210", MelsecFamily.Q_L_iQR);
addr.ShouldBe((ushort)528);
await using var driver = await NewDriverAsync(
new ModbusTagDefinition("X210_hex",
ModbusRegion.DiscreteInputs, Address: addr,
DataType: ModbusDataType.Bool, Writable: false));
var r = await driver.ReadAsync(["X210_hex"], TestContext.Current.CancellationToken);
r[0].StatusCode.ShouldBe(0u);
r[0].Value.ShouldBe(true);
}
[Fact]
public void Mitsubishi_family_trap_X20_differs_on_Q_vs_FX()
{
// Not a live-sim test — a unit-level proof that the MELSEC family selector gates the
// address correctly. Included in the integration suite so anyone running the MELSEC
// tests sees the trap called out explicitly.
MelsecAddress.XInputToDiscrete("X20", MelsecFamily.Q_L_iQR).ShouldBe((ushort)32);
MelsecAddress.XInputToDiscrete("X20", MelsecFamily.F_iQF).ShouldBe((ushort)16);
}
[Fact]
public async Task Mitsubishi_M512_maps_to_coil_512_reads_ON()
{
if (!ShouldRun()) return;
// mitsubishi.json seeds cell 32 (coil 512..527) with value 5 = bit 0 + bit 2 set.
// M512 → coil 512 → cell 32 bit 0 = 1 (ON).
var addr = MelsecAddress.MRelayToCoil("M512");
addr.ShouldBe((ushort)512);
await using var driver = await NewDriverAsync(
new ModbusTagDefinition("M512",
ModbusRegion.Coils, Address: addr,
DataType: ModbusDataType.Bool, Writable: false));
var r = await driver.ReadAsync(["M512"], TestContext.Current.CancellationToken);
r[0].StatusCode.ShouldBe(0u);
r[0].Value.ShouldBe(true);
}
// --- helpers ---
private bool ShouldRun()
{
if (sim.SkipReason is not null) { Assert.Skip(sim.SkipReason); return false; }
if (!string.Equals(Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE"), "mitsubishi",
StringComparison.OrdinalIgnoreCase))
{
Assert.Skip("MODBUS_SIM_PROFILE != mitsubishi — skipping.");
return false;
}
return true;
}
private async Task<ModbusDriver> NewDriverAsync(params ModbusTagDefinition[] tags)
{
var drv = new ModbusDriver(
new ModbusDriverOptions
{
Host = sim.Host,
Port = sim.Port,
UnitId = 1,
Timeout = TimeSpan.FromSeconds(2),
Tags = tags,
Probe = new ModbusProbeOptions { Enabled = false },
},
driverInstanceId: "melsec-quirk");
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
return drv;
}
}

View File

@@ -0,0 +1,45 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.Mitsubishi;
/// <summary>
/// End-to-end smoke against the MELSEC <c>mitsubishi.json</c> pymodbus profile (or a real
/// MELSEC QJ71MT91 / iQ-R / FX5U when <c>MODBUS_SIM_ENDPOINT</c> points at one). Drives
/// the full <see cref="ModbusDriver"/> + real <see cref="ModbusTcpTransport"/> stack.
/// Success proves the driver initializes against the MELSEC sim, writes a known value,
/// and reads it back — the baseline every Mitsubishi-specific test (PR 59+) builds on.
/// </summary>
[Collection(ModbusSimulatorCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Device", "Mitsubishi")]
public sealed class MitsubishiSmokeTests(ModbusSimulatorFixture sim)
{
[Fact]
public async Task Mitsubishi_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"), "mitsubishi",
StringComparison.OrdinalIgnoreCase))
{
Assert.Skip("MODBUS_SIM_PROFILE != mitsubishi — skipping.");
}
var options = MitsubishiProfile.BuildOptions(sim.Host, sim.Port);
await using var driver = new ModbusDriver(options, driverInstanceId: "melsec-smoke");
await driver.InitializeAsync(driverConfigJson: "{}", TestContext.Current.CancellationToken);
var writeResults = await driver.WriteAsync(
[new(FullReference: "Smoke_HReg200", Value: (short)MitsubishiProfile.SmokeHoldingValue)],
TestContext.Current.CancellationToken);
writeResults.Count.ShouldBe(1);
writeResults[0].StatusCode.ShouldBe(0u, "write must succeed against the MELSEC pymodbus 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)MitsubishiProfile.SmokeHoldingValue);
}
}

View File

@@ -0,0 +1,83 @@
using System.Net.Sockets;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests;
/// <summary>
/// Reachability probe for a Modbus TCP simulator (pymodbus in Docker, see
/// <c>Docker/docker-compose.yml</c>) or a real PLC. Parses
/// <c>MODBUS_SIM_ENDPOINT</c> (default <c>localhost:5020</c> per PR 43) and TCP-connects once at
/// fixture construction. Each test checks <see cref="SkipReason"/> and calls
/// <c>Assert.Skip</c> 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
/// <c>GalaxyRepositoryLiveSmokeTests</c>.
/// </summary>
/// <remarks>
/// <para>
/// 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
/// <see cref="ModbusTcpTransport"/>) against the same endpoint. Sharing a socket
/// across tests would serialize them on a single TCP stream.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
public sealed class ModbusSimulatorFixture : IAsyncDisposable
{
// PR 43: default port is 5020 (pymodbus convention) instead of 502 (Modbus standard).
// Picking 5020 sidesteps the privileged-port admin requirement on Windows + matches the
// port baked into the pymodbus simulator JSON profiles in Docker/profiles/. Override with
// MODBUS_SIM_ENDPOINT to point at a real PLC on its native port 502.
private const string DefaultEndpoint = "localhost:5020";
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
{
// Force IPv4 family on the probe — pymodbus's TCP server binds 0.0.0.0 (IPv4 only)
// while .NET's TcpClient default-resolves "localhost" → IPv6 ::1 first, fails to
// connect, and only then tries IPv4. Under .NET 10 the IPv6 fail surfaces as a
// 2s timeout (no graceful fallback by default), so the C# probe times out even
// though a PowerShell probe of the same endpoint succeeds. Resolving + dialing
// explicit IPv4 sidesteps the dual-stack ordering.
using var client = new TcpClient(System.Net.Sockets.AddressFamily.InterNetwork);
var task = client.ConnectAsync(
System.Net.Dns.GetHostAddresses(Host)
.FirstOrDefault(a => a.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
?? System.Net.IPAddress.Loopback,
Port);
if (!task.Wait(TimeSpan.FromSeconds(2)) || !client.Connected)
{
SkipReason = $"Modbus simulator at {Host}:{Port} did not accept a TCP connection within 2s. " +
$"Start the pymodbus Docker container (docker compose -f Docker/docker-compose.yml --profile standard up -d) " +
$"or override {EndpointEnvVar}, then re-run.";
}
}
catch (Exception ex)
{
SkipReason = $"Modbus simulator at {Host}:{Port} unreachable: {ex.GetType().Name}: {ex.Message}. " +
$"Start the pymodbus Docker container (docker compose -f Docker/docker-compose.yml --profile standard up -d) " +
$"or override {EndpointEnvVar}, then re-run.";
}
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
[Xunit.CollectionDefinition(Name)]
public sealed class ModbusSimulatorCollection : Xunit.ICollectionFixture<ModbusSimulatorFixture>
{
public const string Name = "ModbusSimulator";
}

View File

@@ -0,0 +1,44 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.S7;
/// <summary>
/// Tag map for the Siemens SIMATIC S7-1500 device class with the <c>MB_SERVER</c> library
/// block mapping HR[0..] to DB1.DBW0+. Mirrors <c>s7_1500.json</c> in <c>Docker/profiles/</c>.
/// </summary>
/// <remarks>
/// 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
/// <c>docs/v2/s7.md</c> §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.
/// </remarks>
public static class S7_1500Profile
{
/// <summary>
/// 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.
/// </summary>
public const ushort SmokeHoldingRegister = 200;
/// <summary>Value the smoke test writes then reads back.</summary>
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 },
};
}

View File

@@ -0,0 +1,54 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.S7;
/// <summary>
/// End-to-end smoke against the S7-1500 <c>MB_SERVER</c> pymodbus profile (or a real
/// S7-1500 + MB_SERVER deployment when <c>MODBUS_SIM_ENDPOINT</c> points at one). Drives
/// the full <see cref="ModbusDriver"/> + real <see cref="ModbusTcpTransport"/> 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.
/// </summary>
/// <remarks>
/// 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
/// (<c>--filter DisplayName~S7</c>) should surface the quirk-specific failure mode when
/// something goes wrong, not a blanket smoke failure that could mean anything.
/// </remarks>
[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);
}
}

View File

@@ -0,0 +1,132 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.S7;
/// <summary>
/// Verifies the Siemens S7 big-endian (<c>ABCD</c>) word-order default for Float32 and
/// Int32 against the <c>s7_1500.json</c> pymodbus profile. S7's native CPU types are
/// big-endian end-to-end, so <c>MB_SERVER</c> places the high word at the lower register
/// address — <b>opposite</b> of DL260's CDAB. The driver's S7-family tag config must
/// therefore default to <see cref="ModbusByteOrder.BigEndian"/>; selecting
/// <see cref="ModbusByteOrder.WordSwap"/> against an S7 would decode garbage.
/// </summary>
[Collection(ModbusSimulatorCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Device", "S7")]
public sealed class S7_ByteOrderTests(ModbusSimulatorFixture sim)
{
[Fact]
public async Task S7_Float32_ABCD_decodes_1_5f_from_HR100()
{
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 (s7_1500 profile is the only one seeding HR[100..101] ABCD).");
}
var options = new ModbusDriverOptions
{
Host = sim.Host,
Port = sim.Port,
UnitId = 1,
Timeout = TimeSpan.FromSeconds(2),
Tags =
[
new ModbusTagDefinition("S7_Float_ABCD",
ModbusRegion.HoldingRegisters, Address: 100,
DataType: ModbusDataType.Float32, Writable: false,
ByteOrder: ModbusByteOrder.BigEndian),
// Control: same address with WordSwap should decode garbage — proves the
// two code paths diverge on S7 wire bytes.
new ModbusTagDefinition("S7_Float_CDAB_control",
ModbusRegion.HoldingRegisters, Address: 100,
DataType: ModbusDataType.Float32, Writable: false,
ByteOrder: ModbusByteOrder.WordSwap),
],
Probe = new ModbusProbeOptions { Enabled = false },
};
await using var driver = new ModbusDriver(options, driverInstanceId: "s7-float-abcd");
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
var results = await driver.ReadAsync(
["S7_Float_ABCD", "S7_Float_CDAB_control"],
TestContext.Current.CancellationToken);
results[0].StatusCode.ShouldBe(0u);
results[0].Value.ShouldBe(1.5f, "S7 MB_SERVER stores Float32 in ABCD word order; BigEndian decode returns 1.5f");
results[1].StatusCode.ShouldBe(0u);
results[1].Value.ShouldNotBe(1.5f, "applying CDAB swap to S7 ABCD bytes must produce a different value — confirms the flag is not a no-op and S7 profile default must be BigEndian");
}
[Fact]
public async Task S7_Int32_ABCD_decodes_0x12345678_from_HR300()
{
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.");
}
var options = new ModbusDriverOptions
{
Host = sim.Host,
Port = sim.Port,
UnitId = 1,
Timeout = TimeSpan.FromSeconds(2),
Tags =
[
new ModbusTagDefinition("S7_Int32_ABCD",
ModbusRegion.HoldingRegisters, Address: 300,
DataType: ModbusDataType.Int32, Writable: false,
ByteOrder: ModbusByteOrder.BigEndian),
],
Probe = new ModbusProbeOptions { Enabled = false },
};
await using var driver = new ModbusDriver(options, driverInstanceId: "s7-int-abcd");
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
var results = await driver.ReadAsync(["S7_Int32_ABCD"], TestContext.Current.CancellationToken);
results[0].StatusCode.ShouldBe(0u);
results[0].Value.ShouldBe(0x12345678,
"S7 Int32 stored as HR[300]=0x1234, HR[301]=0x5678 with ABCD order decodes to 0x12345678 — DL260 would store the reverse order");
}
[Fact]
public async Task S7_DB1_fingerprint_marker_at_HR0_reads_0xABCD()
{
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.");
}
// Real-world MB_SERVER deployments typically reserve DB1.DBW0 as a fingerprint so
// clients can verify they're pointing at the right DB (protects against typos in
// the MB_SERVER.MB_HOLD_REG.DB_number parameter). 0xABCD is the convention.
var options = new ModbusDriverOptions
{
Host = sim.Host,
Port = sim.Port,
UnitId = 1,
Timeout = TimeSpan.FromSeconds(2),
Tags =
[
new ModbusTagDefinition("S7_Fingerprint",
ModbusRegion.HoldingRegisters, Address: 0,
DataType: ModbusDataType.UInt16, Writable: false),
],
Probe = new ModbusProbeOptions { Enabled = false },
};
await using var driver = new ModbusDriver(options, driverInstanceId: "s7-fingerprint");
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
var results = await driver.ReadAsync(["S7_Fingerprint"], TestContext.Current.CancellationToken);
results[0].StatusCode.ShouldBe(0u);
results[0].Value.ShouldBe((ushort)0xABCD);
}
}

View File

@@ -0,0 +1,38 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3" Version="1.1.0"/>
<PackageReference Include="Shouldly" Version="4.3.0"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Modbus\ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj"/>
</ItemGroup>
<ItemGroup>
<None Update="Docker\**\*" CopyToOutputDirectory="PreserveNewest"/>
<None Update="DL205\**\*" CopyToOutputDirectory="PreserveNewest"/>
<None Update="S7\**\*" CopyToOutputDirectory="PreserveNewest"/>
<None Update="Mitsubishi\**\*" CopyToOutputDirectory="PreserveNewest"/>
</ItemGroup>
<ItemGroup>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
</ItemGroup>
</Project>