166 lines
7.7 KiB
C#
166 lines
7.7 KiB
C#
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);
|
|
}
|
|
}
|