Compare commits
7 Commits
phase-3-pr
...
phase-3-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5464f11ee | ||
| dae29f14c8 | |||
| f306793e36 | |||
| 9e61873cc0 | |||
| 1a60470d4a | |||
| 635f67bb02 | |||
|
|
a3f2f95344 |
@@ -71,4 +71,95 @@ public static class DirectLogicAddress
|
||||
$"System V-memory offset {offsetWithinSystemBank} maps past 0xFFFF");
|
||||
return (ushort)pdu;
|
||||
}
|
||||
|
||||
// Bit-memory bases per DL260 user manual §I/O-configuration.
|
||||
// Numbers after X / Y / C / SP are OCTAL in DirectLOGIC notation. The Modbus base is
|
||||
// added to the octal-decoded offset; e.g. Y017 = Modbus coil 2048 + octal(17) = 2048 + 15 = 2063.
|
||||
|
||||
/// <summary>
|
||||
/// DL260 Y-output coil base. Y0 octal → Modbus coil address 2048 (0-based).
|
||||
/// </summary>
|
||||
public const ushort YOutputBaseCoil = 2048;
|
||||
|
||||
/// <summary>
|
||||
/// DL260 C-relay coil base. C0 octal → Modbus coil address 3072 (0-based).
|
||||
/// </summary>
|
||||
public const ushort CRelayBaseCoil = 3072;
|
||||
|
||||
/// <summary>
|
||||
/// DL260 X-input discrete-input base. X0 octal → Modbus discrete input 0.
|
||||
/// </summary>
|
||||
public const ushort XInputBaseDiscrete = 0;
|
||||
|
||||
/// <summary>
|
||||
/// DL260 SP special-relay discrete-input base. SP0 octal → Modbus discrete input 1024.
|
||||
/// Read-only; writing SP relays is rejected with Illegal Data Address.
|
||||
/// </summary>
|
||||
public const ushort SpecialBaseDiscrete = 1024;
|
||||
|
||||
/// <summary>
|
||||
/// Translate a DirectLOGIC Y-output address (e.g. <c>"Y0"</c>, <c>"Y17"</c>) to its
|
||||
/// 0-based Modbus coil address on DL260. The trailing number is OCTAL, matching the
|
||||
/// ladder-logic editor's notation.
|
||||
/// </summary>
|
||||
public static ushort YOutputToCoil(string yAddress) =>
|
||||
AddOctalOffset(YOutputBaseCoil, StripPrefix(yAddress, 'Y'));
|
||||
|
||||
/// <summary>
|
||||
/// Translate a DirectLOGIC C-relay address (e.g. <c>"C0"</c>, <c>"C1777"</c>) to its
|
||||
/// 0-based Modbus coil address.
|
||||
/// </summary>
|
||||
public static ushort CRelayToCoil(string cAddress) =>
|
||||
AddOctalOffset(CRelayBaseCoil, StripPrefix(cAddress, 'C'));
|
||||
|
||||
/// <summary>
|
||||
/// Translate a DirectLOGIC X-input address (e.g. <c>"X0"</c>, <c>"X17"</c>) to its
|
||||
/// 0-based Modbus discrete-input address. Reading an unpopulated X returns 0, not an
|
||||
/// exception — the CPU sizes the table to configured I/O, not installed modules.
|
||||
/// </summary>
|
||||
public static ushort XInputToDiscrete(string xAddress) =>
|
||||
AddOctalOffset(XInputBaseDiscrete, StripPrefix(xAddress, 'X'));
|
||||
|
||||
/// <summary>
|
||||
/// Translate a DirectLOGIC SP-special-relay address (e.g. <c>"SP0"</c>) to its 0-based
|
||||
/// Modbus discrete-input address. Accepts <c>"SP"</c> prefix case-insensitively.
|
||||
/// </summary>
|
||||
public static ushort SpecialToDiscrete(string spAddress)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(spAddress))
|
||||
throw new ArgumentException("SP address must not be empty", nameof(spAddress));
|
||||
var s = spAddress.Trim();
|
||||
if (s.Length >= 2 && (s[0] == 'S' || s[0] == 's') && (s[1] == 'P' || s[1] == 'p'))
|
||||
s = s.Substring(2);
|
||||
return AddOctalOffset(SpecialBaseDiscrete, s);
|
||||
}
|
||||
|
||||
private static string StripPrefix(string address, char expectedPrefix)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(address))
|
||||
throw new ArgumentException("Address must not be empty", nameof(address));
|
||||
var s = address.Trim();
|
||||
if (s.Length > 0 && char.ToUpperInvariant(s[0]) == char.ToUpperInvariant(expectedPrefix))
|
||||
s = s.Substring(1);
|
||||
return s;
|
||||
}
|
||||
|
||||
private static ushort AddOctalOffset(ushort baseAddr, string octalDigits)
|
||||
{
|
||||
if (octalDigits.Length == 0)
|
||||
throw new ArgumentException("Address has no digits", nameof(octalDigits));
|
||||
uint offset = 0;
|
||||
foreach (var ch in octalDigits)
|
||||
{
|
||||
if (ch < '0' || ch > '7')
|
||||
throw new ArgumentException(
|
||||
$"Address contains non-octal digit '{ch}' — DirectLOGIC I/O addresses are octal (0-7)",
|
||||
nameof(octalDigits));
|
||||
offset = offset * 8 + (uint)(ch - '0');
|
||||
}
|
||||
var result = baseAddr + offset;
|
||||
if (result > ushort.MaxValue)
|
||||
throw new OverflowException($"Address {baseAddr}+{offset} exceeds 0xFFFF");
|
||||
return (ushort)result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,11 +171,14 @@ public sealed class ModbusDriver(ModbusDriverOptions options, string driverInsta
|
||||
{
|
||||
var quantity = RegisterCount(tag);
|
||||
var fc = tag.Region == ModbusRegion.HoldingRegisters ? (byte)0x03 : (byte)0x04;
|
||||
var pdu = new byte[] { fc, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF),
|
||||
(byte)(quantity >> 8), (byte)(quantity & 0xFF) };
|
||||
var resp = await transport.SendAsync(_options.UnitId, pdu, ct).ConfigureAwait(false);
|
||||
// resp = [fc][byte-count][data...]
|
||||
var data = new ReadOnlySpan<byte>(resp, 2, resp[1]);
|
||||
// Auto-chunk when the tag's register span exceeds the caller-configured cap.
|
||||
// Affects long strings (FC03/04 > 125 regs is spec-forbidden; DL205 caps at 128,
|
||||
// Mitsubishi Q caps at 64). Non-string tags max out at 4 regs so the cap never
|
||||
// triggers for numerics.
|
||||
var cap = _options.MaxRegistersPerRead == 0 ? (ushort)125 : _options.MaxRegistersPerRead;
|
||||
var data = quantity <= cap
|
||||
? await ReadRegisterBlockAsync(transport, fc, tag.Address, quantity, ct).ConfigureAwait(false)
|
||||
: await ReadRegisterBlockChunkedAsync(transport, fc, tag.Address, quantity, cap, ct).ConfigureAwait(false);
|
||||
return DecodeRegister(data, tag);
|
||||
}
|
||||
default:
|
||||
@@ -183,6 +186,33 @@ public sealed class ModbusDriver(ModbusDriverOptions options, string driverInsta
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<byte[]> ReadRegisterBlockAsync(
|
||||
IModbusTransport transport, byte fc, ushort address, ushort quantity, CancellationToken ct)
|
||||
{
|
||||
var pdu = new byte[] { fc, (byte)(address >> 8), (byte)(address & 0xFF),
|
||||
(byte)(quantity >> 8), (byte)(quantity & 0xFF) };
|
||||
var resp = await transport.SendAsync(_options.UnitId, pdu, ct).ConfigureAwait(false);
|
||||
// resp = [fc][byte-count][data...]
|
||||
var data = new byte[resp[1]];
|
||||
Buffer.BlockCopy(resp, 2, data, 0, resp[1]);
|
||||
return data;
|
||||
}
|
||||
|
||||
private async Task<byte[]> ReadRegisterBlockChunkedAsync(
|
||||
IModbusTransport transport, byte fc, ushort address, ushort totalRegs, ushort cap, CancellationToken ct)
|
||||
{
|
||||
var assembled = new byte[totalRegs * 2];
|
||||
ushort done = 0;
|
||||
while (done < totalRegs)
|
||||
{
|
||||
var chunk = (ushort)Math.Min(cap, totalRegs - done);
|
||||
var chunkBytes = await ReadRegisterBlockAsync(transport, fc, (ushort)(address + done), chunk, ct).ConfigureAwait(false);
|
||||
Buffer.BlockCopy(chunkBytes, 0, assembled, done * 2, chunkBytes.Length);
|
||||
done += chunk;
|
||||
}
|
||||
return assembled;
|
||||
}
|
||||
|
||||
// ---- IWritable ----
|
||||
|
||||
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
|
||||
@@ -239,8 +269,13 @@ public sealed class ModbusDriver(ModbusDriverOptions options, string driverInsta
|
||||
}
|
||||
else
|
||||
{
|
||||
// FC 16 (Write Multiple Registers) for 32-bit types
|
||||
// FC 16 (Write Multiple Registers) for 32-bit types.
|
||||
var qty = (ushort)(bytes.Length / 2);
|
||||
var writeCap = _options.MaxRegistersPerWrite == 0 ? (ushort)123 : _options.MaxRegistersPerWrite;
|
||||
if (qty > writeCap)
|
||||
throw new InvalidOperationException(
|
||||
$"Write of {qty} registers to {tag.Name} exceeds MaxRegistersPerWrite={writeCap}. " +
|
||||
$"Split the tag (e.g. shorter StringLength) — partial FC16 chunks would lose atomicity.");
|
||||
var pdu = new byte[6 + 1 + bytes.Length];
|
||||
pdu[0] = 0x10;
|
||||
pdu[1] = (byte)(tag.Address >> 8); pdu[2] = (byte)(tag.Address & 0xFF);
|
||||
|
||||
@@ -25,6 +25,26 @@ public sealed class ModbusDriverOptions
|
||||
/// <see cref="IHostConnectivityProbe"/>.
|
||||
/// </summary>
|
||||
public ModbusProbeOptions Probe { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Maximum registers per FC03 (Read Holding Registers) / FC04 (Read Input Registers)
|
||||
/// transaction. Modbus-TCP spec allows 125; many device families impose lower caps:
|
||||
/// AutomationDirect DL205/DL260 cap at <c>128</c>, Mitsubishi Q/FX3U cap at <c>64</c>,
|
||||
/// Omron CJ/CS cap at <c>125</c>. Set to the lowest cap across the devices this driver
|
||||
/// instance talks to; the driver auto-chunks larger reads into consecutive requests.
|
||||
/// Default <c>125</c> — the spec maximum, safe against any conforming server. Setting
|
||||
/// to <c>0</c> disables the cap (discouraged — the spec upper bound still applies).
|
||||
/// </summary>
|
||||
public ushort MaxRegistersPerRead { get; init; } = 125;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum registers per FC16 (Write Multiple Registers) transaction. Spec maximum is
|
||||
/// <c>123</c>; DL205/DL260 cap at <c>100</c>. Matching caller-vs-device semantics:
|
||||
/// exceeding the cap currently throws (writes aren't auto-chunked because a partial
|
||||
/// write across two FC16 calls is no longer atomic — caller must explicitly opt in
|
||||
/// by shortening the tag's <c>StringLength</c> or splitting it into multiple tags).
|
||||
/// </summary>
|
||||
public ushort MaxRegistersPerWrite { get; init; } = 123;
|
||||
}
|
||||
|
||||
public sealed class ModbusProbeOptions
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
@@ -36,9 +36,9 @@
|
||||
[1280, 1282],
|
||||
[1343, 1343],
|
||||
[1407, 1407],
|
||||
[2048, 2050],
|
||||
[3072, 3074],
|
||||
[4000, 4007],
|
||||
[128, 128],
|
||||
[192, 192],
|
||||
[250, 250],
|
||||
[8448, 8448]
|
||||
],
|
||||
|
||||
@@ -88,25 +88,14 @@
|
||||
],
|
||||
|
||||
"bits": [
|
||||
{"_quirk": "Y0 marker. DL260 maps Y0 to coil 2048 (0-based). Coil 2048 = ON proves the mapping.",
|
||||
"addr": 2048, "value": 1},
|
||||
{"addr": 2049, "value": 0},
|
||||
{"addr": 2050, "value": 1},
|
||||
{"_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": "C0 marker. DL260 maps C0 to coil 3072 (0-based). Coil 3072 = ON proves the mapping.",
|
||||
"addr": 3072, "value": 1},
|
||||
{"addr": 3073, "value": 0},
|
||||
{"addr": 3074, "value": 1},
|
||||
{"_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 C-relays for write-roundtrip tests against the writable C range.",
|
||||
"addr": 4000, "value": 0},
|
||||
{"addr": 4001, "value": 0},
|
||||
{"addr": 4002, "value": 0},
|
||||
{"addr": 4003, "value": 0},
|
||||
{"addr": 4004, "value": 0},
|
||||
{"addr": 4005, "value": 0},
|
||||
{"addr": 4006, "value": 0},
|
||||
{"addr": 4007, "value": 0}
|
||||
{"_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": [],
|
||||
|
||||
@@ -74,4 +74,66 @@ public sealed class DirectLogicAddressTests
|
||||
Should.NotThrow(() => DirectLogicAddress.SystemVMemoryToPdu(0xDEFF));
|
||||
Should.Throw<OverflowException>(() => DirectLogicAddress.SystemVMemoryToPdu(0xDF00));
|
||||
}
|
||||
|
||||
// --- Bit memory: Y-output, C-relay, X-input, SP-special ---
|
||||
|
||||
[Theory]
|
||||
[InlineData("Y0", (ushort)2048)]
|
||||
[InlineData("Y1", (ushort)2049)]
|
||||
[InlineData("Y7", (ushort)2055)]
|
||||
[InlineData("Y10", (ushort)2056)] // octal 10 = decimal 8
|
||||
[InlineData("Y17", (ushort)2063)] // octal 17 = decimal 15
|
||||
[InlineData("Y777", (ushort)2559)] // top of DL260 Y range per doc table
|
||||
public void YOutputToCoil_adds_octal_offset_to_2048(string y, ushort expected)
|
||||
=> DirectLogicAddress.YOutputToCoil(y).ShouldBe(expected);
|
||||
|
||||
[Theory]
|
||||
[InlineData("C0", (ushort)3072)]
|
||||
[InlineData("C1", (ushort)3073)]
|
||||
[InlineData("C10", (ushort)3080)]
|
||||
[InlineData("C1777", (ushort)4095)] // top of DL260 C range
|
||||
public void CRelayToCoil_adds_octal_offset_to_3072(string c, ushort expected)
|
||||
=> DirectLogicAddress.CRelayToCoil(c).ShouldBe(expected);
|
||||
|
||||
[Theory]
|
||||
[InlineData("X0", (ushort)0)]
|
||||
[InlineData("X17", (ushort)15)]
|
||||
[InlineData("X777", (ushort)511)] // top of DL260 X range
|
||||
public void XInputToDiscrete_adds_octal_offset_to_0(string x, ushort expected)
|
||||
=> DirectLogicAddress.XInputToDiscrete(x).ShouldBe(expected);
|
||||
|
||||
[Theory]
|
||||
[InlineData("SP0", (ushort)1024)]
|
||||
[InlineData("SP7", (ushort)1031)]
|
||||
[InlineData("sp0", (ushort)1024)] // lowercase prefix
|
||||
[InlineData("SP777", (ushort)1535)]
|
||||
public void SpecialToDiscrete_adds_octal_offset_to_1024(string sp, ushort expected)
|
||||
=> DirectLogicAddress.SpecialToDiscrete(sp).ShouldBe(expected);
|
||||
|
||||
[Theory]
|
||||
[InlineData("Y8")]
|
||||
[InlineData("C9")]
|
||||
[InlineData("X18")]
|
||||
public void Bit_address_rejects_non_octal_digits(string bad)
|
||||
=> Should.Throw<ArgumentException>(() =>
|
||||
{
|
||||
if (bad[0] == 'Y') DirectLogicAddress.YOutputToCoil(bad);
|
||||
else if (bad[0] == 'C') DirectLogicAddress.CRelayToCoil(bad);
|
||||
else DirectLogicAddress.XInputToDiscrete(bad);
|
||||
});
|
||||
|
||||
[Theory]
|
||||
[InlineData("Y")]
|
||||
[InlineData("C")]
|
||||
[InlineData("")]
|
||||
public void Bit_address_rejects_empty(string bad)
|
||||
=> Should.Throw<ArgumentException>(() => DirectLogicAddress.YOutputToCoil(bad));
|
||||
|
||||
[Fact]
|
||||
public void YOutputToCoil_accepts_lowercase_prefix()
|
||||
=> DirectLogicAddress.YOutputToCoil("y0").ShouldBe((ushort)2048);
|
||||
|
||||
[Fact]
|
||||
public void CRelayToCoil_accepts_bare_octal_without_C_prefix()
|
||||
=> DirectLogicAddress.CRelayToCoil("0").ShouldBe((ushort)3072);
|
||||
}
|
||||
|
||||
165
tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusCapTests.cs
Normal file
165
tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusCapTests.cs
Normal file
@@ -0,0 +1,165 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ModbusCapTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Records every PDU sent so tests can assert request-count and per-request quantity —
|
||||
/// the only observable behaviour of the auto-chunking path.
|
||||
/// </summary>
|
||||
private sealed class RecordingTransport : IModbusTransport
|
||||
{
|
||||
public readonly ushort[] HoldingRegisters = new ushort[1024];
|
||||
public readonly List<(ushort Address, ushort Quantity)> Fc03Requests = new();
|
||||
public readonly List<(ushort Address, ushort Quantity)> Fc16Requests = new();
|
||||
|
||||
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
||||
{
|
||||
var fc = pdu[0];
|
||||
if (fc == 0x03)
|
||||
{
|
||||
var addr = (ushort)((pdu[1] << 8) | pdu[2]);
|
||||
var qty = (ushort)((pdu[3] << 8) | pdu[4]);
|
||||
Fc03Requests.Add((addr, qty));
|
||||
var byteCount = (byte)(qty * 2);
|
||||
var resp = new byte[2 + byteCount];
|
||||
resp[0] = 0x03;
|
||||
resp[1] = byteCount;
|
||||
for (var i = 0; i < qty; i++)
|
||||
{
|
||||
resp[2 + i * 2] = (byte)(HoldingRegisters[addr + i] >> 8);
|
||||
resp[3 + i * 2] = (byte)(HoldingRegisters[addr + i] & 0xFF);
|
||||
}
|
||||
return Task.FromResult(resp);
|
||||
}
|
||||
if (fc == 0x10)
|
||||
{
|
||||
var addr = (ushort)((pdu[1] << 8) | pdu[2]);
|
||||
var qty = (ushort)((pdu[3] << 8) | pdu[4]);
|
||||
Fc16Requests.Add((addr, qty));
|
||||
for (var i = 0; i < qty; i++)
|
||||
HoldingRegisters[addr + i] = (ushort)((pdu[6 + i * 2] << 8) | pdu[7 + i * 2]);
|
||||
return Task.FromResult(new byte[] { 0x10, pdu[1], pdu[2], pdu[3], pdu[4] });
|
||||
}
|
||||
return Task.FromException<byte[]>(new ModbusException(fc, 0x01, $"fc={fc} unsupported"));
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_within_cap_issues_single_FC03_request()
|
||||
{
|
||||
var tag = new ModbusTagDefinition("S", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String,
|
||||
StringLength: 40); // 20 regs — fits in default cap (125).
|
||||
var transport = new RecordingTransport();
|
||||
var opts = new ModbusDriverOptions { Host = "fake", Tags = [tag], Probe = new ModbusProbeOptions { Enabled = false } };
|
||||
await using var drv = new ModbusDriver(opts, "modbus-1", _ => transport);
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
_ = await drv.ReadAsync(["S"], TestContext.Current.CancellationToken);
|
||||
|
||||
transport.Fc03Requests.Count.ShouldBe(1);
|
||||
transport.Fc03Requests[0].Quantity.ShouldBe((ushort)20);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_above_cap_splits_into_two_FC03_requests()
|
||||
{
|
||||
// 240-char string = 120 regs. Cap = 100 (a typical sub-spec device cap). Expect 100 + 20.
|
||||
var tag = new ModbusTagDefinition("LongString", ModbusRegion.HoldingRegisters, 100, ModbusDataType.String,
|
||||
StringLength: 240);
|
||||
var transport = new RecordingTransport();
|
||||
// Seed cells so the re-assembled payload is stable — confirms chunks are stitched in order.
|
||||
for (ushort i = 100; i < 100 + 120; i++)
|
||||
transport.HoldingRegisters[i] = (ushort)((('A' + (i - 100) % 26) << 8) | ('A' + (i - 100) % 26));
|
||||
|
||||
var opts = new ModbusDriverOptions
|
||||
{
|
||||
Host = "fake",
|
||||
Tags = [tag],
|
||||
MaxRegistersPerRead = 100,
|
||||
Probe = new ModbusProbeOptions { Enabled = false },
|
||||
};
|
||||
await using var drv = new ModbusDriver(opts, "modbus-1", _ => transport);
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var results = await drv.ReadAsync(["LongString"], TestContext.Current.CancellationToken);
|
||||
results[0].StatusCode.ShouldBe(0u);
|
||||
|
||||
transport.Fc03Requests.Count.ShouldBe(2, "120 regs / cap 100 → 2 requests");
|
||||
transport.Fc03Requests[0].ShouldBe(((ushort)100, (ushort)100));
|
||||
transport.Fc03Requests[1].ShouldBe(((ushort)200, (ushort)20));
|
||||
|
||||
// Payload continuity: re-assembled string starts where register 100 does and keeps going.
|
||||
var s = (string)results[0].Value!;
|
||||
s.Length.ShouldBeGreaterThan(0);
|
||||
s[0].ShouldBe('A'); // register[100] high byte
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_cap_honors_Mitsubishi_lower_cap_of_64()
|
||||
{
|
||||
// 200-char string = 100 regs. Mitsubishi Q cap = 64. Expect: 64, 36.
|
||||
var tag = new ModbusTagDefinition("MitString", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String,
|
||||
StringLength: 200);
|
||||
var transport = new RecordingTransport();
|
||||
var opts = new ModbusDriverOptions { Host = "fake", Tags = [tag], MaxRegistersPerRead = 64, Probe = new ModbusProbeOptions { Enabled = false } };
|
||||
await using var drv = new ModbusDriver(opts, "modbus-1", _ => transport);
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
_ = await drv.ReadAsync(["MitString"], TestContext.Current.CancellationToken);
|
||||
|
||||
transport.Fc03Requests.Count.ShouldBe(2);
|
||||
transport.Fc03Requests[0].Quantity.ShouldBe((ushort)64);
|
||||
transport.Fc03Requests[1].Quantity.ShouldBe((ushort)36);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Write_exceeding_cap_throws_instead_of_splitting()
|
||||
{
|
||||
// Partial FC16 across two transactions is not atomic. Forcing an explicit exception so the
|
||||
// caller knows their tag definition is incompatible with the device cap rather than silently
|
||||
// writing half a string and crashing between chunks.
|
||||
var tag = new ModbusTagDefinition("LongStringWrite", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String,
|
||||
StringLength: 220); // 110 regs.
|
||||
var transport = new RecordingTransport();
|
||||
var opts = new ModbusDriverOptions { Host = "fake", Tags = [tag], MaxRegistersPerWrite = 100, Probe = new ModbusProbeOptions { Enabled = false } };
|
||||
await using var drv = new ModbusDriver(opts, "modbus-1", _ => transport);
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("LongStringWrite", new string('A', 220))],
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
// Driver catches the internal exception and surfaces BadInternalError — the Fc16Requests
|
||||
// list must still be empty because nothing was sent.
|
||||
results[0].StatusCode.ShouldNotBe(0u);
|
||||
transport.Fc16Requests.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Write_within_cap_proceeds_normally()
|
||||
{
|
||||
var tag = new ModbusTagDefinition("ShortStringWrite", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String,
|
||||
StringLength: 40); // 20 regs.
|
||||
var transport = new RecordingTransport();
|
||||
var opts = new ModbusDriverOptions { Host = "fake", Tags = [tag], MaxRegistersPerWrite = 100, Probe = new ModbusProbeOptions { Enabled = false } };
|
||||
await using var drv = new ModbusDriver(opts, "modbus-1", _ => transport);
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("ShortStringWrite", "HELLO")],
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
results[0].StatusCode.ShouldBe(0u);
|
||||
transport.Fc16Requests.Count.ShouldBe(1);
|
||||
transport.Fc16Requests[0].Quantity.ShouldBe((ushort)20);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user