XStore

Modbus-to-MQTT Bridge on ESP32

Updated on September 28, 2026

6 min read

Introduction #

Most industrial meters, drives, sensors and PLCs speak Modbus RTU over RS485, while modern dashboards, cloud platforms and SCADA systems expect MQTT. A Modbus-to-MQTT bridge sits between the two: it polls the Modbus device on a schedule and publishes each value to an MQTT topic that anything else on the network can subscribe to.

The NORVI AGENT 1-BM01-ES-L is well suited to this job. It combines an ESP32 with a built-in RS485 port and a DIN-rail industrial enclosure, so the gateway can be installed in a panel without extra converter boards. In this tutorial you will build a complete, working bridge and then harden it for real deployments.

What you will build: a gateway that reads holding registers from a Modbus RTU slave every few seconds, scales the values, and publishes them to an MQTT broker with retained messages, a Last Will status topic and error reporting.

What You Need #

ItemDetails
NORVI AGENT 1-BM01-ES-LESP32 controller with RS485, programmed over the mini USB port
Modbus RTU slaveAny RS485 meter or sensor (e.g. temperature/humidity transmitter). Note its slave ID, baud rate and register addresses
Power supplyDC supply within the range given in the datasheet
MQTT brokerMosquitto, EMQX, HiveMQ or a cloud broker reachable from the gateway
SoftwareArduino IDE with ESP32 board package; libraries ModbusMaster and PubSubClient
CableTwisted-pair cable for RS485 A/B; 120 ohm resistor for long runs

Hardware Overview #

Programming uses the mini USB port and any ESP32-capable IDE. The RS485 transceiver needs a flow-control pin that switches it between transmit and receive, which the firmware toggles around each Modbus request. The pins used in this tutorial are below.

FunctionESP32 GPIONotes
RS485 RX23Serial1 receive
RS485 TX13Serial1 transmit
RS485 flow control32HIGH = transmit, LOW = receive
Front button35Optional: use for a local reset or test
Status LED (NeoPixel)25Optional: show connection state

Verify before flashing: Confirm each of these GPIO numbers against the GPIO allocation table in the NORVI AGENT 1-BM01-ES-L datasheet for your exact unit, and change the #define lines in the code if they differ.

Wiring #

RS485 connection #

Gateway terminalModbus devicePurpose
RS485 A (D+)A (D+)Differential data line
RS485 B (D-)B (D-)Differential data line
GND / commonGND (if provided)Common reference
  1. Power the gateway and the Modbus device with the supply switched off.
  2. Connect A to A and B to B. Vendors label these differently, so if you get no response, swap the pair.
  3. Use twisted-pair cable and keep the run as a single daisy chain, not a star.
  4. On runs longer than a few metres or at high baud rates, place a 120 ohm resistor across A and B at the far end.
  5. Connect the gateway to your PC with the mini USB cable for programming.

Tip: Set the Modbus device to the baud rate and parity used in the code (9600, 8N1 here) and make sure its slave ID matches SLAVE_ID.

Software Setup #

  1. Install the Arduino IDE and add the ESP32 board package through the Boards Manager.
  2. Select the ESP32 module used on your unit (the ES-L variant uses an ESP32-WROVER-B) and the correct COM port.
  3. In Library Manager install ModbusMaster (by Doc Walker) and PubSubClient (by Nick O’Leary).

If upload fails to start, hold the boot button while the IDE shows Connecting, as described in the device’s user guide.

Design: Topics and Payloads #

A predictable topic structure is what makes a bridge reusable. Every gateway publishes under its own device ID, with one topic per data point:

Copy
factory/agent1-001/status          -> online | offline   (retained, Last Will)
factory/agent1-001/temperature     -> 24.60              (retained)
factory/agent1-001/humidity        -> 58.20              (retained)
factory/agent1-001/error           -> error 0xE2         (Modbus failure code)
  • Retained messages let a new subscriber see the last known value immediately.
  • Last Will makes the broker publish offline if the gateway loses power or network, so dashboards can show a fault instead of stale data.
  • Table-driven register map means supporting a new device only requires editing a few rows, not the logic.

The Firmware #

The complete sketch is below. Edit the site settings and register map, then upload. It polls each register, applies a scale factor (for example a raw value of 246 with scale 0.1 becomes 24.6), and publishes the result.

Copy
#include <WiFi.h>
#include <PubSubClient.h>
#include <ModbusMaster.h>

// ---- RS485 pins (verify against your unit's GPIO table) ----
#define RS485_RX 23
#define RS485_TX 13
#define RS485_FC 32          // flow-control (DE/RE)

// ---- Site settings ----
const char* WIFI_SSID = "YourSSID";
const char* WIFI_PASS = "YourPassword";
const char* MQTT_HOST = "broker.example.com";
const int   MQTT_PORT = 1883;
const char* DEVICE_ID = "agent1-001";
const uint8_t  SLAVE_ID = 1;
const uint32_t POLL_MS = 5000;

// ---- Register map: add rows to add data points ----
struct Reg { const char* name; uint16_t addr; float scale; };
Reg regs[] = {
  {"temperature", 0, 0.1},
  {"humidity",    1, 0.1},
};
const int NREGS = sizeof(regs) / sizeof(regs[0]);

WiFiClient net;
PubSubClient mqtt(net);
ModbusMaster node;
char tBase[64], tStatus[72];
uint32_t lastPoll = 0;

void preTx()  { digitalWrite(RS485_FC, HIGH); }
void postTx() { digitalWrite(RS485_FC, LOW);  }

void connectWifi() {
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  while (WiFi.status() != WL_CONNECTED) delay(300);
}

void connectMqtt() {
  while (!mqtt.connected()) {
    // Last Will: broker publishes "offline" if we vanish
    if (mqtt.connect(DEVICE_ID, nullptr, nullptr, tStatus, 1, true, "offline")) {
      mqtt.publish(tStatus, "online", true);
    } else delay(2000);
  }
}

void setup() {
  Serial.begin(115200);
  pinMode(RS485_FC, OUTPUT);
  digitalWrite(RS485_FC, LOW);
  Serial1.begin(9600, SERIAL_8N1, RS485_RX, RS485_TX);
  node.begin(SLAVE_ID, Serial1);
  node.preTransmission(preTx);
  node.postTransmission(postTx);

  snprintf(tBase, sizeof(tBase), "factory/%s", DEVICE_ID);
  snprintf(tStatus, sizeof(tStatus), "factory/%s/status", DEVICE_ID);
  connectWifi();
  mqtt.setServer(MQTT_HOST, MQTT_PORT);
}

void loop() {
  if (WiFi.status() != WL_CONNECTED) connectWifi();
  if (!mqtt.connected()) connectMqtt();
  mqtt.loop();

  if (millis() - lastPoll >= POLL_MS) {
    lastPoll = millis();
    for (int i = 0; i < NREGS; i++) {
      uint8_t r = node.readHoldingRegisters(regs[i].addr, 1);
      char topic[96], payload[48];
      snprintf(topic, sizeof(topic), "%s/%s", tBase, regs[i].name);
      if (r == node.ku8MBSuccess) {
        int16_t raw = (int16_t)node.getResponseBuffer(0);
        snprintf(payload, sizeof(payload), "%.2f", raw * regs[i].scale);
        mqtt.publish(topic, payload, true);
      } else {
        snprintf(payload, sizeof(payload), "error 0x%02X", r);
        char et[112];
        snprintf(et, sizeof(et), "%s/error", tBase);
        mqtt.publish(et, payload);
      }
    }
  }
}

How it works #

  • preTx / postTx raise and lower the flow-control pin so the transceiver transmits only while the request is going out.
  • readHoldingRegisters performs Modbus function 0x03. The return code is checked, and any failure is published to the error topic instead of a bad number.
  • Reconnect logic in loop() restores Wi-Fi and MQTT automatically after an outage.

Note: Some devices use input registers (function 0x04) or 32-bit values across two registers. Use readInputRegisters() or combine two response words when your device manual calls for it.

Testing #

Watch the data on the broker #

From any computer that can reach the broker, subscribe to everything the gateway publishes:

mosquitto_sub -h broker.example.com -t "factory/agent1-001/#" -v

Within one poll interval you should see status online followed by your values, for example factory/agent1-001/temperature 24.60.

Confirm the Last Will #

Unplug the gateway. After the broker’s keep-alive timeout, the status topic changes to offline. Reconnect it and it returns to online.

Confirm error handling #

Disconnect the RS485 cable. The error topic should report a timeout code (0xE2) and the last good values remain retained until the link is restored.

Turning It into a Product #

A sketch that works on the bench is not yet a product. These steps close the gap between a demo and something you can ship to customers.

AreaWhat to add
ConfigurationStore Wi-Fi, broker, slave ID, baud rate and register map in flash (Preferences or LittleFS) and edit them through a captive web page, so nothing is hard-coded per customer.
ReliabilityEnable the ESP32 task watchdog, retry with back-off, and add a scheduled reboot if MQTT cannot reconnect for a long period.
SecurityUse TLS (WiFiClientSecure) with a per-device username and password, and restrict each device to its own topic tree with broker ACLs.
ConnectivityWhere Wi-Fi is not available, publish over the cellular modem instead. Use TinyGSM with the modem UART pins from the datasheet; the MQTT and Modbus code stays the same.
UpdatesAdd OTA firmware updates so fixes can be delivered remotely.
DiscoveryPublish Home Assistant style discovery messages, or a JSON payload with a timestamp, so platforms configure themselves.
Multi-deviceLoop over several slave IDs on the same RS485 bus, each with its own register map and topic prefix.

Troubleshooting #

SymptomLikely cause and fix
Error 0xE2 (timeout)Wrong slave ID or baud rate, A/B swapped, or flow-control pin incorrect. Swap A/B first.
Error 0x02 (illegal address)Register address is wrong. Many manuals count from 1, while the library counts from 0.
Garbage or unstable valuesWrong scale, signed vs unsigned, or 32-bit values split across two registers.
Connects, then drops from brokerDuplicate client ID. Give every gateway a unique DEVICE_ID.
Upload failsHold the boot button during upload and confirm the correct COM port.

Conclusion #

You now have a working Modbus RTU to MQTT gateway on the NORVI AGENT 1-BM01-ES-L, with clean topics, retained values, offline detection and error reporting. From here, the productization steps turn it into a configurable, secure and updatable device that can be deployed across many sites with only a change of settings.