On boards using the Arduino mbed core (Arduino Opta WiFi, Opta RS485, Portenta H7, Nano 33 BLE, etc.), RS485.endTransmission() drops the DE (driver-enable) line before the UART has finished clocking the final byte out of the shift register. The result: the last byte of every transmission is truncated on the wire, the slave's CRC check fails, and the slave silently rejects the frame.
The default _postDelay of 0 µs is the trigger. The library does call _serial->flush(), but on the mbed core Serial1.flush() returns when the TX buffer is empty — before the UART transmit-complete (TXC) flag asserts. DE then drops mid-byte.
This affects every Modbus RTU user on the Opta. The workaround is well-known in the community but not discoverable without burning a day on a logic analyzer.
Affected boards (confirmed or strongly suspected)
- Arduino Opta WiFi (AFX00002) — confirmed
- Arduino Opta RS485 (AFX00001) — same RS-485 hardware, expected same behavior
- Likely also: Portenta H7 + Portenta Machine Control, Nano 33 BLE shields, any board where
flush() does not block on TXC
Reproduction
Minimal sketch (Opta WiFi, any Modbus RTU slave at 9600 8N2 — we used a SunSaver MPPT via Morningstar MRC-1):
#include <ArduinoRS485.h>
#include <ArduinoModbus.h>
void setup() {
Serial.begin(115200);
while (!Serial && millis() < 3000) {}
ModbusRTUClient.begin(9600, SERIAL_8N2);
ModbusRTUClient.setTimeout(500);
// <-- comment this line out to reproduce the bug
// RS485.setDelays(0, 1200);
}
void loop() {
uint16_t v = 0;
if (ModbusRTUClient.requestFrom(1, HOLDING_REGISTERS, 0x0008, 1) &&
ModbusRTUClient.available()) {
v = ModbusRTUClient.read();
Serial.print("OK 0x"); Serial.println(v, HEX);
} else {
Serial.print("FAIL err="); Serial.println(ModbusRTUClient.lastError());
}
delay(1000);
}
Without setDelays(0, 1200): every poll fails with lastError() == 4 (timeout). Logic-analyzer capture shows the final byte of the request is truncated to ~half its normal width.
With setDelays(0, 1200): every poll succeeds.
Root cause analysis
In RS485.cpp:
size_t RS485Class::endTransmission() {
_serial->flush();
if (_postDelay > 0) {
delayMicroseconds(_postDelay);
}
if (_dePin != -1) {
digitalWrite(_dePin, LOW);
}
// ...
}
On AVR cores, HardwareSerial::flush() polls the UART data-register-empty and the transmit-complete flag, so DE drops cleanly after the last byte clears the shift register. On the mbed core, arduino::UART::flush() (and the underlying mbed::SerialBase) only wait for the software TX buffer to drain; the hardware shift register is still clocking when the call returns.
The current default of _postDelay = 0 therefore works on AVR but breaks on mbed.
Suggested fixes (in order of preference)
-
Block on hardware TX-complete inside endTransmission() on mbed.
Use the mbed SerialBase API or write directly to the peripheral's TXC flag rather than relying on Serial1.flush() semantics. This is the correct, baud-rate-independent fix.
-
Set a sane default _postDelay for mbed/Opta boards.
Computed from baud rate, e.g. one character time + margin:
#if defined(ARDUINO_OPTA) || defined(ARDUINO_PORTENTA_H7_M7) || \
defined(ARDUINO_NANO33BLE)
// 11 bits/char @ baud, in microseconds, plus 10% margin
_postDelay = (11UL * 1100000UL) / baudrate;
#endif
At 9600 baud this is ~1260 µs (matches our empirically-validated 1200 µs); at 115200 it is ~105 µs.
-
At minimum: document this in the README under a "Known issues" or "Board-specific notes" section so users find it before the bug-hunt.
Workaround for current users
After ModbusRTUClient.begin() (which re-initializes the serial port and overwrites RS485 settings), call:
RS485.setDelays(0, 1200); // 1200 µs covers 9600 8N1 (1042 µs) and 8N2 (1146 µs)
Note: it must be called after ModbusRTUClient.begin(), not before, because begin() re-applies its own RS485 configuration.
References
Environment
- arduino-cli 1.x
- Core:
arduino:mbed_opta (latest)
- Library: ArduinoRS485 (latest from master)
- Library: ArduinoModbus (latest from master)
- Board: Arduino Opta WiFi (AFX00002), FQBN
arduino:mbed_opta:opta
On boards using the Arduino mbed core (Arduino Opta WiFi, Opta RS485, Portenta H7, Nano 33 BLE, etc.),
RS485.endTransmission()drops the DE (driver-enable) line before the UART has finished clocking the final byte out of the shift register. The result: the last byte of every transmission is truncated on the wire, the slave's CRC check fails, and the slave silently rejects the frame.The default
_postDelayof0µs is the trigger. The library does call_serial->flush(), but on the mbed coreSerial1.flush()returns when the TX buffer is empty — before the UART transmit-complete (TXC) flag asserts. DE then drops mid-byte.This affects every Modbus RTU user on the Opta. The workaround is well-known in the community but not discoverable without burning a day on a logic analyzer.
Affected boards (confirmed or strongly suspected)
flush()does not block on TXCReproduction
Minimal sketch (Opta WiFi, any Modbus RTU slave at 9600 8N2 — we used a SunSaver MPPT via Morningstar MRC-1):
Without
setDelays(0, 1200): every poll fails withlastError() == 4(timeout). Logic-analyzer capture shows the final byte of the request is truncated to ~half its normal width.With
setDelays(0, 1200): every poll succeeds.Root cause analysis
In
RS485.cpp:On AVR cores,
HardwareSerial::flush()polls the UART data-register-empty and the transmit-complete flag, so DE drops cleanly after the last byte clears the shift register. On the mbed core,arduino::UART::flush()(and the underlyingmbed::SerialBase) only wait for the software TX buffer to drain; the hardware shift register is still clocking when the call returns.The current default of
_postDelay = 0therefore works on AVR but breaks on mbed.Suggested fixes (in order of preference)
Block on hardware TX-complete inside
endTransmission()on mbed.Use the mbed
SerialBaseAPI or write directly to the peripheral's TXC flag rather than relying onSerial1.flush()semantics. This is the correct, baud-rate-independent fix.Set a sane default
_postDelayfor mbed/Opta boards.Computed from baud rate, e.g. one character time + margin:
At 9600 baud this is ~1260 µs (matches our empirically-validated 1200 µs); at 115200 it is ~105 µs.
At minimum: document this in the README under a "Known issues" or "Board-specific notes" section so users find it before the bug-hunt.
Workaround for current users
After
ModbusRTUClient.begin()(which re-initializes the serial port and overwrites RS485 settings), call:Note: it must be called after
ModbusRTUClient.begin(), not before, because begin() re-applies its own RS485 configuration.References
ModbusServer::writeInputRegistersnot implemented ArduinoModbus#18 — the post that finally pointed atsetDelays()as the missing piecehttps://github.com/SenaxInc/SenaxTankAlarm/blob/master/TankAlarm-112025-Common/src/TankAlarm_Solar.cpp
(see
SolarManager::begin(), line ~110)Environment
arduino:mbed_opta(latest)arduino:mbed_opta:opta