review(Driver.Modbus): validate FC03 RMW response + correct write error mapping

Re-review at 7286d320. Modbus-013 (Low): bit RMW now routes the FC03 read through the
validated ReadRegisterBlockAsync (was raw-indexing readResp -> IndexOutOfRange on a truncated
PDU). Modbus-014 (Low): WriteAsync maps InvalidDataException to BadCommunicationError (was
BadInternalError), matching ReadAsync. + TDD.
This commit is contained in:
Joseph Doherty
2026-06-19 11:34:34 -04:00
parent f2bdd8bc1c
commit 6853a0430f
3 changed files with 131 additions and 6 deletions
@@ -987,6 +987,14 @@ public sealed class ModbusDriver
{
results[i] = new WriteResult(MapModbusExceptionToStatus(mex.ExceptionCode));
}
catch (InvalidDataException)
{
// Driver.Modbus-014: malformed/truncated PDU during a write (e.g. the FC03 RMW
// read returning a short response). This is a communication-layer error — surface
// as BadCommunicationError to match the ReadAsync path, not BadInternalError which
// implies a driver code defect.
results[i] = new WriteResult(StatusBadCommunicationError);
}
catch (Exception)
{
results[i] = new WriteResult(StatusBadInternalError);
@@ -1165,10 +1173,16 @@ public sealed class ModbusDriver
try
{
// FC03 read 1 holding register at tag.Address.
var readPdu = new byte[] { 0x03, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF), 0x00, 0x01 };
var readResp = await transport.SendAsync(ResolveUnitId(tag), readPdu, ct).ConfigureAwait(false);
// resp = [fc][byte-count=2][hi][lo]
var current = (ushort)((readResp[2] << 8) | readResp[3]);
// Driver.Modbus-013: use ReadRegisterBlockAsync so the response is validated before
// indexing (mirrors the fix applied to the normal read path in Driver.Modbus-005).
// Direct transport.SendAsync previously skipped the length checks, letting a truncated
// PDU throw IndexOutOfRangeException from readResp[2]/[3] — now surfaces as a clean
// InvalidDataException, which WriteAsync's communication-error catch arm maps to
// BadCommunicationError (see Driver.Modbus-014).
var readRaw = await ReadRegisterBlockAsync(transport, ResolveUnitId(tag), 0x03, tag.Address, 1, ct).ConfigureAwait(false);
// readRaw = [hi][lo] — ReadRegisterBlockAsync strips the fc+byte-count header and
// validates the payload length, so [0] and [1] are always safe to index here.
var current = (ushort)((readRaw[0] << 8) | readRaw[1]);
var updated = on
? (ushort)(current | (1 << bit))