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 #
| Item | Details |
|---|---|
| NORVI AGENT 1-BM01-ES-L | ESP32 controller with RS485, programmed over the mini USB port |
| Modbus RTU slave | Any RS485 meter or sensor (e.g. temperature/humidity transmitter). Note its slave ID, baud rate and register addresses |
| Power supply | DC supply within the range given in the datasheet |
| MQTT broker | Mosquitto, EMQX, HiveMQ or a cloud broker reachable from the gateway |
| Software | Arduino IDE with ESP32 board package; libraries ModbusMaster and PubSubClient |
| Cable | Twisted-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.
| Function | ESP32 GPIO | Notes |
|---|---|---|
| RS485 RX | 23 | Serial1 receive |
| RS485 TX | 13 | Serial1 transmit |
| RS485 flow control | 32 | HIGH = transmit, LOW = receive |
| Front button | 35 | Optional: use for a local reset or test |
| Status LED (NeoPixel) | 25 | Optional: 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 terminal | Modbus device | Purpose |
|---|---|---|
| RS485 A (D+) | A (D+) | Differential data line |
| RS485 B (D-) | B (D-) | Differential data line |
| GND / common | GND (if provided) | Common reference |
- Power the gateway and the Modbus device with the supply switched off.
- Connect A to A and B to B. Vendors label these differently, so if you get no response, swap the pair.
- Use twisted-pair cable and keep the run as a single daisy chain, not a star.
- 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.
- 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 #
- Install the Arduino IDE and add the ESP32 board package through the Boards Manager.
- Select the ESP32 module used on your unit (the ES-L variant uses an ESP32-WROVER-B) and the correct COM port.
- 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:
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.
#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/#" -vWithin 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.
| Area | What to add |
|---|---|
| Configuration | Store 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. |
| Reliability | Enable the ESP32 task watchdog, retry with back-off, and add a scheduled reboot if MQTT cannot reconnect for a long period. |
| Security | Use TLS (WiFiClientSecure) with a per-device username and password, and restrict each device to its own topic tree with broker ACLs. |
| Connectivity | Where 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. |
| Updates | Add OTA firmware updates so fixes can be delivered remotely. |
| Discovery | Publish Home Assistant style discovery messages, or a JSON payload with a timestamp, so platforms configure themselves. |
| Multi-device | Loop over several slave IDs on the same RS485 bus, each with its own register map and topic prefix. |
Troubleshooting #
| Symptom | Likely 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 values | Wrong scale, signed vs unsigned, or 32-bit values split across two registers. |
| Connects, then drops from broker | Duplicate client ID. Give every gateway a unique DEVICE_ID. |
| Upload fails | Hold 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.
