Preview build — page text is a draft awaiting human review. Code badges come from real compiles. The flasher is real but not yet hardware-tested; the Playground is still a preview.
Modbus Energy Meter on Wi-Fi: the finished build
ESP32-C6Home automation230 V

Read a DIN-rail energy meter over Modbus RS-485 and show it on Wi-Fi (ESP32-C6)

Your meter already measures everything — this reads it over RS-485 and puts it on your phone.

Difficulty
Medium
Build time
2 h (plus the electrician’s meter installation)
Parts cost
₹4,102 · $48
Verified — core esp32 3.3.12, ModbusMaster 2.0.1, 3 Nov 2026Compile-only

Community draft — report a mistake

Mains voltage (230 V) — can kill. The low-voltage side (board, sensors, display, relay module) is safe to build yourself. The mains side must be wired by a licensed electrician, and tested with the circuit switched off at the MCB.

  • Never switch a motor with a relay module. Motors pull several times their running current at start-up; hobby relay contacts weld or overheat. The relay only drives a contactor’s coil (or your pump starter’s coil input).
  • Contactor rated for motors (AC-3) at or above the load’s nameplate current.
  • Protect and earth: MCB on live, RCCB/ELCB on the circuit, metal bodies earthed. Switch live only — never neutral or earth.
  • Keep the two worlds apart: mains and low-voltage wiring in separate compartments of a closed, non-metallic enclosure; no bare 230 V terminals.
  • Fail safe: the code switches the load off when a sensor stops answering. Still fit a manual override at the starter.

Parts list

PartQtyApprox.Buy
ESP32-C6-DevKitC-11₹800Buy ESP32-C6-DevKitC-1
MAX485 TTL-to-RS485 module1₹60Buy MAX485 TTL-to-RS485 module
1 kΩ + 2 kΩ resistors (divider)1₹2Buy 1 kΩ + 2 kΩ resistors (divider)
Single-phase DIN-rail meter with Modbus RS-485 (Eastron SDM120-Modbus or similar), installed by an electrician1₹3,000Buy Single-phase DIN-rail meter with Modbus RS-485 (Eastron SDM120-Modbus or similar), installed by an electrician
Twisted-pair cable (CAT5/6 offcut) for A/B, up to tens of metres1₹60Buy Twisted-pair cable (CAT5/6 offcut) for A/B, up to tens of metres
5 V USB adapter + cable for the C61₹180Buy 5 V USB adapter + cable for the C6
Total₹4,102

Affiliate disclosure: “Buy” links may earn FlashReady a small commission at no extra cost to you. Prices are indicative.

Wiring

ESP32-C6-DevKitC-1Only the pins used are shown · 3.3 V logicMAX485 TTL to RS485 module1k│2k6 · 67 · 72 · 25VGNDROREDEDIVCCB → Energy meter RS-485 terminals (−)A → Energy meter RS-485 terminals (+)GND
Every connection
ESP32-C6-DevKitC-1MAX485 TTL to RS485 moduleWireNotes
—A—To Energy meter RS-485 terminals (+) (not drawn)
—B—To Energy meter RS-485 terminals (−) (not drawn)
6ROgreenVia 1 kΩ / 2 kΩ divider — RO is a 5 V output; the divider brings it to 3.3 V for GPIO6 (Serial1 RX)
7DIblueGPIO7 — Serial1 TX (3.3 V is a valid HIGH for the MAX485)
2DEorangeGPIO2 drives DE and RE together: HIGH = transmit, LOW = listen
2REorange
5VVCCredThe MAX485 is a 5 V chip
GNDGNDblackAlso run this GND to the meter’s RS-485 ground/shield terminal if it has one

Board quirks: The ESP32-C6 never touches 230 V. It only connects to the meter’s low-voltage RS-485 terminals (A, B and sometimes GND). Fitting the meter into the distribution board — after the MCB, with the RCCB and earth in place — is electrician-only work. The sketch uses the <strong>Eastron SDM120</strong> register map: input registers (function 04), each value a 32-bit float, high word first (0x0000 V, 0x0006 A, 0x000C W, 0x001E PF, 0x0046 Hz, 0x0156 kWh). Other meters (Selec, Chint, L&T) use different registers and sometimes integers — check your meter’s Modbus manual and change <code>poll()</code>. RO on the MAX485 is a 5 V output, so it reaches GPIO6 through a 1 kΩ / 2 kΩ divider. DI, DE and RE accept the C6’s 3.3 V as HIGH. Serial1 is mapped to GPIO6/7 — the C6 can route a UART to almost any pin — keeping away from the strapping pins 4, 5, 8, 9, 15 and the USB pins 12/13. For long runs, use twisted pair for A/B and fit a 120 Ω terminator at the far end (many meters have one built in, as do some MAX485 boards).

Code

modbus-energy-meter-wifi.inoArduino C++Open in PlaygroundOpen in PlaygroundFlash
// Modbus energy meter on Wi-Fi — ESP32-C6-DevKitC-1 + MAX485, reading a DIN-rail meter
// (Eastron SDM120-Modbus register map) over RS-485 every 10 s and serving the numbers as a
// web page and JSON at http://<board-ip>/ (and /json for Home Assistant's REST sensor).
// The meter is installed in the distribution board by an electrician; the C6 only touches the
// meter's low-voltage RS-485 terminals (A/B), never 230 V.
// MAX485: RO -> 1k/2k divider -> GPIO6 (RX), DI <- GPIO7 (TX), DE + RE <- GPIO2, VCC 5V.
// Meter defaults: slave ID 1, 9600 baud, 8N1 (check your meter's menu).
#include <WiFi.h>
#include <WebServer.h>
#include <ModbusMaster.h>
#include "fr_settings.h"

const char* WIFI_SSID = "YOUR_SSID";
const char* WIFI_PASS = "YOUR_PASSWORD";
FrSetting SETTINGS[] = {{"wifi_ssid", &WIFI_SSID, true}, {"wifi_pass", &WIFI_PASS, true}};

constexpr int RX_PIN = 6, TX_PIN = 7, DIR_PIN = 2;
constexpr uint8_t SLAVE_ID = 1;
constexpr uint32_t BAUD = 9600;

ModbusMaster meter;
WebServer web(80);
struct {
  float v, a, w, pf, hz, kwh;
  bool ok;
  uint32_t at;
} now = {};

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

// SDM120 input registers (function 04) hold IEEE-754 floats, high word first.
bool readFloat(uint16_t reg, float& out) {
  if (meter.readInputRegisters(reg, 2) != meter.ku8MBSuccess) return false;
  uint32_t raw = ((uint32_t)meter.getResponseBuffer(0) << 16) | meter.getResponseBuffer(1);
  memcpy(&out, &raw, 4);
  return true;
}

void poll() {
  bool ok = readFloat(0x0000, now.v) && readFloat(0x0006, now.a) && readFloat(0x000C, now.w) && readFloat(0x001E, now.pf) &&
            readFloat(0x0046, now.hz) && readFloat(0x0156, now.kwh);
  now.ok = ok;
  if (ok) {
    now.at = millis();
    Serial.printf("%.1f V  %.2f A  %.0f W  PF %.2f  %.2f Hz  %.2f kWh\n", now.v, now.a, now.w, now.pf, now.hz, now.kwh);
  } else Serial.println("No reply from the meter - check A/B, slave ID, baud rate and GND");
}

String json() {
  char b[200];
  snprintf(b, sizeof b, "{\"ok\":%s,\"voltage\":%.1f,\"current\":%.2f,\"power\":%.0f,\"pf\":%.2f,\"frequency\":%.2f,\"energy_kwh\":%.2f,\"age_s\":%lu}",
           now.ok ? "true" : "false", now.v, now.a, now.w, now.pf, now.hz, now.kwh, (millis() - now.at) / 1000);
  return b;
}

void page() {
  char b[700];
  snprintf(b, sizeof b,
           "<!doctype html><meta name=viewport content='width=device-width'><meta http-equiv=refresh content=10>"
           "<title>Energy meter</title><style>body{font:20px sans-serif;margin:16px}b{font-size:32px}</style>"
           "<h3>Energy meter %s</h3><p><b>%.0f W</b><p>%.1f V &nbsp; %.2f A &nbsp; PF %.2f &nbsp; %.2f Hz"
           "<p>Total <b>%.2f kWh</b><p><small>updated %lu s ago &middot; <a href=/json>json</a></small>",
           now.ok ? "" : "(no reply!)", now.w, now.v, now.a, now.pf, now.hz, now.kwh, (millis() - now.at) / 1000);
  web.send(200, "text/html", b);
}

void setup() {
  Serial.begin(115200);
  frSettingsBegin(SETTINGS);
  pinMode(DIR_PIN, OUTPUT);
  digitalWrite(DIR_PIN, LOW);
  Serial1.begin(BAUD, SERIAL_8N1, RX_PIN, TX_PIN);
  meter.begin(SLAVE_ID, Serial1);
  meter.preTransmission(preTx);
  meter.postTransmission(postTx);
  if (*WIFI_SSID) {
    WiFi.begin(WIFI_SSID, WIFI_PASS);
    for (int i = 0; i < 40 && WiFi.status() != WL_CONNECTED; i++) delay(250);
    if (WiFi.status() == WL_CONNECTED) Serial.printf("Open http://%s/\n", WiFi.localIP().toString().c_str());
    else Serial.println("Wi-Fi not connected yet - still reading the meter");
  } else Serial.println("Web page off - no Wi-Fi name set. Flash from flashready.in or type it into the code.");
  web.on("/", page);
  web.on("/json", [] { web.send(200, "application/json", json()); });
  web.begin();
}

void loop() {
  static uint32_t last = 0;
  if (millis() - last > 10000 || !last) {
    last = millis();
    poll();
  }
  web.handleClient();
}

Arduino IDE: keep fr_settings.h in the same folder as the .ino (tab menu → New Tab, same name). Flashing from this page doesn’t need it.

fr_settings.hArduino C++
// fr_settings.h — FlashReady on-board settings (Wi-Fi name, phone number, ...) over USB.
//
// Why: the pre-built file on flashready.in is the same for everyone, so your own settings
// can't be inside it. Instead the board asks for them over USB serial and keeps them in
// its EEPROM. If you use Arduino IDE you can still type them into the constants in the
// sketch — this file then changes nothing except a 2-second pause at start-up.
//
// The protocol, in plain words (one text line each way, ending in \n, at 115200 baud):
//  1. Right after Serial.begin(115200) the board prints
//       FR?SETTINGS v1 keys=wifi_ssid,wifi_pass missing=wifi_pass
//     "missing" lists the required settings that still have no real value.
//  2. If nothing is missing it listens for 2 seconds and then carries on. If something is
//     missing it keeps listening, printing the line again every 2 seconds, for up to
//     30 seconds; then it carries on anyway and the sketch skips whatever needs the
//     missing value (an alarm still sounds, it just can't send the SMS).
//  3. The flasher answers   FR!SET wifi_ssid=My%20Home&wifi_pass=secret
//     (values URL-encoded). The board stores them and answers FR!OK, then carries on with
//     the new values. Errors: FR!ERR unknown <key>, FR!ERR long <key> (nothing is stored).
//     A key that is left out keeps its stored value; "key=" (empty) removes it.
//     "FR!SET" with nothing after it means "carry on now": FR!OK, nothing is stored.
//  4. FR!CLEAR wipes everything that was stored, answers FR!OK and keeps listening.
//  Other lines are ignored.
//
// A stored value wins over the constant in the sketch. A constant that is still a
// placeholder ("YOUR_SSID", "your-wifi", "+91XXXXXXXXXX") or empty counts as not set.
//
// EEPROM block at FR_SETTINGS_ADDR: 'F' 'R' 0x01, length (uint16, low byte first), then
// "key=value\n" lines. Uses FR_SETTINGS_SIZE bytes; move FR_SETTINGS_ADDR with a #define
// before the #include if your sketch uses EEPROM for something else.
//
// Use in a sketch:
//   const char* WIFI_SSID = "YOUR_SSID";
//   FrSetting SETTINGS[] = { {"wifi_ssid", &WIFI_SSID, true} };   // key, constant, required
//   void setup() { Serial.begin(115200); frSettingsBegin(SETTINGS); ... WiFi.begin(WIFI_SSID, ...); }
// After frSettingsBegin() each constant points at the value to use ("" if none is set).
#pragma once
#include <Arduino.h>
#include <EEPROM.h>
#include <string.h>

#ifndef FR_SETTINGS_ADDR
#define FR_SETTINGS_ADDR 0
#endif
#ifndef FR_SETTINGS_SIZE
#if defined(__AVR__)
#define FR_SETTINGS_SIZE 96  // 2 KB of RAM on an Uno: a phone number fits easily
#else
#define FR_SETTINGS_SIZE 512
#endif
#endif
#ifndef FR_LINE_MAX
#if defined(__AVR__)
#define FR_LINE_MAX 128
#else
#define FR_LINE_MAX 1024
#endif
#endif
#define FR_MAX_KEYS 8
#define FR_WAIT_MAX_MS 30000UL  // never wait longer than this for missing settings
#define FR_DATA_MAX (FR_SETTINGS_SIZE - 5)

struct FrSetting {
  const char* key;     // [a-z0-9_], as in settings.json
  const char** value;  // the sketch's constant; replaced by the value to use
  bool required;
};

// Stored settings in RAM as "key=value\0key=value\0" (only non-empty values).
static char frData[FR_DATA_MAX + 1];
static uint16_t frLen = 0;

static inline void frEepromBegin() {
#if defined(ESP32) || defined(ESP8266)
  static bool started = false;
  if (!started) EEPROM.begin(FR_SETTINGS_ADDR + FR_SETTINGS_SIZE);
  started = true;
#endif
}

static inline void frLoad() {
  frEepromBegin();
  frLen = 0;
  frData[0] = 0;
  if (EEPROM.read(FR_SETTINGS_ADDR) != 'F' || EEPROM.read(FR_SETTINGS_ADDR + 1) != 'R' || EEPROM.read(FR_SETTINGS_ADDR + 2) != 1) return;
  uint16_t n = EEPROM.read(FR_SETTINGS_ADDR + 3) | (EEPROM.read(FR_SETTINGS_ADDR + 4) << 8);
  if (n > FR_DATA_MAX) return;
  for (uint16_t i = 0; i < n; i++) {
    char c = EEPROM.read(FR_SETTINGS_ADDR + 5 + i);
    frData[i] = c == '\n' ? '\0' : c;
  }
  frData[n] = 0;
  frLen = n;
}

static inline void frPut(int addr, uint8_t b) {
#if defined(__AVR__)
  EEPROM.update(addr, b);
#else
  EEPROM.write(addr, b);
#endif
}

static inline void frSave() {
  frEepromBegin();
  frPut(FR_SETTINGS_ADDR, 'F');
  frPut(FR_SETTINGS_ADDR + 1, 'R');
  frPut(FR_SETTINGS_ADDR + 2, 1);
  frPut(FR_SETTINGS_ADDR + 3, frLen & 0xFF);
  frPut(FR_SETTINGS_ADDR + 4, frLen >> 8);
  for (uint16_t i = 0; i < frLen; i++) frPut(FR_SETTINGS_ADDR + 5 + i, frData[i] ? frData[i] : '\n');
#if defined(ESP32) || defined(ESP8266)
  EEPROM.commit();
#endif
}

// The stored value for key, or nullptr.
static inline const char* frStored(const char* key) {
  size_t kl = strlen(key);
  for (uint16_t i = 0; i < frLen; i += strlen(frData + i) + 1) {
    if (strncmp(frData + i, key, kl) == 0 && frData[i + kl] == '=') return frData + i + kl + 1;
  }
  return nullptr;
}

// Empty, "your_…" / "YOUR-…" / "your …", or a "+91XXXX…" phone number.
static inline bool frIsPlaceholder(const char* v) {
  if (!v || !*v) return true;
  if (strncasecmp(v, "your", 4) == 0 && (v[4] == '_' || v[4] == '-' || v[4] == ' ')) return true;
  return v[0] == '+' && strstr(v, "XXXX") != nullptr;
}

// The stored value for key, else the fallback constant; "" if that is a placeholder.
static inline const char* frSettingsGet(const char* key, const char* fallback) {
  const char* v = frStored(key);
  if (!v) v = fallback;
  return frIsPlaceholder(v) ? "" : v;
}

static inline bool frIsMissing(const FrSetting& s) {
  return s.required && !*frSettingsGet(s.key, *s.value);
}

static inline void frRequest(FrSetting* s, uint8_t n) {
  Serial.print(F("FR?SETTINGS v1 keys="));
  for (uint8_t i = 0; i < n; i++) {
    if (i) Serial.print(',');
    Serial.print(s[i].key);
  }
  Serial.print(F(" missing="));
  bool first = true;
  for (uint8_t i = 0; i < n; i++) {
    if (!frIsMissing(s[i])) continue;
    if (!first) Serial.print(',');
    Serial.print(s[i].key);
    first = false;
  }
  Serial.print('\n');
}

static inline void frReply(const __FlashStringHelper* what, const char* key) {
  Serial.print(what);
  Serial.print(key);
  Serial.print('\n');
}

static inline int8_t frHex(char c) {
  if (c >= '0' && c <= '9') return c - '0';
  c |= 0x20;
  return (c >= 'a' && c <= 'f') ? c - 'a' + 10 : -1;
}

// Percent-decodes in place. Drops NUL, CR and LF: they can't be stored.
static inline void frDecode(char* s) {
  char* o = s;
  for (; *s; s++) {
    char c = *s;
    if (c == '%' && frHex(s[1]) >= 0 && frHex(s[2]) >= 0) {
      c = (char)(frHex(s[1]) << 4 | frHex(s[2]));
      s += 2;
    }
    if (c != '\0' && c != '\n' && c != '\r') *o++ = c;
  }
  *o = 0;
}

// "k1=v1&k2=v2" (cut short if the line overflowed). Saves and returns true, or replies with an error.
static inline bool frSet(FrSetting* s, uint8_t n, char* p, bool overflow) {
  const char* upd[FR_MAX_KEYS] = {};
  while (*p) {
    char* amp = strchr(p, '&');
    bool last = !amp;
    if (amp) *amp = 0;
    char* eq = strchr(p, '=');
    if (eq) *eq = 0;
    if (*p) {
      if (last && overflow) {
        frReply(F("FR!ERR long "), p);
        return false;
      }
      uint8_t i = 0;
      while (i < n && strcmp(s[i].key, p) != 0) i++;
      if (i == n) {
        frReply(F("FR!ERR unknown "), p);
        return false;
      }
      char* v = eq ? eq + 1 : p + strlen(p);  // "key" alone = "key="
      frDecode(v);
      upd[i] = v;
    }
    if (last) break;
    p = amp + 1;
  }
  // Build the new block: for each key, the new value or the one already stored.
  char next[FR_DATA_MAX + 1];
  uint16_t len = 0;
  for (uint8_t i = 0; i < n; i++) {
    const char* v = upd[i] ? upd[i] : frStored(s[i].key);
    if (!v || !*v) continue;
    size_t kl = strlen(s[i].key), vl = strlen(v);
    if (len + kl + vl + 2 > FR_DATA_MAX) {
      frReply(F("FR!ERR long "), s[i].key);
      return false;
    }
    memcpy(next + len, s[i].key, kl);
    next[len + kl] = '=';
    memcpy(next + len + kl + 1, v, vl + 1);
    len += kl + vl + 2;
  }
  memcpy(frData, next, len);
  frData[len] = 0;
  frLen = len;
  frSave();
  return true;
}

// Handles one received line. Returns true once settings were saved (boot carries on).
static inline bool frCommand(FrSetting* s, uint8_t n, char* line, bool overflow) {
  if (strcmp(line, "FR!CLEAR") == 0) {
    frLen = 0;
    frData[0] = 0;
    frSave();
    Serial.print(F("FR!OK\n"));
    return false;
  }
  if (strncmp(line, "FR!SET", 6) != 0 || (line[6] != ' ' && line[6] != 0)) return false;  // not for us
  char* p = line + 6;
  while (*p == ' ') p++;
  if (*p && !frSet(s, n, p, overflow)) return false;  // empty FR!SET = carry on, store nothing
  Serial.print(F("FR!OK\n"));
  return true;
}

// Call right after Serial.begin(115200). Prints the request, waits as described at the top
// (30 s at most), then points every constant in the list at the value to use ("" = not set).
static inline void frSettingsBegin(FrSetting* s, uint8_t n) {
  frLoad();
  char line[FR_LINE_MAX];
  uint16_t len = 0;
  bool overflow = false;
  frRequest(s, n);
  unsigned long start = millis(), quietSince = start;
  while (millis() - start < FR_WAIT_MAX_MS) {
    int c = Serial.read();
    if (c >= 0) {
      quietSince = millis();
      if (c == '\r') continue;
      if (c != '\n') {
        if (len < FR_LINE_MAX - 1) line[len++] = (char)c;
        else overflow = true;
        continue;
      }
      line[len] = 0;
      bool saved = frCommand(s, n, line, overflow);
      len = 0;
      overflow = false;
      if (saved) break;
      continue;
    }
    if (millis() - quietSince < 2000) {
      delay(1);  // also keeps the ESP8266 watchdog fed
      continue;
    }
    len = 0;  // drop half a line
    overflow = false;
    bool missing = false;
    for (uint8_t i = 0; i < n; i++) missing |= frIsMissing(s[i]);
    if (!missing) break;
    frRequest(s, n);
    quietSince = millis();
  }
  for (uint8_t i = 0; i < n; i++) *s[i].value = frSettingsGet(s[i].key, *s[i].value);
}

template <size_t N>
static inline void frSettingsBegin(FrSetting (&s)[N]) {
  static_assert(N <= FR_MAX_KEYS, "fr_settings.h: at most FR_MAX_KEYS settings");
  frSettingsBegin(s, (uint8_t)N);
}

Compiled for esp32c6 on esp32 3.3.12 with ModbusMaster 2.0.1 · 3 Nov 2026

How it works

Many homes, shops, PGs and small offices in India now have a DIN-rail energy meter in the distribution board — to check a tenant’s usage, a solar inverter’s export or an air-conditioner’s consumption. Most of these meters have an RS-485 Modbus port that nobody uses. It already gives voltage, current, power, power factor, frequency and total kWh; all you need is something to ask for them.

The MAX485 module turns the C6’s UART into RS-485, the two-wire differential bus used in factories because it runs hundreds of metres through electrical noise. The C6 is the Modbus master: every 10 seconds it pulls the MAX485’s DE/RE pin HIGH, sends a request to meter address 1, drops the pin LOW and listens for the reply. The ModbusMaster library builds the frames and checks the CRC. Each value comes back as two 16-bit registers that the sketch joins into a 32-bit float.

The results are served by the C6 itself on your Wi-Fi: open the board’s address on a phone for a page that refreshes every 10 seconds, or point Home Assistant’s REST sensor at /json. If the meter stops answering, the page says so instead of showing stale numbers.

The ESP32-C6 suits this always-on job: a cheap board with Wi-Fi 6, a spare UART that can sit on any pin, and plenty of headroom. Nothing in this build touches mains — the meter does the measuring, safely inside the board, installed by an electrician.

Troubleshooting

“No reply from the meter”

Swap A and B first — makers label them differently. Then check the meter’s address (usually 1) and baud rate (9600 on the SDM120 by default) in its menu, and that GND is shared.

Values are huge or nonsense

Your meter isn’t an SDM120 type: its registers or number format differ. Read one register at a time with the MAX485 guide sketch and compare with the manual.

The page doesn’t open

The Serial Monitor prints the address after joining Wi-Fi. Phone and C6 must be on the same network; some routers isolate guest Wi-Fi.

Upgrades

  • Add a REST sensor in Home Assistant pointing at <code>/json</code> and use it in the Energy dashboard.
  • Publish over MQTT, or read up to 30 meters on one RS-485 pair by giving each its own slave ID.
  • Log hourly kWh to flash and draw a day graph on the web page.

↑↓ to move · Enter to open