all posts

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.

By Akshay Sarode· December 4, 2025· 12 min readesp32modbusrs485industrial

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 pinESP32 pinNotes
VCC3.3 V(3.3 V if using MAX3485; 5 V if MAX485 with logic shifter)
GNDGND
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 4Tied to RE; high to transmit, low to receive
RE (Receive Enable)GPIO 4Same pin as DE — tied together
Abus A (D+)One twisted-pair conductor
Bbus 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