feat(modbus): wire Family / MelsecSubFamily / DisableFC23, and the form's timing keys
U1: a tag's addressString (Modicon / mnemonic / DL205 V-memory / MELSEC D-X-Y grammar) now resolves through ModbusAddressParser with the DRIVER's Family and MelsecSubFamily at every initialize and is authoritative; the family-unaware gates (deploy Inspect, AdminUI parse consult) accept what some family resolves. The tag editor gets an address-text field that resolves with the owning driver's family and persists both the string and the resolved region/offset/type fields; a hand edit drops the string. DisableFC23 now gates FC23: by default a 2..121-register holding write is one FC23 write + read-back (a unit answering exception 01, or failing FC23 at the transport, moves to FC16 for the rest of the generation); RTU framing deframes the FC23 reply. Live-checked: the pymodbus fixture implements FC23. Also: the AdminUI driver form serialises ModbusDriverOptions (TimeSpan strings), which the factory DTO never read - every timing field on the form was dead. The DTO now accepts both shapes (*Ms keys win). Refs #741 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
@@ -86,6 +86,37 @@ public sealed class CommsRecoveryTests(ModbusSimulatorFixture sim)
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// U1 (DisableFC23 wired): a multi-register write on the default (FC23 write + read-back) path
|
||||
/// round-trips against the real server — whether it implements FC23 or answers Illegal Function and
|
||||
/// is moved to FC16 — and so does the forced-FC16 path. Uses the standard profile's scratch HR[200..209].
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(false, 200)]
|
||||
[InlineData(true, 202)]
|
||||
public async Task Multi_register_write_round_trips_with_and_without_FC23(bool disableFc23, int address)
|
||||
{
|
||||
SkipUnlessStandardLive();
|
||||
var tag = new ModbusTagDefinition("F", ModbusRegion.HoldingRegisters, (ushort)address, ModbusDataType.Float32);
|
||||
var opts = new ModbusDriverOptions
|
||||
{
|
||||
Host = sim.Host, Port = sim.Port, UnitId = 1, Timeout = TimeSpan.FromSeconds(2), DisableFC23 = disableFc23,
|
||||
RawTags = ModbusRawTags.Entries([tag]), Probe = new ModbusProbeOptions { Enabled = false },
|
||||
};
|
||||
await using var driver = new ModbusDriver(opts, "modbus-u1");
|
||||
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var value = disableFc23 ? 12.25f : 6.5f;
|
||||
for (var i = 0; i < 2; i++) // second write exercises the latched path when the server lacks FC23
|
||||
{
|
||||
var w = await driver.WriteAsync([new WriteRequest("F", value + i)], TestContext.Current.CancellationToken);
|
||||
w[0].StatusCode.ShouldBe(StatusGood);
|
||||
var r = await driver.ReadAsync(["F"], TestContext.Current.CancellationToken);
|
||||
r[0].Value.ShouldBe(value + i);
|
||||
}
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// H2: a deadbanded tag whose device returns holding the SAME value must publish the Good recovery.
|
||||
/// HR[5] is a constant 5 in the standard profile, so the recovery value is identical by construction.
|
||||
|
||||
@@ -46,6 +46,7 @@ public sealed class ModbusDriverTests
|
||||
0x06 => Task.FromResult(WriteSingleReg(pdu)),
|
||||
0x0F => Task.FromResult(WriteMultipleCoils(pdu)),
|
||||
0x10 => Task.FromResult(WriteMultipleRegs(pdu)),
|
||||
0x17 => Task.FromResult(ReadWriteMultipleRegs(pdu)),
|
||||
_ => Task.FromException<byte[]>(new ModbusException(fc, 0x01, $"fc={fc} not supported by fake")),
|
||||
};
|
||||
}
|
||||
@@ -102,6 +103,26 @@ public sealed class ModbusDriverTests
|
||||
return new byte[] { 0x10, pdu[1], pdu[2], pdu[3], pdu[4] };
|
||||
}
|
||||
|
||||
// FC23 Read/Write Multiple Registers: write first, then read back the read range.
|
||||
private byte[] ReadWriteMultipleRegs(byte[] pdu)
|
||||
{
|
||||
var readAddr = (ushort)((pdu[1] << 8) | pdu[2]);
|
||||
var readQty = (ushort)((pdu[3] << 8) | pdu[4]);
|
||||
var writeAddr = (ushort)((pdu[5] << 8) | pdu[6]);
|
||||
var writeQty = (ushort)((pdu[7] << 8) | pdu[8]);
|
||||
for (var i = 0; i < writeQty; i++)
|
||||
HoldingRegisters[writeAddr + i] = (ushort)((pdu[10 + i * 2] << 8) | pdu[11 + i * 2]);
|
||||
var resp = new byte[2 + readQty * 2];
|
||||
resp[0] = 0x17;
|
||||
resp[1] = (byte)(readQty * 2);
|
||||
for (var i = 0; i < readQty; i++)
|
||||
{
|
||||
resp[2 + i * 2] = (byte)(HoldingRegisters[readAddr + i] >> 8);
|
||||
resp[3 + i * 2] = (byte)(HoldingRegisters[readAddr + i] & 0xFF);
|
||||
}
|
||||
return resp;
|
||||
}
|
||||
|
||||
private byte[] WriteMultipleCoils(byte[] pdu)
|
||||
{
|
||||
var addr = (ushort)((pdu[1] << 8) | pdu[2]);
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 07 U1: <c>Family</c>, <c>MelsecSubFamily</c> and <c>DisableFC23</c> were authored in the
|
||||
/// AdminUI and consumed by nothing. A tag's <c>addressString</c> now resolves through
|
||||
/// <see cref="ModbusAddressParser"/> with the driver's family; <c>DisableFC23</c> gates the FC23
|
||||
/// write-and-read-back path for multi-register writes. Also pins that the AdminUI form's own
|
||||
/// (TimeSpan-shaped) config keys reach the driver.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ModbusFamilyWiringTests
|
||||
{
|
||||
// ---------------- addressString through the family-aware parser ----------------
|
||||
|
||||
[Fact]
|
||||
public void A_MELSEC_D_register_resolves_only_under_the_MELSEC_family()
|
||||
{
|
||||
const string json = """{"addressString":"D100"}""";
|
||||
ModbusTagDefinitionFactory.FromTagConfig(json, "T", ModbusFamily.MELSEC, MelsecFamily.Q_L_iQR, out var def).ShouldBeTrue();
|
||||
def.Region.ShouldBe(ModbusRegion.HoldingRegisters);
|
||||
def.Address.ShouldBe((ushort)100);
|
||||
// Positive control: the same text means nothing to a Generic (Modicon) driver.
|
||||
ModbusTagDefinitionFactory.FromTagConfig(json, "T", ModbusFamily.Generic, MelsecFamily.Q_L_iQR, out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_DL205_V_memory_address_resolves_octal_under_DL205()
|
||||
{
|
||||
ModbusTagDefinitionFactory.FromTagConfig("""{"addressString":"V2000"}""", "T", ModbusFamily.DL205, MelsecFamily.Q_L_iQR, out var def)
|
||||
.ShouldBeTrue();
|
||||
def.Region.ShouldBe(ModbusRegion.HoldingRegisters);
|
||||
def.Address.ShouldBe((ushort)0x400); // V2000 octal = 1024
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_address_string_carries_type_order_and_count_and_keeps_the_other_keys()
|
||||
{
|
||||
ModbusTagDefinitionFactory.FromTagConfig(
|
||||
"""{"addressString":"40011:F:CDAB:4","writable":false,"unitId":3,"region":"Coils","address":9}""",
|
||||
"T", ModbusFamily.Generic, MelsecFamily.Q_L_iQR, out var def).ShouldBeTrue();
|
||||
def.Region.ShouldBe(ModbusRegion.HoldingRegisters, "addressString is authoritative over stale resolved keys");
|
||||
def.Address.ShouldBe((ushort)10);
|
||||
def.DataType.ShouldBe(ModbusDataType.Float32);
|
||||
def.ByteOrder.ShouldBe(ModbusByteOrder.WordSwap);
|
||||
def.ArrayCount.ShouldBe(4);
|
||||
def.Writable.ShouldBeFalse();
|
||||
def.UnitId.ShouldBe((byte)3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_unparseable_address_string_rejects_the_tag_and_the_deploy_gate_says_why()
|
||||
{
|
||||
const string json = """{"addressString":"ZZ9"}""";
|
||||
ModbusTagDefinitionFactory.FromTagConfig(json, "T", out _).ShouldBeFalse();
|
||||
ModbusTagDefinitionFactory.Inspect(json).ShouldContain(w => w.Contains("addressString"));
|
||||
// The family-unaware gate accepts what SOME family resolves (it must never be stricter than the driver).
|
||||
ModbusTagDefinitionFactory.Inspect("""{"addressString":"D100"}""").ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_driver_resolves_address_strings_with_its_configured_family()
|
||||
{
|
||||
var fake = new ScriptedTransport();
|
||||
fake.Bank.HoldingRegisters[100] = 1234;
|
||||
var opts = new ModbusDriverOptions
|
||||
{
|
||||
Host = "f", Family = ModbusFamily.MELSEC, Probe = new ModbusProbeOptions { Enabled = false },
|
||||
RawTags = [new RawTagEntry("D100", """{"addressString":"D100"}""", false)],
|
||||
};
|
||||
await using var drv = new ModbusDriver(opts, "m1", _ => fake);
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
var r = await drv.ReadAsync(["D100"], TestContext.Current.CancellationToken);
|
||||
r[0].StatusCode.ShouldBe(0u);
|
||||
r[0].Value.ShouldBe((short)1234);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Family_and_MelsecSubFamily_parse_from_the_driver_config()
|
||||
{
|
||||
var o = ModbusDriverFactoryExtensions.ParseOptions("m1", """{"host":"h","family":"MELSEC","melsecSubFamily":"F_iQF","disableFC23":true}""");
|
||||
o.Family.ShouldBe(ModbusFamily.MELSEC);
|
||||
o.MelsecSubFamily.ShouldBe(MelsecFamily.F_iQF);
|
||||
o.DisableFC23.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// ---------------- DisableFC23 gates the FC23 multi-register write ----------------
|
||||
|
||||
private static async Task<(ModbusDriver, ScriptedTransport)> StartWriteAsync(bool disableFc23)
|
||||
{
|
||||
var t = new ScriptedTransport();
|
||||
var opts = new ModbusDriverOptions
|
||||
{
|
||||
Host = "f", DisableFC23 = disableFc23, Probe = new ModbusProbeOptions { Enabled = false },
|
||||
RawTags = ModbusRawTags.Entries([new ModbusTagDefinition("F", ModbusRegion.HoldingRegisters, 20, ModbusDataType.Float32)]),
|
||||
};
|
||||
var drv = new ModbusDriver(opts, "m1", _ => t);
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
return (drv, t);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Multi_register_writes_use_FC23_write_and_read_back_by_default()
|
||||
{
|
||||
var (drv, t) = await StartWriteAsync(disableFc23: false);
|
||||
await using var _ = drv;
|
||||
var w = await drv.WriteAsync([new WriteRequest("F", 1.5f)], TestContext.Current.CancellationToken);
|
||||
w[0].StatusCode.ShouldBe(0u);
|
||||
t.Sent.Select(s => s.Pdu[0]).ShouldBe([(byte)0x17]);
|
||||
(await drv.ReadAsync(["F"], TestContext.Current.CancellationToken))[0].Value.ShouldBe(1.5f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisableFC23_writes_with_FC16()
|
||||
{
|
||||
var (drv, t) = await StartWriteAsync(disableFc23: true);
|
||||
await using var _ = drv;
|
||||
(await drv.WriteAsync([new WriteRequest("F", 1.5f)], TestContext.Current.CancellationToken))[0].StatusCode.ShouldBe(0u);
|
||||
t.Sent.Select(s => s.Pdu[0]).ShouldBe([(byte)0x10]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_device_without_FC23_falls_back_to_FC16_once_and_stays_on_it()
|
||||
{
|
||||
var (drv, t) = await StartWriteAsync(disableFc23: false);
|
||||
await using var _ = drv;
|
||||
t.Handler = (_, pdu, _) => pdu[0] == 0x17
|
||||
? Task.FromException<byte[]>(new ModbusException(0x17, 0x01, "illegal function"))
|
||||
: null;
|
||||
|
||||
(await drv.WriteAsync([new WriteRequest("F", 1.5f)], TestContext.Current.CancellationToken))[0].StatusCode.ShouldBe(0u);
|
||||
(await drv.WriteAsync([new WriteRequest("F", 2.5f)], TestContext.Current.CancellationToken))[0].StatusCode.ShouldBe(0u);
|
||||
|
||||
t.Sent.Select(s => s.Pdu[0]).ShouldBe([(byte)0x17, (byte)0x10, (byte)0x10]);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
// ---------------- the AdminUI form's own keys reach the driver ----------------
|
||||
|
||||
[Fact]
|
||||
public void The_AdminUI_form_shape_of_the_timing_keys_is_honoured()
|
||||
{
|
||||
// The form serialises ModbusDriverOptions itself (camelCase, TimeSpan strings), not the *Ms DTO keys.
|
||||
var formShape = JsonSerializer.Serialize(new ModbusDriverOptions
|
||||
{
|
||||
Host = "h",
|
||||
Timeout = TimeSpan.FromSeconds(7),
|
||||
IdleDisconnectTimeout = TimeSpan.FromSeconds(90),
|
||||
AutoProhibitReprobeInterval = TimeSpan.FromSeconds(45),
|
||||
Probe = new ModbusProbeOptions { Interval = TimeSpan.FromSeconds(11), Timeout = TimeSpan.FromSeconds(3) },
|
||||
KeepAlive = new ModbusKeepAliveOptions { Time = TimeSpan.FromSeconds(60), Interval = TimeSpan.FromSeconds(20) },
|
||||
Reconnect = new ModbusReconnectOptions { InitialDelay = TimeSpan.FromSeconds(2), MaxDelay = TimeSpan.FromSeconds(40) },
|
||||
}, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, Converters = { new JsonStringEnumConverter() } });
|
||||
|
||||
var o = ModbusDriverFactoryExtensions.ParseOptions("m1", formShape);
|
||||
|
||||
o.Timeout.ShouldBe(TimeSpan.FromSeconds(7));
|
||||
o.IdleDisconnectTimeout.ShouldBe(TimeSpan.FromSeconds(90));
|
||||
o.AutoProhibitReprobeInterval.ShouldBe(TimeSpan.FromSeconds(45));
|
||||
o.Probe.Interval.ShouldBe(TimeSpan.FromSeconds(11));
|
||||
o.Probe.Timeout.ShouldBe(TimeSpan.FromSeconds(3));
|
||||
o.KeepAlive.Time.ShouldBe(TimeSpan.FromSeconds(60));
|
||||
o.KeepAlive.Interval.ShouldBe(TimeSpan.FromSeconds(20));
|
||||
o.Reconnect.InitialDelay.ShouldBe(TimeSpan.FromSeconds(2));
|
||||
o.Reconnect.MaxDelay.ShouldBe(TimeSpan.FromSeconds(40));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_millisecond_keys_still_win_when_both_are_present()
|
||||
{
|
||||
var o = ModbusDriverFactoryExtensions.ParseOptions("m1", """{"host":"h","timeoutMs":1500,"timeout":"00:00:07"}""");
|
||||
o.Timeout.ShouldBe(TimeSpan.FromMilliseconds(1500));
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,17 @@ public sealed class ModbusRtuFramingTests
|
||||
pdu.ShouldBe(new byte[] { 0x03, 0x02, 0x00, 0x0A }); // fc + byteCount + data, no addr/CRC
|
||||
}
|
||||
|
||||
/// <summary>FC23 (Read/Write Multiple) replies like a read: byte count + data. It used to be sized as a
|
||||
/// fixed 4-byte write echo and desync.</summary>
|
||||
[Fact]
|
||||
public async Task ReadResponse_FC23_returns_bare_pdu()
|
||||
{
|
||||
var body = new byte[] { 0x01, 0x17, 0x04, 0x00, 0x0A, 0x00, 0x0B };
|
||||
await using var s = Canned(ModbusCrc.Append(body));
|
||||
var pdu = await ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken);
|
||||
pdu.ShouldBe(new byte[] { 0x17, 0x04, 0x00, 0x0A, 0x00, 0x0B });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadResponse_exception_frame_throws_ModbusException()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user