Compare commits

...

3 Commits

Author SHA1 Message Date
Joseph Doherty
a3f2f95344 Phase 3 PR 49 -- Per-device FC03/FC16 register caps with auto-chunking. Adds MaxRegistersPerRead (default 125, spec max) + MaxRegistersPerWrite (default 123, spec max) to ModbusDriverOptions. Reads that exceed the cap automatically split into consecutive FC03 requests: the driver dispatches chunks of [cap] regs at incrementing addresses, copies each response into an assembled byte[] buffer, and hands the full payload to DecodeRegister. From the caller's view a 240-char string read against a cap-100 device is still one Read() call returning one string -- the chunking is invisible, the wire shows N requests of cap-sized quantity plus one tail chunk. Writes are NOT auto-chunked. Splitting an FC16 across two transactions would lose atomicity -- mid-split crash leaves half the value written, which is strictly worse than rejecting upfront. Instead, writes exceeding MaxRegistersPerWrite throw InvalidOperationException with a message naming the tag + cap + the caller's escape hatch (shorten StringLength or split into multiple tags). The driver catches the exception internally and surfaces it to IWritable as BadInternalError so the caller pattern stays symmetric with other failure modes. Per-family cap cheat-sheet (documented in xml-doc on the option): Modbus-TCP spec = 125 read / 123 write, AutomationDirect DL205/DL260 = 128 read / 100 write (128 exceeds spec byte-count capacity so in practice 125 is the working ceiling), Mitsubishi Q/FX3U = 64 / 64, Omron CJ/CS = 125 / 123. Not all PLCs reject over-cap requests cleanly -- some drop the connection silently -- so having the cap enforced client-side prevents the hard-to-diagnose 'driver just stopped' failure mode. Unit tests: Read_within_cap_issues_single_FC03_request (control: no unnecessary chunking), Read_above_cap_splits_into_two_FC03_requests (120 regs / cap 100 -> 100+20, asserts exact per-chunk (Address,Quantity) and end-to-end payload continuity starting with register[100] high byte = 'A'), Read_cap_honors_Mitsubishi_lower_cap_of_64 (100 regs / cap 64 -> 64+36), Write_exceeding_cap_throws_instead_of_splitting (110 regs / cap 100 -> status != 0 AND Fc16Requests.Count == 0 to prove nothing was sent), Write_within_cap_proceeds_normally (control: cap honored on short writes too). Tests use a new RecordingTransport that captures the (Address, Quantity) tuple of every FC03/FC16 request so the chunk layout is directly assertable -- the existing FakeTransport does not expose request history. 103/103 Modbus.Tests pass; 6/6 DL205 integration tests still pass against the live pymodbus dl205 profile with MODBUS_SIM_PROFILE=dl205. 2026-04-18 21:58:49 -04:00
Joseph Doherty
463c5a4320 Phase 3 PR 48 -- DL205 CDAB word order for Float32 end-to-end test. The driver has supported ModbusByteOrder.WordSwap (CDAB) since PR 24 for all multi-register types -- the underlying word-swap code path was already there. PR 48 closes the loop with an integration test that validates it end-to-end against the dl205 pymodbus profile: HR[1056..1057] stores IEEE-754 1.5f with the low word at the lower address (0x0000 at HR[1056], 0x3FC0 at HR[1057]). Reading with WordSwap returns 1.5f; reading with BigEndian returns a tiny denormal (~5.74e-41) -- a silent "value is 0" bug that typically surfaces in the field only when an operator notices a setpoint readout stuck at 0 while the PLC display shows the real value. Test asserts both: WordSwap==1.5f AND BigEndian!=1.5f, proving the flag is not a no-op. No driver code changes -- the word-swap normalization at NormalizeWordOrder() has handled Float32/Int32/UInt32 correctly since PR 24 and the unit test suite already covers it (Int32_WordSwap_decodes_CDAB_layout + Float32 equivalent). This PR exists primarily to lock in the integration-level validation so future refactors of the codec don't silently break DL205/DL260 floats. 6/6 DL205 integration tests pass with MODBUS_SIM_PROFILE=dl205. 2026-04-18 21:51:15 -04:00
Joseph Doherty
2b5222f5db Phase 3 PR 47 -- DL205 V-memory octal-address helper. Adds DirectLogicAddress static class with two entry points: UserVMemoryToPdu(string) parses a DirectLOGIC V-address (V-prefixed or bare, whitespace tolerated) as OCTAL and returns the 0-based Modbus PDU address. V2000 octal = decimal 1024 = PDU 0x0400, which is the canonical start of the user V-memory bank on DL205/DL260. SystemVMemoryBasePdu + SystemVMemoryToPdu(ushort offset) handle the system bank (V40400 and up) which does NOT follow the simple octal-to-decimal formula -- the CPU relocates the system bank to PDU 0x2100 in H2-ECOM100 absolute mode. A naive caller converting 40400 octal would land at PDU 0x4100 (decimal 16640) and miss the system registers entirely; the helper routes the correct 0x2100 base. Why this matters: DirectLOGIC operators think in OCTAL (the ladder-logic editor, the Productivity/Do-more UI, every AutomationDirect manual addresses V-memory octally) while the Modbus wire is DECIMAL. Integrators routinely copy V-addresses from the PLC documentation into client configs and read garbage because they treated V2000 as decimal 2000 (HR[2000] = 0 in the dl205 sim, zero in most PLCs). The helper makes the translation explicit per the D2-USER-M appendix + H2-ECOM-M \u00A76.5 references cited in docs/v2/dl205.md. Unit tests: UserVMemoryToPdu_converts_octal_V_prefix (V0, V1, V7, V10, V2000, V7777, V10000, V17777 -- the exact sweep documented in dl205.md), UserVMemoryToPdu_accepts_bare_or_prefixed_or_padded (case + whitespace tolerance), UserVMemoryToPdu_rejects_non_octal_digits (V8/V19/V2009 must throw ArgumentException with 'octal' in the message -- .NET has no base-8 int.Parse so we hand-walk digits to catch 8/9 instead of silently accepting them), UserVMemoryToPdu_rejects_empty_input, UserVMemoryToPdu_overflow_rejected (200000 octal = 0x10000 overflows ushort), SystemVMemoryBasePdu_is_0x2100_for_V40400, SystemVMemoryToPdu_offsets_within_bank, SystemVMemoryToPdu_rejects_overflow. 23/23 Modbus.Tests pass. Integration tests against dl205.json pymodbus profile: DL205_V2000_user_memory_resolves_to_PDU_0x0400_marker (reads HR[0x0400]=0x2000), DL205_V40400_system_memory_resolves_to_PDU_0x2100_marker (reads HR[0x2100]=0x4040). 5/5 DL205 integration tests pass. Caller opts into the helper per tag by calling DirectLogicAddress.UserVMemoryToPdu("V2000") as the ModbusTagDefinition Address -- no driver-wide "DL205 mode" flag needed, because users mix DL and non-DL tags in a single driver instance all the time. 2026-04-18 21:49:58 -04:00
7 changed files with 532 additions and 6 deletions

View File

@@ -0,0 +1,74 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
/// <summary>
/// AutomationDirect DirectLOGIC address-translation helpers. DL205 / DL260 / DL350 CPUs
/// address V-memory in OCTAL while the Modbus wire uses DECIMAL PDU addresses — operators
/// see "V2000" in the PLC ladder-logic editor but the Modbus client must write PDU 0x0400.
/// The formulas differ between user V-memory (simple octal-to-decimal) and system V-memory
/// (fixed bank mappings), so the two cases are separate methods rather than one overloaded
/// "ToPdu" call.
/// </summary>
/// <remarks>
/// See <c>docs/v2/dl205.md</c> §V-memory for the full CPU-family matrix + rationale.
/// References: D2-USER-M appendix (DL205/D2-260), H2-ECOM-M §6.5 (absolute vs relative
/// addressing), AutomationDirect forum guidance on V40400 system-base.
/// </remarks>
public static class DirectLogicAddress
{
/// <summary>
/// Convert a DirectLOGIC user V-memory address (octal) to a 0-based Modbus PDU address.
/// Accepts bare octal (<c>"2000"</c>) or <c>V</c>-prefixed (<c>"V2000"</c>). Range
/// depends on CPU model — DL205 D2-260 user memory is V1400-V7377 + V10000-V17777
/// octal, DL260 extends to V77777 octal.
/// </summary>
/// <exception cref="ArgumentException">Input is null / empty / contains non-octal digits (8,9).</exception>
/// <exception cref="OverflowException">Parsed value exceeds ushort.MaxValue (0xFFFF).</exception>
public static ushort UserVMemoryToPdu(string vAddress)
{
if (string.IsNullOrWhiteSpace(vAddress))
throw new ArgumentException("V-memory address must not be empty", nameof(vAddress));
var s = vAddress.Trim();
if (s[0] == 'V' || s[0] == 'v') s = s.Substring(1);
if (s.Length == 0)
throw new ArgumentException($"V-memory address '{vAddress}' has no digits", nameof(vAddress));
// Octal conversion. Reject 8/9 digits up-front — int.Parse in the obvious base would
// accept them silently because .NET has no built-in base-8 parser.
uint result = 0;
foreach (var ch in s)
{
if (ch < '0' || ch > '7')
throw new ArgumentException(
$"V-memory address '{vAddress}' contains non-octal digit '{ch}' — DirectLOGIC V-addresses are octal (0-7)",
nameof(vAddress));
result = result * 8 + (uint)(ch - '0');
if (result > ushort.MaxValue)
throw new OverflowException(
$"V-memory address '{vAddress}' exceeds the 16-bit Modbus PDU address range");
}
return (ushort)result;
}
/// <summary>
/// DirectLOGIC system V-memory starts at octal V40400 on DL260 / H2-ECOM100 in factory
/// "absolute" addressing mode. Unlike user V-memory, the mapping is NOT a simple
/// octal-to-decimal conversion — the CPU relocates the system bank to Modbus PDU 0x2100
/// (decimal 8448). This helper returns the CPU-family base plus a user-supplied offset
/// within the system bank.
/// </summary>
public const ushort SystemVMemoryBasePdu = 0x2100;
/// <param name="offsetWithinSystemBank">
/// 0-based register offset within the system bank. Pass 0 for V40400 itself; pass 1 for
/// V40401 (octal), and so on. NOT an octal-decoded value — the system bank lives at
/// consecutive PDU addresses, so the offset is plain decimal.
/// </param>
public static ushort SystemVMemoryToPdu(ushort offsetWithinSystemBank)
{
var pdu = SystemVMemoryBasePdu + offsetWithinSystemBank;
if (pdu > ushort.MaxValue)
throw new OverflowException(
$"System V-memory offset {offsetWithinSystemBank} maps past 0xFFFF");
return (ushort)pdu;
}
}

View File

@@ -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);

View File

@@ -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

View File

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

View File

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

View File

@@ -0,0 +1,77 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
[Trait("Category", "Unit")]
public sealed class DirectLogicAddressTests
{
[Theory]
[InlineData("V0", (ushort)0x0000)]
[InlineData("V1", (ushort)0x0001)]
[InlineData("V7", (ushort)0x0007)]
[InlineData("V10", (ushort)0x0008)]
[InlineData("V2000", (ushort)0x0400)] // canonical DL205/DL260 user-memory start
[InlineData("V7777", (ushort)0x0FFF)]
[InlineData("V10000", (ushort)0x1000)]
[InlineData("V17777", (ushort)0x1FFF)]
public void UserVMemoryToPdu_converts_octal_V_prefix(string v, ushort expected)
=> DirectLogicAddress.UserVMemoryToPdu(v).ShouldBe(expected);
[Theory]
[InlineData("0", (ushort)0)]
[InlineData("2000", (ushort)0x0400)]
[InlineData("v2000", (ushort)0x0400)] // lowercase v
[InlineData(" V2000 ", (ushort)0x0400)] // surrounding whitespace
public void UserVMemoryToPdu_accepts_bare_or_prefixed_or_padded(string v, ushort expected)
=> DirectLogicAddress.UserVMemoryToPdu(v).ShouldBe(expected);
[Theory]
[InlineData("V8")] // 8 is not a valid octal digit
[InlineData("V19")]
[InlineData("V2009")]
public void UserVMemoryToPdu_rejects_non_octal_digits(string v)
{
Should.Throw<ArgumentException>(() => DirectLogicAddress.UserVMemoryToPdu(v))
.Message.ShouldContain("octal");
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("V")]
public void UserVMemoryToPdu_rejects_empty_input(string? v)
=> Should.Throw<ArgumentException>(() => DirectLogicAddress.UserVMemoryToPdu(v!));
[Fact]
public void UserVMemoryToPdu_overflow_rejected()
{
// 200000 octal = 0x10000 — one past ushort range.
Should.Throw<OverflowException>(() => DirectLogicAddress.UserVMemoryToPdu("V200000"));
}
[Fact]
public void SystemVMemoryBasePdu_is_0x2100_for_V40400()
{
// V40400 on DL260 / H2-ECOM100 absolute mode → PDU 0x2100 (decimal 8448), NOT 0x4100
// which a naive octal-to-decimal of 40400 octal would give (= 16640).
DirectLogicAddress.SystemVMemoryBasePdu.ShouldBe((ushort)0x2100);
DirectLogicAddress.SystemVMemoryToPdu(0).ShouldBe((ushort)0x2100);
}
[Fact]
public void SystemVMemoryToPdu_offsets_within_bank()
{
DirectLogicAddress.SystemVMemoryToPdu(1).ShouldBe((ushort)0x2101);
DirectLogicAddress.SystemVMemoryToPdu(0x100).ShouldBe((ushort)0x2200);
}
[Fact]
public void SystemVMemoryToPdu_rejects_overflow()
{
// ushort wrap: 0xFFFF - 0x2100 = 0xDEFF; anything above should throw.
Should.NotThrow(() => DirectLogicAddress.SystemVMemoryToPdu(0xDEFF));
Should.Throw<OverflowException>(() => DirectLogicAddress.SystemVMemoryToPdu(0xDF00));
}
}

View 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);
}
}