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:
+7
-7
@@ -87,26 +87,26 @@ public sealed class CommsRecoveryTests(ModbusSimulatorFixture sim)
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// U1 (DisableFC23 wired): a multi-register write on the default (FC23 write + read-back) path
|
||||
/// U1 (FC23 opt-in, DisableFC23 kill-switch): a multi-register write on the opted-in 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].
|
||||
/// is moved to FC16 — and so does the default 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)
|
||||
[InlineData(true, 200)]
|
||||
[InlineData(false, 202)]
|
||||
public async Task Multi_register_write_round_trips_with_and_without_FC23(bool useFc23, 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,
|
||||
Host = sim.Host, Port = sim.Port, UnitId = 1, Timeout = TimeSpan.FromSeconds(2), UseFC23ForMultiRegisterWrites = useFc23,
|
||||
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;
|
||||
var value = useFc23 ? 6.5f : 12.25f;
|
||||
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);
|
||||
|
||||
@@ -49,6 +49,32 @@ public sealed class ModbusCoalescingDefaultsTests
|
||||
r.Select(s => (short)s.Value!).ShouldBe(Enumerable.Range(1, 10).Select(i => (short)i));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fix round 1 (coordinator ruling 2): an explicitly authored maxReadGap 0 — what the pre-#741 form
|
||||
/// persisted on every driver — keeps its old meaning, NO coalescing; the gap-8 default applies only to an
|
||||
/// absent key (positive control).
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("""{"host":"h","maxReadGap":0,"disableFC23":false}""", 2)]
|
||||
[InlineData("""{"host":"h"}""", 1)]
|
||||
public async Task A_persisted_maxReadGap_0_keeps_coalescing_off(string legacyConfig, int expectedReads)
|
||||
{
|
||||
var parsed = ModbusDriverFactoryExtensions.ParseOptions("m1", legacyConfig);
|
||||
var tags = new[]
|
||||
{
|
||||
new ModbusTagDefinition("A", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16),
|
||||
new ModbusTagDefinition("B", ModbusRegion.HoldingRegisters, 101, ModbusDataType.Int16),
|
||||
};
|
||||
var (drv, t) = await StartAsync(new ModbusDriverOptions
|
||||
{
|
||||
Host = "f", MaxReadGap = parsed.MaxReadGap, DisableReadCoalescing = parsed.DisableReadCoalescing,
|
||||
RawTags = ModbusRawTags.Entries(tags), Probe = new ModbusProbeOptions { Enabled = false },
|
||||
});
|
||||
await using var _ = drv;
|
||||
await drv.ReadAsync(["A", "B"], TestContext.Current.CancellationToken);
|
||||
t.Requests(0x03).Count.ShouldBe(expectedReads);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisableReadCoalescing_reads_every_tag_alone()
|
||||
{
|
||||
|
||||
@@ -42,9 +42,9 @@ public sealed class ModbusCoalescingTests
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>MaxReadGap=0 coalesces only adjacent tags: a one-register hole splits the read.</summary>
|
||||
/// <summary>MaxReadGap=0 means no coalescing (its historical meaning, kept for persisted configs).</summary>
|
||||
[Fact]
|
||||
public async Task MaxReadGap_Zero_Does_Not_Bridge_A_Gap()
|
||||
public async Task MaxReadGap_Zero_Means_No_Coalescing()
|
||||
{
|
||||
var fake = new CountingTransport();
|
||||
var t1 = new ModbusTagDefinition("T1", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16);
|
||||
@@ -56,7 +56,7 @@ public sealed class ModbusCoalescingTests
|
||||
|
||||
await drv.ReadAsync(["T1", "T2"], CancellationToken.None);
|
||||
|
||||
// A one-register gap with MaxReadGap=0: expect 2 separate FC03 reads.
|
||||
// MaxReadGap=0 is coalescing OFF: expect 2 separate FC03 reads.
|
||||
var fc03Reads = fake.Reads.Where(r => r.Fc == 0x03).ToList();
|
||||
fc03Reads.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
@@ -39,12 +39,12 @@ public sealed class ModbusFamilyWiringTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_address_string_carries_type_order_and_count_and_keeps_the_other_keys()
|
||||
public void A_string_only_blob_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}""",
|
||||
"""{"addressString":"40011:F:CDAB:4","writable":false,"unitId":3}""",
|
||||
"T", ModbusFamily.Generic, MelsecFamily.Q_L_iQR, out var def).ShouldBeTrue();
|
||||
def.Region.ShouldBe(ModbusRegion.HoldingRegisters, "addressString is authoritative over stale resolved keys");
|
||||
def.Region.ShouldBe(ModbusRegion.HoldingRegisters);
|
||||
def.Address.ShouldBe((ushort)10);
|
||||
def.DataType.ShouldBe(ModbusDataType.Float32);
|
||||
def.ByteOrder.ShouldBe(ModbusByteOrder.WordSwap);
|
||||
@@ -53,6 +53,22 @@ public sealed class ModbusFamilyWiringTests
|
||||
def.UnitId.ShouldBe((byte)3);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fix round 1 (review m2): persisted region/address are authoritative over an addressString, so a
|
||||
/// later Family / MelsecSubFamily change can never silently remap a live tag the editor resolved.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Persisted_resolved_keys_win_over_the_address_string_so_a_family_change_cannot_remap()
|
||||
{
|
||||
// The editor resolved D100 under MELSEC and persisted HR 100; the driver is later switched to DL205.
|
||||
const string json = """{"addressString":"D100","region":"HoldingRegisters","address":100,"dataType":"Int16"}""";
|
||||
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 An_unparseable_address_string_rejects_the_tag_and_the_deploy_gate_says_why()
|
||||
{
|
||||
@@ -83,31 +99,52 @@ public sealed class ModbusFamilyWiringTests
|
||||
[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}""");
|
||||
var o = ModbusDriverFactoryExtensions.ParseOptions("m1",
|
||||
"""{"host":"h","family":"MELSEC","melsecSubFamily":"F_iQF","disableFC23":true,"useFC23ForMultiRegisterWrites":true}""");
|
||||
o.Family.ShouldBe(ModbusFamily.MELSEC);
|
||||
o.MelsecSubFamily.ShouldBe(MelsecFamily.F_iQF);
|
||||
o.DisableFC23.ShouldBeTrue();
|
||||
o.UseFC23ForMultiRegisterWrites.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// ---------------- DisableFC23 gates the FC23 multi-register write ----------------
|
||||
// ---------------- FC23 is OPT-IN; DisableFC23 is the kill-switch ----------------
|
||||
|
||||
private static async Task<(ModbusDriver, ScriptedTransport)> StartWriteAsync(bool disableFc23)
|
||||
private static async Task<(ModbusDriver, ScriptedTransport)> StartWriteAsync(string driverConfigJson)
|
||||
{
|
||||
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 opts = ModbusDriverFactoryExtensions.ParseOptions("m1", driverConfigJson) is var parsed
|
||||
? new ModbusDriverOptions
|
||||
{
|
||||
Host = "f", DisableFC23 = parsed.DisableFC23,
|
||||
UseFC23ForMultiRegisterWrites = parsed.UseFC23ForMultiRegisterWrites,
|
||||
Probe = new ModbusProbeOptions { Enabled = false },
|
||||
RawTags = ModbusRawTags.Entries([new ModbusTagDefinition("F", ModbusRegion.HoldingRegisters, 20, ModbusDataType.Float32)]),
|
||||
}
|
||||
: null!;
|
||||
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()
|
||||
/// <summary>
|
||||
/// Fix round 1 (coordinator ruling 1): a config without the opt-in key keeps the FC16 wire — including the
|
||||
/// legacy shape every form-saved config carries ("disableFC23": false).
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("""{"host":"h"}""")]
|
||||
[InlineData("""{"host":"h","disableFC23":false,"maxReadGap":0,"useFC15ForSingleCoilWrites":false}""")]
|
||||
public async Task Without_the_opt_in_multi_register_writes_stay_on_FC16(string legacyConfig)
|
||||
{
|
||||
var (drv, t) = await StartWriteAsync(disableFc23: false);
|
||||
var (drv, t) = await StartWriteAsync(legacyConfig);
|
||||
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 Opted_in_multi_register_writes_use_FC23_write_and_read_back()
|
||||
{
|
||||
var (drv, t) = await StartWriteAsync("""{"host":"h","useFC23ForMultiRegisterWrites":true}""");
|
||||
await using var _ = drv;
|
||||
var w = await drv.WriteAsync([new WriteRequest("F", 1.5f)], TestContext.Current.CancellationToken);
|
||||
w[0].StatusCode.ShouldBe(0u);
|
||||
@@ -116,18 +153,18 @@ public sealed class ModbusFamilyWiringTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisableFC23_writes_with_FC16()
|
||||
public async Task DisableFC23_overrides_the_opt_in()
|
||||
{
|
||||
var (drv, t) = await StartWriteAsync(disableFc23: true);
|
||||
var (drv, t) = await StartWriteAsync("""{"host":"h","useFC23ForMultiRegisterWrites":true,"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()
|
||||
public async Task An_opted_in_device_without_FC23_falls_back_to_FC16_once_and_stays_on_it()
|
||||
{
|
||||
var (drv, t) = await StartWriteAsync(disableFc23: false);
|
||||
var (drv, t) = await StartWriteAsync("""{"host":"h","useFC23ForMultiRegisterWrites":true}""");
|
||||
await using var _ = drv;
|
||||
t.Handler = (_, pdu, _) => pdu[0] == 0x17
|
||||
? Task.FromException<byte[]>(new ModbusException(0x17, 0x01, "illegal function"))
|
||||
|
||||
@@ -156,8 +156,10 @@ public sealed class ModbusHealthContractTests
|
||||
// ---------------- H7: write status mapping + write-side health ----------------
|
||||
|
||||
[Fact]
|
||||
public async Task Write_transport_failure_is_BadCommunicationError_and_degrades_the_write_side()
|
||||
public async Task Write_transport_failure_is_BadCommunicationError_and_a_good_read_clears_it()
|
||||
{
|
||||
// Fix round 1 (review Important #3): ONE health writer for reads and writes. A single write blip used
|
||||
// to latch Degraded until the next good WRITE — forever, in a read-mostly plant.
|
||||
var (drv, t) = await StartAsync(Opts(Hr("A", 10)));
|
||||
await using var _ = drv;
|
||||
t.Handler = (_, pdu, _) => pdu[0] == 0x06 ? Task.FromException<byte[]>(new IOException("dropped")) : null;
|
||||
@@ -166,16 +168,49 @@ public sealed class ModbusHealthContractTests
|
||||
w[0].StatusCode.ShouldBe(BadCommunicationError);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
|
||||
// A good READ does not clear a write-side fault...
|
||||
(await drv.ReadAsync(["A"], TestContext.Current.CancellationToken))[0].StatusCode.ShouldBe(Good);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy, "a good read proves the shared transport answered");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_good_write_clears_a_read_side_comms_fault()
|
||||
{
|
||||
var (drv, t) = await StartAsync(Opts(Hr("A", 10)));
|
||||
await using var _ = drv;
|
||||
t.Handler = (_, pdu, _) => pdu[0] == 0x03 ? Task.FromException<byte[]>(new IOException("dropped")) : null;
|
||||
await drv.ReadAsync(["A"], TestContext.Current.CancellationToken);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
|
||||
// ...a good WRITE does.
|
||||
t.Handler = null;
|
||||
(await drv.WriteAsync([new WriteRequest("A", (short)2)], TestContext.Current.CancellationToken))[0].StatusCode.ShouldBe(Good);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_malformed_reply_does_not_fail_the_rest_of_the_unit_fast()
|
||||
{
|
||||
// Review m3: a byte-count mismatch is a reply that ARRIVED — only a timeout / desync / IO fails the unit fast.
|
||||
var (drv, t) = await StartAsync(Opts(Hr("Bad", 10), Hr("Fine", 50)) is var o
|
||||
? new ModbusDriverOptions { Host = o.Host, RawTags = o.RawTags, Probe = o.Probe, DisableReadCoalescing = true }
|
||||
: null!);
|
||||
await using var _ = drv;
|
||||
t.Bank.HoldingRegisters[50] = 9;
|
||||
t.Handler = (_, pdu, _) => Addr(pdu) == 10 ? Task.FromResult(new byte[] { 0x03, 0x04, 0, 0 }) : null;
|
||||
|
||||
var r = await drv.ReadAsync(["Bad", "Fine"], TestContext.Current.CancellationToken);
|
||||
r[0].StatusCode.ShouldBe(BadCommunicationError);
|
||||
r[1].Value.ShouldBe((short)9, "the sibling on the same unit is still read");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisposeAsync_leaves_health_Unknown()
|
||||
{
|
||||
// Review m1: Dispose bumps the generation (dropping a concurrent shutdown's write), so it must write its own.
|
||||
var (drv, _) = await StartAsync(Opts(Hr("A", 10)));
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
await drv.DisposeAsync();
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_write_refusal_counts_as_answered_and_clears_a_write_side_fault()
|
||||
{
|
||||
|
||||
+37
-4
@@ -14,14 +14,47 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
public sealed class ModbusRegionTypeValidationTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("""{"region":"Coils","address":1,"dataType":"Int16"}""")]
|
||||
[InlineData("""{"region":"DiscreteInputs","address":1,"dataType":"Float32"}""")]
|
||||
[InlineData("""{"region":"HoldingRegisters","address":1,"dataType":"Bool"}""")]
|
||||
[InlineData("""{"region":"InputRegisters","address":1,"dataType":"Bool"}""")]
|
||||
public void Incompatible_region_and_type_is_rejected(string json)
|
||||
public void A_register_region_with_Bool_is_rejected(string json)
|
||||
{
|
||||
// That pair never read (RegisterCount threw), so refusing it breaks nothing that worked.
|
||||
ModbusTagDefinitionFactory.FromTagConfig(json, "T", out _).ShouldBeFalse();
|
||||
ModbusTagDefinitionFactory.Inspect(json).ShouldContain(w => w.Contains("only") || w.Contains("register"));
|
||||
ModbusTagDefinitionFactory.Inspect(json).ShouldContain(w => w.Contains("register"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fix round 1 (review Important #2): the pre-#741 editor defaulted dataType to Int16 and persisted it for
|
||||
/// coil / DI tags, and the old driver ignored it for bit regions — those tags worked and must keep loading.
|
||||
/// A bit region with any type is coerced to Bool and draws no deploy warning (a warning can be promoted to
|
||||
/// a blocking error by TagConfigValidationMode.Error, which would refuse an existing config).
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("""{"region":"Coils","address":3,"dataType":"Int16","byteOrder":"BigEndian","bitIndex":0,"stringLength":0}""")]
|
||||
[InlineData("""{"region":"DiscreteInputs","address":1,"dataType":"Float32"}""")]
|
||||
public void A_legacy_bit_region_tag_with_a_register_type_still_loads_as_Bool(string legacyJson)
|
||||
{
|
||||
ModbusTagDefinitionFactory.FromTagConfig(legacyJson, "T", out var def).ShouldBeTrue();
|
||||
def.DataType.ShouldBe(ModbusDataType.Bool);
|
||||
ModbusTagDefinitionFactory.Inspect(legacyJson).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_legacy_Int16_coil_tag_reads_and_writes_the_coil()
|
||||
{
|
||||
var fake = new ModbusDriverTests.FakeTransport();
|
||||
fake.Coils[3] = true;
|
||||
var opts = new ModbusDriverOptions
|
||||
{
|
||||
Host = "f", Probe = new ModbusProbeOptions { Enabled = false },
|
||||
RawTags = [new RawTagEntry("Run", """{"region":"Coils","address":3,"dataType":"Int16","byteOrder":"BigEndian","bitIndex":0,"stringLength":0}""", false)],
|
||||
};
|
||||
await using var drv = new ModbusDriver(opts, "m1", _ => fake);
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
(await drv.ReadAsync(["Run"], TestContext.Current.CancellationToken))[0].Value.ShouldBe(true);
|
||||
(await drv.WriteAsync([new WriteRequest("Run", false)], TestContext.Current.CancellationToken))[0].StatusCode.ShouldBe(0u);
|
||||
fake.Coils[3].ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
|
||||
Reference in New Issue
Block a user