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:
Joseph Doherty
2026-10-22 15:35:58 -04:00
parent 1adc9d65e1
commit 4a3d7a9361
13 changed files with 656 additions and 58 deletions
@@ -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.