esp32
Modbus RTU over RS-485 from an ESP32 — the 30-minute version
A practical, no-fluff Modbus RTU walkthrough for the ESP32. MAX485 transceiver, UART2 wiring, DE/RE control, 120Ω termination, holding registers, slave addressing. Real Carel chiller, Schneider PowerLogic, Loxone Air examples.
Modbus RTU over RS-485 from an ESP32 — the 30-minute version
Modbus RTU was published by Modicon in 1979. It is older than the Walkman, predates TCP/IP by four years, and is still the default protocol on roughly two-thirds of the industrial equipment in any European factory built before 2018. If you want to read live data from a Carel chiller controller, a Schneider PowerLogic energy meter, a Loxone Air HVAC controller, or 95% of the variable-frequency drives on the market, you need Modbus RTU. This post gets you from a stock ESP32 dev kit to a working RTU master that's reading holding registers off a real device — in about half an hour.
The short version: an ESP32 plus a $1 MAX485 transceiver, four wires to UART2, a 120 Ω termination resistor, and the ModbusMaster Arduino library. That's it. Everything else is detail.
What you need
- An ESP32 dev board (we use the ESP32-S3-DevKitC-1 from the bench kit, but any ESP32 works).
- A MAX485 transceiver module. The cheap blue boards on AliExpress are fine. The slightly fancier MAX3485 (3.3 V native, no logic-level shifter needed) is worth the extra $0.50.
- A 120 Ω 1/4 W resistor for end-of-line termination.
- A real Modbus RTU slave to talk to. For bench purposes, an Eastron SDM120 single-phase energy meter ($35) is the cheapest way to get genuine Modbus RTU traffic on a desk.
- Two-core shielded twisted pair for the bus itself. Belden 9841 is the spec, but for bench work any two-core cable will do.
Wiring
The MAX485 module has eight pins. You only use six of them:
| MAX485 pin | ESP32 pin | Notes |
|---|---|---|
| VCC | 3.3 V | (3.3 V if using MAX3485; 5 V if MAX485 with logic shifter) |
| GND | GND | |
| RO (Receive Out) | GPIO 16 (UART2 RX) | The byte the bus sent us |
| DI (Driver Input) | GPIO 17 (UART2 TX) | The byte we want to send |
| DE (Driver Enable) | GPIO 4 | Tied to RE; high to transmit, low to receive |
| RE (Receive Enable) | GPIO 4 | Same pin as DE — tied together |
| A | bus A (D+) | One twisted-pair conductor |
| B | bus B (D-) | The other |
The DE and RE pins are tied together because RS-485 is half-duplex — you're either driving the line or listening, never both. We toggle them as a single GPIO before/after each transmit.
The 120 Ω termination resistor goes across the A and B lines at each end of the bus — at the master (your ESP32 + MAX485) and at the last slave on the chain. Not in between. Multiple terminations corrupt the differential signal.
The "right" UART
ESP32 has three UARTs. UART0 is the USB-serial console (do not use it). UART1 is on weird pins by default (don't bother). UART2 (Serial2) is the right answer — clean default pins on most dev boards, no conflict with logging.
#include <Arduino.h>
#include <ModbusMaster.h>
#define RS485_RX 16
#define RS485_TX 17
#define RS485_DE 4
#define MODBUS_BAUD 9600
#define SLAVE_ADDR 1
ModbusMaster bus;
void preTx() { digitalWrite(RS485_DE, HIGH); }
void postTx() { digitalWrite(RS485_DE, LOW); }
void setup() {
Serial.begin(115200);
Serial2.begin(MODBUS_BAUD, SERIAL_8N1, RS485_RX, RS485_TX);
pinMode(RS485_DE, OUTPUT);
digitalWrite(RS485_DE, LOW); // start in receive mode
bus.begin(SLAVE_ADDR, Serial2);
bus.preTransmission(preTx);
bus.postTransmission(postTx);
}
void loop() {
uint8_t result = bus.readHoldingRegisters(0x0000, 4);
if (result == bus.ku8MBSuccess) {
for (int i = 0; i < 4; i++) {
Serial.printf("HR[%d] = %u\n", i, bus.getResponseBuffer(i));
}
} else {
Serial.printf("Modbus error: 0x%02X\n", result);
}
delay(1000);
}
Three things to note about the timing of preTx / postTx. First, ModbusMaster calls preTransmission() before it writes any byte and postTransmission() after the last byte clears the UART transmit buffer — this is correct. Second, the standard says the bus must be silent for 3.5 character times between frames at 9600 baud, that's about 4 ms. ModbusMaster handles this. Third, if you have a slave that's slower than the spec (some old Carel boards are), you may need to delay(20) between requests.
Reading real devices
Three real-world examples, with actual register maps and actual functioning code.
Carel pCO chiller controller
Carel's industrial-refrigeration controller (the pCO5+ and the newer pCO Sistema) speaks Modbus RTU at 19200 baud, 8N2 framing (note the two stop bits, which trips up many libraries). The supervisor address is configurable; default is 1. Reading the chilled-water supply temperature:
Serial2.begin(19200, SERIAL_8N2, RS485_RX, RS485_TX);
// ...
uint8_t r = bus.readInputRegisters(0x0010, 1);
if (r == bus.ku8MBSuccess) {
int16_t raw = (int16_t) bus.getResponseBuffer(0);
float supply_c = raw / 10.0f;
Serial.printf("Supply: %.1f C\n", supply_c);
}
The Carel register map is in the pCO Sistema documentation — the supply temp is at input register 0x0010, scaled by 10 (so a value of 72 means 7.2 °C). Carel makes a habit of these scaling factors, which is why you should always have the device-specific map at hand and not guess.
Schneider PowerLogic PM5560 energy meter
The PowerLogic PM5500 series is the workhorse of European facility energy monitoring. RTU at 19200 baud 8N1, slave address as set on the LCD, default 1. The total active power (kW) is at register 0x0BF3 as a 32-bit float across two registers, big-endian word order:
uint8_t r = bus.readHoldingRegisters(0x0BF3, 2);
if (r == bus.ku8MBSuccess) {
uint16_t hi = bus.getResponseBuffer(0);
uint16_t lo = bus.getResponseBuffer(1);
uint32_t bits = ((uint32_t)hi << 16) | lo;
float kw;
memcpy(&kw, &bits, 4);
Serial.printf("Total kW: %.3f\n", kw);
}
Schneider's word order is big-endian (high word first) — the opposite of what some Eastron meters do. This is the single most common cause of "the meter says 47 but I'm reading 4.7 trillion" bugs. Always check the device docs before assuming.
Loxone Air bridge
Loxone's Modbus extension exposes the Loxone Air mesh as a Modbus slave. Holding register 0x0000 is the temperature for the first paired sensor in the order it was bound; 0x0001 is humidity. Both are int16 scaled by 10. Default 9600 8N1, address 1.
Serial2.begin(9600, SERIAL_8N1, RS485_RX, RS485_TX);
uint8_t r = bus.readHoldingRegisters(0x0000, 2);
if (r == bus.ku8MBSuccess) {
float t = (int16_t) bus.getResponseBuffer(0) / 10.0f;
float h = (int16_t) bus.getResponseBuffer(1) / 10.0f;
Serial.printf("Loxone air sensor: %.1f C, %.1f %% RH\n", t, h);
}
Common failure modes (and the 30-second fix)
You read all 0xFF or all 0x00. Almost always the DE/RE pin is wrong way round, or the A/B pair is swapped. Try swapping A↔B first; it's the cheapest fix.
You get response on the first read, then silence. The post-transmission timing is too tight — the master finished transmitting, switched to receive, but missed the slave's first byte. Add delay(2) in postTx() before pulling DE low, or make sure the UART driver has actually flushed.
Response works for one slave, breaks when you add a second. Either two slaves have the same address, or you have terminations in the middle of the bus, or you're missing terminations at the ends. RS-485 is unforgiving about topology — strict daisy-chain only, terminations only at the two extremes.
Long runs (>50 m) show CRC errors at high baud. Drop to 9600 baud. The bus is louder than you think. RS-485 can technically do 4 km at 9600 but you'll see frame errors well before that on most cabling.
Where this fits in a Sutrace deployment
The bench kit doesn't include the MAX485 — it's a separate $1 part you add when you're ready to talk to real industrial equipment. The Sutrace dashboard treats Modbus channels exactly the same as direct GPIO/ADC channels: each register publishes to its own MQTT subtopic with a Sparkplug B namespace and shows up as a chart. There's no special "Modbus driver" config to write — the firmware handles the protocol, the dashboard handles the rest.
For a mid-market plant rollout you typically end up with 4-12 Modbus RTU slaves per ESP32 master, on a single twisted-pair bus, polling each device every 1-5 seconds depending on register count. That gives you full-fat energy monitoring, HVAC monitoring, and chiller monitoring per ESP32 — at $30 of hardware per master plus $35-200 per slave for the actual instrument. Compare to a $4,000 Cisco IR1101 industrial gateway and a multi-thousand-euro per-tag SCADA license fee.
External references
- how2electronics ESP32 + Modbus RTU — https://how2electronics.com/how-to-use-modbus-rtu-with-esp32-to-read-sensor-data/
- microdigisoft ESP32 + RS485 Modbus — https://microdigisoft.com/esp32-with-modbus-rtu-rs485-protocol-using-arduino-ide/
- NORVI Modbus blog — https://norvi.io/modbus-devices-with-esp32/
- Zbotic Modbus TCP — https://zbotic.in/esp32-modbus-tcp-connect-industrial-plcs-to-iot-cloud/
- esp32io Modbus tutorial — https://esp32io.com/tutorials/esp32-modbus
- ModbusMaster Arduino library — https://github.com/4-20ma/ModbusMaster
- Modbus.org RTU specification — https://modbus.org/docs/Modbus_over_serial_line_V1_02.pdf