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
@@ -141,4 +141,43 @@ public sealed class ModbusTagConfigModelTests
[Fact]
public void Validate_refuses_an_incompatible_region_and_type()
=> ModbusTagConfigModel.FromJson("""{"region":"Coils","address":1,"dataType":"Int16"}""").Validate().ShouldNotBeNull();
/// <summary>Review 07 U1: the editor resolves an address string with the DRIVER's family and persists
/// both the string and the resolved fields; the driver re-resolves the string with its family.</summary>
[Fact]
public void ResolveAddress_uses_the_driver_family_and_persists_string_and_resolved_fields()
{
var (family, sub) = ModbusTagConfigModel.ReadFamily("""{"Family":"MELSEC","melsecSubFamily":"Q_L_iQR"}""");
family.ShouldBe(ModbusFamily.MELSEC);
var m = ModbusTagConfigModel.FromJson("{}");
m.ResolveAddress("D100", family, sub).ShouldBeNull();
m.Region.ShouldBe(ModbusRegion.HoldingRegisters);
m.Address.ShouldBe(100);
var json = m.ToJson();
json.ShouldContain("\"addressString\":\"D100\"");
json.ShouldContain("\"address\":100");
ModbusTagDefinitionFactory.FromTagConfig(json, "T", ModbusFamily.MELSEC, MelsecFamily.Q_L_iQR, out var def).ShouldBeTrue();
def.Address.ShouldBe((ushort)100);
ModbusTagConfigModel.FromJson(json).Validate().ShouldBeNull("the family-unaware authoring gate accepts what the MELSEC driver serves");
// Positive control: under Generic the same text is an error, and nothing is persisted.
var g = ModbusTagConfigModel.FromJson("{}");
g.ResolveAddress("D100", ModbusFamily.Generic, MelsecFamily.Q_L_iQR).ShouldNotBeNull();
g.AddressString.ShouldBeNull();
}
[Fact]
public void A_hand_edit_after_resolving_drops_the_address_string()
{
var m = ModbusTagConfigModel.FromJson("{}");
m.ResolveAddress("40011:F", ModbusFamily.Generic, MelsecFamily.Q_L_iQR).ShouldBeNull();
m.ClearAddressString();
m.Address = 50;
var json = m.ToJson();
json.ShouldNotContain("addressString");
ModbusTagDefinitionFactory.FromTagConfig(json, "T", out var def).ShouldBeTrue();
def.Address.ShouldBe((ushort)50);
}
}