fix(modbus): review r0 - FC23 opt-in, explicit maxReadGap 0 = off, legacy coil types load, one health writer

Coordinator rulings and Important findings from review r0:
1. FC23 is opt-in (new UseFC23ForMultiRegisterWrites, default false); a config
   without it - including the persisted "disableFC23": false every form-saved
   config carries - keeps the FC16 wire. DisableFC23 stays the hard kill-switch.
2. An explicit maxReadGap 0 means coalescing off again (its pre-lane meaning);
   the gap-8 default applies only to an absent key.
3. Coils/DiscreteInputs tags persisted with a register dataType (the old
   editor saved Int16) are coerced to Bool, never refused, with no deploy
   warning (a warning can be promoted to a blocking error). The editor loads
   them as Bool. Register region + Bool is still refused.
4. One comms-fault slot for reads and writes: a good read clears a write's
   transport fault and vice versa.
Minors: Dispose writes its own Unknown health (m1); persisted region/address
are authoritative over addressString so a Family change cannot remap a live
tag (m2); a malformed-but-delivered reply no longer fails the unit fast (m3);
a fresh socket published after a concurrent dispose is torn down (m4); the
editor resets address text per tag (m5); master merged (m6); the FC23 latch
log says re-init restores it (m7). Back-compat tests use the old persisted
JSON shapes and fail without the fixes.

Refs #741
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 15:55:32 -04:00
parent c12269c221
commit fd6ecf8293
15 changed files with 314 additions and 117 deletions
@@ -80,7 +80,7 @@
<div class="col-md-3">
<label class="form-label">Max read gap (coalescing)</label>
<InputNumber @bind-Value="_form.MaxReadGap" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Default 8. 0 = adjacent tags only. Refused ranges fall back per tag.</div>
<div class="form-text">0 = no coalescing. Default 8. Refused ranges fall back per tag.</div>
</div>
<div class="col-md-3">
<div class="form-check form-switch mt-4">
@@ -112,7 +112,13 @@
<div class="col-md-4">
<div class="form-check form-switch">
<InputCheckbox @bind-Value="_form.DisableFC23" @bind-Value:after="EmitAsync" class="form-check-input" id="disableFC23" />
<label class="form-check-label" for="disableFC23">Disable FC23 (multi-register writes use FC16 instead of FC23 write + read-back)</label>
<label class="form-check-label" for="disableFC23">Disable FC23 (never send FC23, even when opted in below)</label>
</div>
</div>
<div class="col-md-4">
<div class="form-check form-switch">
<InputCheckbox @bind-Value="_form.UseFC23ForMultiRegisterWrites" @bind-Value:after="EmitAsync" class="form-check-input" id="useFC23" />
<label class="form-check-label" for="useFC23">Use FC23 write + read-back for multi-register writes (opt-in; default FC16)</label>
</div>
</div>
<div class="col-md-4">
@@ -324,6 +330,7 @@
public bool UseFC15ForSingleCoilWrites { get; set; } = false;
public bool UseFC16ForSingleRegisterWrites { get; set; } = false;
public bool DisableFC23 { get; set; } = false;
public bool UseFC23ForMultiRegisterWrites { get; set; } = false;
public bool WriteOnChangeOnly { get; set; } = false;
// Keep-alive
@@ -368,6 +375,7 @@
UseFC15ForSingleCoilWrites = o.UseFC15ForSingleCoilWrites,
UseFC16ForSingleRegisterWrites = o.UseFC16ForSingleRegisterWrites,
DisableFC23 = o.DisableFC23,
UseFC23ForMultiRegisterWrites = o.UseFC23ForMultiRegisterWrites,
WriteOnChangeOnly = o.WriteOnChangeOnly,
KeepAliveEnabled = o.KeepAlive.Enabled,
KeepAliveTimeSeconds = (int)o.KeepAlive.Time.TotalSeconds,
@@ -404,6 +412,7 @@
UseFC15ForSingleCoilWrites = UseFC15ForSingleCoilWrites,
UseFC16ForSingleRegisterWrites = UseFC16ForSingleRegisterWrites,
DisableFC23 = DisableFC23,
UseFC23ForMultiRegisterWrites = UseFC23ForMultiRegisterWrites,
AutoProhibitReprobeInterval = AutoProhibitReprobeIntervalSeconds > 0
? TimeSpan.FromSeconds(AutoProhibitReprobeIntervalSeconds) : null,
MaxReadGap = (ushort)Math.Clamp(MaxReadGap, 0, 65535),
@@ -80,7 +80,10 @@
if (ConfigJson == _lastConfigJson) { return; }
_lastConfigJson = ConfigJson;
_m = ModbusTagConfigModel.FromJson(ConfigJson);
_addressText ??= _m.AddressString;
// Reset on EVERY config change: a reused editor instance switched to another tag must not show the
// previous tag's address text.
_addressText = _m.AddressString;
_addressError = null;
_family = ModbusTagConfigModel.ReadFamily(GetDriverConfigJson()).Family;
}
@@ -32,9 +32,10 @@ public sealed class ModbusTagConfigModel
/// <summary>
/// The address in the parser grammar (<c>40001:F:CDAB</c>, DL205 <c>V2000</c>, MELSEC <c>D100</c>) when
/// the tag was authored that way; <c>null</c> for a tag authored by region + numeric address. When
/// present the driver re-resolves it with ITS family at runtime, so it is authoritative — editing any
/// resolved field by hand clears it (<see cref="ClearAddressString"/>).
/// the tag was authored that way; <c>null</c> for a tag authored by region + numeric address. The
/// persisted resolved fields are what the driver reads (the string is only resolved by the driver for a
/// string-only blob), so editing any resolved field by hand clears it (<see cref="ClearAddressString"/>)
/// to keep the two from disagreeing.
/// </summary>
public string? AddressString { get; set; }
@@ -130,9 +131,12 @@ public sealed class ModbusTagConfigModel
_source = json ?? "",
Region = TagConfigJson.GetEnum(o, "region", ModbusRegion.HoldingRegisters),
Address = TagConfigJson.GetInt(o, "address"),
// Absent dataType defaults per region, exactly as the driver's factory does (Bool for a bit region).
DataType = TagConfigJson.GetEnum(o, "dataType",
ModbusTagDefinitionFactory.DefaultDataType(TagConfigJson.GetEnum(o, "region", ModbusRegion.HoldingRegisters))),
// Absent dataType defaults per region, exactly as the driver's factory does (Bool for a bit region),
// and a bit region shows Bool whatever was persisted — the driver coerces a legacy coil/DI
// "dataType":"Int16" (what the pre-#741 editor saved) to Bool, so the editor shows what is polled.
DataType = TagConfigJson.GetEnum(o, "region", ModbusRegion.HoldingRegisters) is ModbusRegion.Coils or ModbusRegion.DiscreteInputs
? ModbusDataType.Bool
: TagConfigJson.GetEnum(o, "dataType", ModbusDataType.Int16),
ByteOrder = TagConfigJson.GetEnum(o, "byteOrder", ModbusByteOrder.BigEndian),
BitIndex = TagConfigJson.GetInt(o, "bitIndex"),
StringLength = TagConfigJson.GetInt(o, "stringLength"),