fix(modbus): review r1 - resolved address keys win only when the editor pinned them

N1: the m2 precedence trusted region/address whenever the keys were present,
but the AdminUI model stamps defaulted HoldingRegisters/0 on every save, so a
string-only tag (CSV import, or any editor toggle) silently became HR[0] -
reads AND writes. The resolved keys are now authoritative only when the editor
marked them ("addressResolved": true, written by Resolve); an unmarked
addressString is always resolved through the parser with the driver's family.
The model no longer stamps address fields for a string-only tag, CSV export
leaves its address cells blank, and the editor hint distinguishes string-only
from pinned tags. Tests: CSV import and editor save of a string-only tag, a
stamped-default blob, and the pinned positive control across families.
n1: docs/v2/modbus-addressing.md updated (FC23 opt-in, maxReadGap semantics,
coalescing scope, addressString/family rules, coil coercion, refusals).
n2: form round-trip tests for UseFC23ForMultiRegisterWrites /
DisableReadCoalescing. n4: the editor keeps unresolved address text when the
parent echoes back its own emit. n3: noted, no change.

Refs #741
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 16:06:02 -04:00
parent fd6ecf8293
commit 3531834cce
9 changed files with 219 additions and 36 deletions
@@ -30,6 +30,8 @@ public sealed class ModbusDriverPageFormSerializationTests
UseFC15ForSingleCoilWrites = true,
UseFC16ForSingleRegisterWrites = true,
DisableFC23 = true,
UseFC23ForMultiRegisterWrites = true,
DisableReadCoalescing = true,
WriteOnChangeOnly = true,
AutoReconnect = false,
Family = ModbusFamily.DL205,
@@ -72,6 +74,8 @@ public sealed class ModbusDriverPageFormSerializationTests
back.UseFC15ForSingleCoilWrites.ShouldBeTrue();
back.UseFC16ForSingleRegisterWrites.ShouldBeTrue();
back.DisableFC23.ShouldBeTrue();
back.UseFC23ForMultiRegisterWrites.ShouldBeTrue();
back.DisableReadCoalescing.ShouldBeTrue();
back.WriteOnChangeOnly.ShouldBeTrue();
back.AutoReconnect.ShouldBeFalse();
back.Family.ShouldBe(ModbusFamily.DL205);
@@ -22,6 +22,23 @@ public sealed class ModbusDriverFormModelTests
Converters = { new JsonStringEnumConverter() },
};
/// <summary>Review r1 n2: the lane's new form keys survive FromOptions → ToOptions, and their defaults keep
/// the historical wire (FC16, coalescing on at gap 8 only when the key is absent).</summary>
[Fact]
public void Form_round_trips_the_FC23_opt_in_and_coalescing_keys()
{
var form = new ModbusDriverForm.FormModel { UseFC23ForMultiRegisterWrites = true, DisableFC23 = true, DisableReadCoalescing = true, MaxReadGap = 0 };
var back = ModbusDriverForm.FormModel.FromOptions(form.ToOptions());
back.UseFC23ForMultiRegisterWrites.ShouldBeTrue();
back.DisableFC23.ShouldBeTrue();
back.DisableReadCoalescing.ShouldBeTrue();
back.MaxReadGap.ShouldBe(0);
var defaults = new ModbusDriverForm.FormModel().ToOptions();
defaults.UseFC23ForMultiRegisterWrites.ShouldBeFalse();
defaults.MaxReadGap.ShouldBe((ushort)8);
}
[Fact]
public void Round_trip_preserves_channel_fields_and_enum()
{
@@ -182,6 +182,58 @@ public sealed class ModbusTagConfigModelTests
g.AddressString.ShouldBeNull();
}
/// <summary>Fix round 2 (review r1 N1): a string-only tag through CSV import must still resolve its string
/// with the driver's family — never the HoldingRegisters/0 defaults the model loads it with.</summary>
[Fact]
public void Csv_import_of_a_string_only_tag_keeps_resolving_the_string()
{
var map = CsvColumnMap.Resolve(ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverTypeNames.Modbus).ShouldNotBeNull();
var merged = map.ApplyTypedColumns("""{"addressString":"D100"}""",
new Dictionary<string, string>(StringComparer.Ordinal) { ["Writable"] = "false" });
ModbusTagDefinitionFactory.FromTagConfig(merged, "T", ModbusFamily.MELSEC, MelsecFamily.Q_L_iQR, out var melsec).ShouldBeTrue();
melsec.Address.ShouldBe((ushort)100, "not HR[0]");
melsec.Writable.ShouldBeFalse("the typed column still applied");
// Under another family the string is resolved by THAT family (here it does not parse), never HR[0].
ModbusTagDefinitionFactory.FromTagConfig(merged, "T", ModbusFamily.Generic, MelsecFamily.Q_L_iQR, out _).ShouldBeFalse();
// Export shows blank address cells for it, so an export → re-import cannot author an HR[0] address.
map.ReadTypedColumns(merged).ShouldNotContainKey("Region");
map.ReadTypedColumns(merged).ShouldNotContainKey("Address");
}
/// <summary>Fix round 2 (review r1 N1): an editor save (e.g. the Writable toggle) of a string-only tag.</summary>
[Fact]
public void Editor_save_of_a_string_only_tag_keeps_resolving_the_string()
{
var m = ModbusTagConfigModel.FromJson("""{"addressString":"40011:F"}""");
m.IsStringOnly.ShouldBeTrue();
m.Writable = false;
var json = m.ToJson();
ModbusTagDefinitionFactory.FromTagConfig(json, "T", out var def).ShouldBeTrue();
def.Region.ShouldBe(ModbusRegion.HoldingRegisters);
def.Address.ShouldBe((ushort)10);
def.DataType.ShouldBe(ModbusDataType.Float32);
def.Writable.ShouldBeFalse();
json.ShouldNotContain("\"address\":");
}
/// <summary>Positive control for the two above: Resolve pins the fields (marker), and a family change then
/// cannot remap the tag.</summary>
[Fact]
public void A_resolved_tag_is_pinned_against_a_later_family_change()
{
var m = ModbusTagConfigModel.FromJson("{}");
m.ResolveAddress("D100", ModbusFamily.MELSEC, MelsecFamily.Q_L_iQR).ShouldBeNull();
var json = m.ToJson();
json.ShouldContain("\"addressResolved\":true");
foreach (var family in new[] { ModbusFamily.MELSEC, ModbusFamily.DL205, ModbusFamily.Generic })
{
ModbusTagDefinitionFactory.FromTagConfig(json, "T", family, MelsecFamily.Q_L_iQR, out var def).ShouldBeTrue();
def.Address.ShouldBe((ushort)100, $"family {family}");
}
}
[Fact]
public void A_hand_edit_after_resolving_drops_the_address_string()
{