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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
@@ -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)");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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)");
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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"
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)" }
|
||||
]
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user