An ESP32 Modbus RS485 MQTT gateway lets you pull data out of energy meters, VFDs, temperature transmitters and other Modbus RTU devices and put it on Wi-Fi without a PLC or an industrial PC. In this tutorial you will wire an RS485 transceiver to an ESP32, poll registers from one or more slaves with the ModbusMaster library, scale the raw values and publish them as JSON to an MQTT broker.
What the Gateway Does
Modbus RTU is a polled, master-slave protocol: nothing happens on the bus until a master asks a specific slave for a specific block of registers. MQTT is the opposite, an event-driven publish-subscribe protocol. The ESP32 sits between the two:
- It acts as the Modbus master on the RS485 bus, polling each configured device on a fixed interval.
- It converts raw 16-bit registers into engineering values (volts, degrees, kilowatt-hours) using a register map.
- It acts as an MQTT client, publishing one JSON message per device to a topic such as
connectmq/demo/mb-4d1e/meter1.
Many devices can share one RS485 pair (each with its own slave address), so one ESP32 can serve a small panel full of instruments.
Hardware
| Part | Notes |
|---|---|
| ESP32 DevKit | Uses UART2 for RS485 |
| 3.3 V RS485 transceiver (MAX3485, SP3485) | The classic MAX485 is a 5 V part; prefer a 3.3 V chip |
| Modbus RTU device | Energy meter, temperature/humidity transmitter, VFD... |
| 120 Ω resistor | Termination at the far end of long buses |
| Twisted-pair cable | Shielded twisted pair for anything beyond a bench test |
Some transceiver boards switch direction automatically and expose only RX and TX. Those work too: just leave the DE/RE pin unused in the code below.
Wiring
| Transceiver pin | ESP32 pin | Purpose |
|---|---|---|
| VCC | 3V3 | Power |
| GND | GND | Ground, also connect to the device's signal ground if available |
| RO | GPIO 16 (RX2) | Data from bus to ESP32 |
| DI | GPIO 17 (TX2) | Data from ESP32 to bus |
| DE and RE (tied) | GPIO 4 | High to transmit, low to receive |
| A / B | Device A / B | The bus pair |
The most common wiring mistake is swapped A and B lines. Vendors disagree on which one is "plus", so if you get only timeouts, swap them before debugging anything else. Termination resistors belong at the two physical ends of the bus, not on every device.
Know Your Register Map
Every Modbus device documents its registers in a table: address, function code, data type and scale. Read it carefully, because it answers the questions that cause most gateway bugs:
- Function code: holding registers are read with function 03, input registers with function 04. Asking with the wrong one returns an exception or nothing.
- Address offset: documentation that says "30001" or "40001" usually means register address 0 with function 04 or 03. Some manuals list zero-based addresses, others one-based.
- Scale: many devices send
235meaning 23.5 degrees (scale 0.1). - Data type: 16-bit unsigned, 16-bit signed, or 32-bit values spread across two registers, sometimes with swapped word order.
- Serial settings: baud rate, parity and stop bits must match exactly. 9600 8N1 is common, but 9600 8E1 is the Modbus default in the standard.
The ESP32 Modbus RS485 MQTT Sketch
Install ModbusMaster by Doc Walker and PubSubClient by Nick O'Leary from the Library Manager. The register map at the top is the only part you need to change for your devices. The example polls two devices: a temperature/humidity transmitter at address 1 and an energy meter at address 2.
#include <WiFi.h>
#include <PubSubClient.h>
#include <ModbusMaster.h>
const char* WIFI_SSID = "YOUR_WIFI";
const char* WIFI_PASSWORD = "YOUR_WIFI_PASSWORD";
const char* MQTT_HOST = "broker.connectmq.com";
const uint16_t MQTT_PORT = 1883;
const char* CLIENT_ID = "mb-4d1e-gateway";
const char* TOPIC_PREFIX = "connectmq/demo/mb-4d1e";
const int RS485_RX = 16;
const int RS485_TX = 17;
const int RS485_DE = 4;
const uint32_t BAUD = 9600;
const uint32_t POLL_INTERVAL_MS = 10000;
struct Point {
const char* device; // becomes the topic level
uint8_t slave; // Modbus address
uint8_t fc; // 3 = holding, 4 = input
uint16_t reg; // register address (zero-based)
uint8_t words; // 1 = 16 bit, 2 = 32 bit (high word first)
bool isSigned;
float scale;
const char* name; // JSON key
};
// Edit this table to match your devices' documentation
Point points[] = {
{"climate1", 1, 4, 0x0001, 1, true, 0.1, "temperature_c"},
{"climate1", 1, 4, 0x0002, 1, false, 0.1, "humidity_pct"},
{"meter1", 2, 3, 0x0000, 1, false, 0.1, "voltage_v"},
{"meter1", 2, 3, 0x0001, 1, false, 0.01, "current_a"},
{"meter1", 2, 3, 0x0010, 2, false, 0.01, "energy_kwh"},
};
const size_t NUM_POINTS = sizeof(points) / sizeof(points[0]);
ModbusMaster node;
WiFiClient net;
PubSubClient mqtt(net);
uint32_t lastPoll = 0;
void preTx() { digitalWrite(RS485_DE, HIGH); }
void postTx() { digitalWrite(RS485_DE, LOW); }
bool readPoint(const Point& p, float& out) {
node.begin(p.slave, Serial2);
uint8_t rc = (p.fc == 4) ? node.readInputRegisters(p.reg, p.words)
: node.readHoldingRegisters(p.reg, p.words);
if (rc != node.ku8MBSuccess) {
Serial.printf("slave %u reg 0x%04X error 0x%02X\n", p.slave, p.reg, rc);
return false;
}
if (p.words == 2) {
uint32_t raw = ((uint32_t)node.getResponseBuffer(0) << 16) | node.getResponseBuffer(1);
out = p.isSigned ? (int32_t)raw * p.scale : raw * p.scale;
} else {
uint16_t raw = node.getResponseBuffer(0);
out = p.isSigned ? (int16_t)raw * p.scale : raw * p.scale;
}
return true;
}
void connectWifi() {
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
while (WiFi.status() != WL_CONNECTED) delay(500);
}
void connectMqtt() {
char statusTopic[96];
snprintf(statusTopic, sizeof(statusTopic), "%s/status", TOPIC_PREFIX);
while (!mqtt.connected()) {
if (mqtt.connect(CLIENT_ID, statusTopic, 1, true, "offline")) {
mqtt.publish(statusTopic, "online", true);
} else {
delay(5000);
}
}
}
void pollAndPublish() {
// Group consecutive points that belong to the same device into one JSON message
size_t i = 0;
while (i < NUM_POINTS) {
const char* dev = points[i].device;
String json = "{";
int ok = 0, failed = 0;
while (i < NUM_POINTS && strcmp(points[i].device, dev) == 0) {
float v;
if (readPoint(points[i], v)) {
if (ok > 0) json += ",";
json += "\"" + String(points[i].name) + "\":" + String(v, 2);
ok++;
} else {
failed++;
}
delay(20); // inter-frame gap, helps slow devices
i++;
}
if (ok > 0) json += ",";
json += "\"errors\":" + String(failed) + "}";
char topic[96];
snprintf(topic, sizeof(topic), "%s/%s", TOPIC_PREFIX, dev);
mqtt.publish(topic, json.c_str(), true);
Serial.printf("%s %s\n", topic, json.c_str());
}
}
void setup() {
Serial.begin(115200);
pinMode(RS485_DE, OUTPUT);
digitalWrite(RS485_DE, LOW);
Serial2.begin(BAUD, SERIAL_8N1, RS485_RX, RS485_TX);
node.preTransmission(preTx);
node.postTransmission(postTx);
connectWifi();
mqtt.setServer(MQTT_HOST, MQTT_PORT);
mqtt.setBufferSize(512);
connectMqtt();
}
void loop() {
if (WiFi.status() != WL_CONNECTED) connectWifi();
if (!mqtt.connected()) connectMqtt();
mqtt.loop();
if (millis() - lastPoll >= POLL_INTERVAL_MS) {
lastPoll = millis();
pollAndPublish();
}
}
A few details in this code matter more than they look:
preTransmissionandpostTransmissiondrive the DE pin so the transceiver only talks while the ESP32 transmits. Without them, the transceiver never releases the bus and every read times out.- Each published message carries an
errorscount. A dashboard can then show "device present but some registers failing" instead of silently freezing on old values. - Messages are retained, so a newly connected dashboard immediately sees the last values.
setBufferSize(512)gives room for longer JSON when you add more points.
Check the output:
mosquitto_sub -h broker.connectmq.com -p 1883 -i mb-4d1e-viewer -t "connectmq/demo/mb-4d1e/#" -v
Reading ModbusMaster Error Codes
| Code | Meaning | Typical cause |
|---|---|---|
0xE2 |
Response timed out | Wrong A/B polarity, wrong baud or parity, wrong slave address, DE pin not toggling |
0xE0 |
Invalid slave ID in response | Two devices with the same address, or noise |
0xE3 |
Invalid CRC | Missing termination, long unshielded cable, ground offset |
0x02 |
Illegal data address | Register does not exist; check zero-based vs one-based addressing |
0x01 |
Illegal function | Using function 03 for a register that only answers to 04, or vice versa |
Troubleshooting Checklist for ESP32 Modbus RS485 MQTT
- Confirm serial settings from the device menu or manual: baud, parity, stop bits, address.
- Test one device with a USB-RS485 adapter and a PC Modbus tool before involving the ESP32. If the PC cannot read it, the ESP32 will not either.
- Swap A and B if every request times out.
- Add 120 Ω termination at both ends of buses longer than a few metres, and connect signal ground.
- Keep the poll interval realistic. A dozen registers at 9600 baud take a noticeable fraction of a second; do not poll faster than the bus can answer.
- Read contiguous registers in one request when you can. One request for ten registers is far faster than ten requests for one.
Publishing Less, Not More
Polling every ten seconds and publishing everything is the simplest design, but meters change slowly and cloud traffic adds up. Two improvements fit easily into the sketch: publish a point only when it changes by more than a deadband, and send a full snapshot every few minutes as a heartbeat. That pattern is explained in report by exception. For a gateway-level view of the same problem, including PC-based gateways and larger RTU networks, see Modbus RTU to MQTT.
If MQTT itself is new, start with what an MQTT broker does, and for building JSON with a library rather than string concatenation, the ArduinoJson MQTT guide is a good companion. The ESP32 IoT tutorial covers the Wi-Fi and reconnect foundations used here.
Production Hardening
A gateway on a factory wall runs for years, so treat it like infrastructure:
- TLS and credentials. Use port 8883 with
WiFiClientSecureand credentials issued for this gateway. Never publish plant data to a public test topic. - Watchdog. Enable the task watchdog so a hung Modbus transaction or Wi-Fi stack reboots the board.
- Isolation. In electrically noisy panels, an isolated RS485 transceiver prevents ground loops from damaging the ESP32.
- Read-only by default. Writing registers over MQTT can start motors or change setpoints. If you add writes, restrict them to an allowlist of registers and validate ranges.
- Broker choice. A fully managed MQTT service removes broker patching and certificate renewal from your to-do list, and ConnectMQ's topic explorer is a quick way to confirm every device is reporting.
Get Started with ConnectMQ
Prototype against the public area of broker.connectmq.com, then give your gateway a private broker. ConnectMQ provides TLS endpoints, per-broker credentials, a live topic explorer and a devices view organised by ISA-95 levels for industrial topics. Create a free Serverless broker at app.connectmq.com with no credit card and point the sketch at it.
Frequently Asked Questions
Can one ESP32 read several Modbus devices?
Yes. Give each device a unique slave address on the same RS485 pair and add its registers to the point table. The ESP32 polls them one after another.
Do I need a MAX485 or a MAX3485?
The ESP32 uses 3.3 V logic, so a 3.3 V transceiver such as the MAX3485 or SP3485 is the clean choice. A 5 V MAX485 board can work but needs level care on the RO line.
Why do I only get timeout errors?
Almost always wiring or settings: swapped A/B, wrong baud or parity, wrong slave address, or the DE/RE pin not switching. Test the device with a PC tool first.
Can the gateway write to Modbus registers from MQTT?
ModbusMaster supports writes, so it is possible, but restrict writes to specific registers and validate values. Unchecked remote writes to industrial equipment are a safety risk.



