diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs
index 2bc8e8ef..ad9b7295 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs
@@ -746,6 +746,7 @@ public sealed class ModbusDriver
// Fail fast: this unit (or the whole endpoint) already failed at the transport this batch;
// paying the same timeout once per remaining tag would stall the poll N-fold.
results[i] = new DataValueSnapshot(null, StatusBadCommunicationError, null, now);
+ InvalidateWriteCache(fullReferences[i]);
continue;
}
try
@@ -767,6 +768,7 @@ public sealed class ModbusDriver
catch (Exception ex)
{
results[i] = new DataValueSnapshot(null, ClassifyFailure(ex, unit, fullReferences[i], batch, isWrite: false), null, now);
+ InvalidateWriteCache(fullReferences[i]);
}
}
@@ -1253,6 +1255,7 @@ public sealed class ModbusDriver
foreach (var (idx, _) in block.Members)
{
results[idx] = new DataValueSnapshot(null, StatusBadCommunicationError, null, timestamp);
+ InvalidateWriteCache(fullReferences[idx]);
handled.Add(idx);
}
continue;
@@ -1285,6 +1288,7 @@ public sealed class ModbusDriver
foreach (var (idx, _) in block.Members)
{
results[idx] = new DataValueSnapshot(null, status, null, timestamp);
+ InvalidateWriteCache(fullReferences[idx]);
handled.Add(idx);
}
continue;
@@ -1341,6 +1345,17 @@ public sealed class ModbusDriver
? ReadBitBlockAsync(transport, unit, ReadFunctionCode(region), start, qty, ct)
: ReadRegisterBlockAsync(transport, unit, ReadFunctionCode(region), start, qty, ct);
+ ///
+ /// A failed read leaves the device's value UNKNOWN (it may have rebooted holding anything), so the
+ /// WriteOnChangeOnly suppression for that tag is dropped — otherwise re-asserting the same setpoint
+ /// after an outage would be swallowed, the write-side twin of the value-only recovery dedup trap.
+ ///
+ private void InvalidateWriteCache(string fullRef)
+ {
+ if (!_options.WriteOnChangeOnly) return;
+ lock (_lastWrittenLock) _lastWrittenByRef.Remove(fullRef);
+ }
+
private void InvalidateWriteCacheIfDiverged(string fullRef, object value)
{
if (!_options.WriteOnChangeOnly) return;
@@ -1483,6 +1498,8 @@ public sealed class ModbusDriver
catch (Exception ex)
{
results[i] = new WriteResult(MapWriteFailure(ex, unit, w.FullReference, batch, cancellationToken));
+ // A failed write may or may not have landed — the device value is unknown now.
+ InvalidateWriteCache(w.FullReference);
}
}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusHealthContractTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusHealthContractTests.cs
index 50282ec3..0e591189 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusHealthContractTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusHealthContractTests.cs
@@ -215,6 +215,32 @@ public sealed class ModbusHealthContractTests
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
+ [Fact]
+ public async Task A_failed_read_invalidates_the_WriteOnChangeOnly_cache()
+ {
+ // Value-only suppression is the write-side twin of the #527 dedup trap: after a comms fault the
+ // device's register is unknown (it may have rebooted), so re-asserting the same setpoint must reach it.
+ var opts = new ModbusDriverOptions
+ {
+ Host = "f", RawTags = ModbusRawTags.Entries([Hr("A", 10)]), WriteOnChangeOnly = true,
+ Probe = new ModbusProbeOptions { Enabled = false },
+ };
+ var (drv, t) = await StartAsync(opts);
+ await using var _ = drv;
+ int Fc06() => t.Sent.Count(s => s.Pdu[0] == 0x06);
+
+ await drv.WriteAsync([new WriteRequest("A", (short)5)], TestContext.Current.CancellationToken);
+ await drv.WriteAsync([new WriteRequest("A", (short)5)], TestContext.Current.CancellationToken);
+ Fc06().ShouldBe(1, "positive control: an identical re-write is suppressed while nothing failed");
+
+ t.Handler = (_, pdu, _) => pdu[0] == 0x03 ? Task.FromException(new IOException("dropped")) : null;
+ await drv.ReadAsync(["A"], TestContext.Current.CancellationToken);
+ t.Handler = null;
+
+ await drv.WriteAsync([new WriteRequest("A", (short)5)], TestContext.Current.CancellationToken);
+ Fc06().ShouldBe(2, "after a failed read the device value is unknown; the write must go to the wire");
+ }
+
// ---------------- lifecycle generation ----------------
[Fact]