Skip to content

Workshop: Industrial RS-485 Modbus Energy Telemetry Gateway

In commercial facilities, data centers, and renewable microgrids, real-time power telemetry is essential for load balancing, sub-metering, and anomaly detection. Consumer smart plugs are inadequate for industrial electrical cabinets: they cannot measure 3-phase 400V distribution feeds and lack electrical isolation.

In this enterprise hands-on lab, we transform a Raspberry Pi (Raspberry Pi OS 64-bit Bookworm) into an industrial IoT Energy Telemetry Gateway. We interface a calibrated DIN-rail electrical power meter (Eastron SDM630 / SDM120) over a differential RS-485 bus using Modbus RTU, ingest high-frequency electrical parameters into SQLite (WAL mode), and publish structured telemetry to an MQTT broker for live Grafana visualization.


1. Bill of Materials & Electrical Safety

%%{init: {'flowchart': {'wrappingWidth': 600}}}%%
flowchart TB
    subgraph Cabinet["High-Voltage Electrical Cabinet (400V AC)"]
        GRID["3-Phase Mains Grid (L1, L2, L3, N)"] --> METER["DIN-Rail Energy Meter (Eastron SDM630)"]
        LOAD["Facility Electrical Load"] --- METER
    end

    subgraph Bus["Industrial Fieldbus (RS-485)"]
        METER -->|Differential Twisted Pair A/B + Shield| TRANS["SP3485 3.3V Transceiver"]
        TERM["120 Ω End Termination"] --- TRANS
    end

    subgraph Gateway["Low-Voltage IoT Gateway (Raspberry Pi 4/5)"]
        TRANS -->|UART RX/TX (GPIO 14/15) + DE/RE (GPIO 18)| RPI["Raspberry Pi (Debian 12 Bookworm)"]
        RPI -->|Ingestion Daemon (minimalmodbus)| DB["SQLite WAL Database (/data/energy.db)"]
        RPI -->|MQTT Telemetry (TCP 1883)| MQTT["Central MQTT Broker / Grafana"]
    end

Required Hardware Components

  1. Host Computer: Raspberry Pi 4 (2GB+) or Raspberry Pi 5.
  2. DIN-Rail Energy Meter: Eastron SDM630 Modbus-V2 (3-phase) or Eastron SDM120 Modbus (single-phase 230V).
  3. Differential Transceiver: SP3485 (native 3.3V) or an isolated RS-485 HAT (e.g., Waveshare 2-CH RS485 HAT).
  4. Cabling: 24 AWG shielded twisted pair (Belden 9841 or Cat6 pair) + two 120 Ω 0.25W metal film resistors.
  5. Mains Power: 5V 3A+ or 27W USB-C power supply.

High Voltage Danger (400V / 230V AC)

Mains line voltages are lethal. Installation of the DIN-rail energy meter onto 230V/400V busbars must be performed strictly by a qualified certified electrician with the main feed breaker locked out. The RS-485 terminals (A, B, G) are low-voltage (< 5V) and physically isolated from the mains terminals.


2. Hardware Wiring & Transceiver Interfacing

Connect the SP3485 transceiver module to the Raspberry Pi 40-pin header:

SP3485 Pin Raspberry Pi Pin Function
VCC Pin 1 (3.3V) Logic Power Supply
GND Pin 6 (GND) Digital Ground
TXD (DI) Pin 8 (BCM GPIO 14 / TXD) Serial transmit into transceiver
RXD (RO) Pin 10 (BCM GPIO 15 / RXD) Serial receive from transceiver
DE & RE Pin 12 (BCM GPIO 18) Direction control (HIGH=TX, LOW=RX)
Terminal A Energy Meter Terminal A (+) Differential Data +
Terminal B Energy Meter Terminal B (-) Differential Data -
Shield / G Earth Ground (one end only) Cable shield

Fieldbus Termination

Install a 120 Ω resistor in parallel across terminals A and B directly at the energy meter, and another 120 Ω resistor across terminals A and B at the transceiver.


3. Host System Configuration (Kernel UART & Packages)

Step 1: Dedicate PL011 UART to GPIO 14/15

Ensure the full-featured PL011 hardware UART is allocated to the header pins and remove the kernel serial console:

Edit /boot/firmware/config.txt:

enable_uart=1
dtoverlay=disable-bt

Edit /boot/firmware/cmdline.txt to remove console=serial0,115200.

Reboot the host:

sudo reboot

Step 2: Install Python Libraries

Install Python serial, Modbus, SQLite, and MQTT client dependencies:

sudo apt update
sudo apt install -y python3-serial python3-pip sqlite3 mosquitto-clients
pip3 install --break-system-packages minimalmodbus paho-mqtt

4. Modbus RTU Register Mapping (Eastron SDM Series)

The Eastron SDM series communicates via Modbus RTU over RS-485 with factory defaults: Baud rate 9600, 8 data bits, no parity, 1 stop bit (8N1), and Slave ID 1.

Measurements are represented as 32-bit IEEE 754 Floating-Point numbers occupying two consecutive 16-bit input registers (accessed using Function Code 0x04):

Parameter Register Address (Hex) Register Count Data Type Units
Line 1 Voltage 0x0000 2 32-bit Float Volts (V)
Line 2 Voltage 0x0002 2 32-bit Float Volts (V)
Line 3 Voltage 0x0004 2 32-bit Float Volts (V)
Line 1 Current 0x0006 2 32-bit Float Amperes (A)
Total Active Power 0x0034 2 32-bit Float Watts (W)
Grid Frequency 0x0046 2 32-bit Float Hertz (Hz)
Total Import Energy 0x0048 2 32-bit Float Kilowatt-hours (kWh)

5. Ingestion Engine Implementation: Modbus to SQLite & MQTT

Create the production ingestion daemon: /usr/local/bin/energy_telemetry_gateway.py:

#!/usr/bin/env python3
"""
Industrial Modbus RTU Energy Telemetry Gateway
Polls electrical parameters from Eastron SDM630 via RS-485,
logs atomically to SQLite (WAL mode), and broadcasts JSON to MQTT.
"""

import time
import json
import sqlite3
import logging
import minimalmodbus
import serial
import paho.mqtt.client as mqtt

# Configure logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")

SERIAL_PORT = "/dev/serial0"
SLAVE_ADDRESS = 1
BAUD_RATE = 9600
DB_FILE = "/data/energy_telemetry.db"
MQTT_BROKER = "192.168.10.10"
MQTT_PORT = 1883
MQTT_TOPIC = "industrial/power/cabinet_01"

class EnergyGateway:
    def __init__(self):
        # Initialize Modbus Instrument
        self.meter = minimalmodbus.Instrument(SERIAL_PORT, SLAVE_ADDRESS)
        self.meter.serial.baudrate = BAUD_RATE
        self.meter.serial.bytesize = 8
        self.meter.serial.parity = serial.PARITY_NONE
        self.meter.serial.stopbits = 1
        self.meter.serial.timeout = 0.5
        self.meter.mode = minimalmodbus.MODE_RTU
        self.meter.clear_buffers_before_each_transaction = True

        # Initialize SQLite database in WAL mode
        self._init_database()

        # Initialize MQTT client
        self.mqtt_client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, "rpi-energy-gateway")
        try:
            self.mqtt_client.connect(MQTT_BROKER, MQTT_PORT, keepalive=60)
            self.mqtt_client.loop_start()
            logging.info(f"Connected to MQTT broker at {MQTT_BROKER}:{MQTT_PORT}")
        except Exception as err:
            logging.warning(f"Could not connect to MQTT broker ({err}). Operating in local-only mode.")

    def _init_database(self) -> None:
        conn = sqlite3.connect(DB_FILE)
        cursor = conn.cursor()
        cursor.execute("PRAGMA journal_mode=WAL;")
        cursor.execute("""
            CREATE TABLE IF NOT EXISTS electrical_readings (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
                voltage_l1 REAL,
                voltage_l2 REAL,
                voltage_l3 REAL,
                current_l1 REAL,
                total_power_w REAL,
                frequency_hz REAL,
                import_kwh REAL
            );
        """)
        conn.commit()
        conn.close()
        logging.info(f"Initialized SQLite database at {DB_FILE} with PRAGMA journal_mode=WAL")

    def read_metrics(self) -> dict:
        """Reads 32-bit float parameters using Modbus Function Code 0x04."""
        v_l1 = self.meter.read_float(0x0000, functioncode=4, number_of_registers=2)
        v_l2 = self.meter.read_float(0x0002, functioncode=4, number_of_registers=2)
        v_l3 = self.meter.read_float(0x0004, functioncode=4, number_of_registers=2)
        i_l1 = self.meter.read_float(0x0006, functioncode=4, number_of_registers=2)
        power = self.meter.read_float(0x0034, functioncode=4, number_of_registers=2)
        freq = self.meter.read_float(0x0046, functioncode=4, number_of_registers=2)
        kwh = self.meter.read_float(0x0048, functioncode=4, number_of_registers=2)

        return {
            "timestamp": int(time.time()),
            "voltage_l1": round(v_l1, 1),
            "voltage_l2": round(v_l2, 1),
            "voltage_l3": round(v_l3, 1),
            "current_l1": round(i_l1, 2),
            "total_power_w": round(power, 1),
            "frequency_hz": round(freq, 2),
            "import_kwh": round(kwh, 2)
        }

    def persist_and_publish(self, data: dict) -> None:
        # 1. Write to SQLite
        conn = sqlite3.connect(DB_FILE)
        cursor = conn.cursor()
        cursor.execute("""
            INSERT INTO electrical_readings (voltage_l1, voltage_l2, voltage_l3, current_l1, total_power_w, frequency_hz, import_kwh)
            VALUES (?, ?, ?, ?, ?, ?, ?);
        """, (data["voltage_l1"], data["voltage_l2"], data["voltage_l3"], data["current_l1"], data["total_power_w"], data["frequency_hz"], data["import_kwh"]))
        conn.commit()
        conn.close()

        # 2. Publish to MQTT
        payload = json.dumps(data)
        self.mqtt_client.publish(MQTT_TOPIC, payload, qos=1)

    def run(self) -> None:
        logging.info("Starting telemetry loop (sampling every 1.0s)...")
        while True:
            try:
                metrics = self.read_metrics()
                self.persist_and_publish(metrics)
                logging.info(f"Telemetry: {metrics['voltage_l1']}V | Load: {metrics['total_power_w']}W | Freq: {metrics['frequency_hz']}Hz | Total: {metrics['import_kwh']} kWh")
            except (minimalmodbus.NoResponseError, minimalmodbus.InvalidResponseError) as err:
                logging.warning(f"Modbus acquisition error: {err}")
            except Exception as err:
                logging.error(f"Unexpected error: {err}")

            time.sleep(1.0)

if __name__ == "__main__":
    gateway = EnergyGateway()
    gateway.run()

6. Systemd Daemonization & 24/7 Supervisor Service

To ensure that the energy gateway automatically starts on boot, restarts upon failure, and runs under a least-privileged service account:

Create /etc/systemd/system/energy-gateway.service:

[Unit]
Description=Industrial RS-485 Modbus Energy Telemetry Gateway
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=root
ExecStart=/usr/bin/python3 /usr/local/bin/energy_telemetry_gateway.py
Restart=always
RestartSec=5s
KillMode=process

# Sandboxing & Hardening
ProtectSystem=full
ProtectHome=true
NoNewPrivileges=true

[Install]
WantedBy=multi-user.target

Enable and start the service:

sudo systemctl daemon-reload
sudo systemctl enable --now energy-gateway.service

Verify service telemetry logs:

sudo journalctl -u energy-gateway.service -f

7. Troubleshooting Guide & Commissioning Checklist

Symptom Probable Root Cause Resolution
NoResponseError Reversed A/B differential wiring Invert wires on Terminal A and Terminal B.
InvalidResponseError (CRC Mismatch) Missing 120 Ω termination or baud mismatch Install 120 Ω resistors at cable ends; verify meter baud is set to 9600.
Permission Denied on /dev/serial0 User account not in dialout group Run sudo usermod -aG dialout $USER.
Zero or Negative Power readings Inverted Current Transformer (CT) polarity Verify CT arrow points toward load, not incoming grid feed.

Next Steps

With your industrial fieldbus telemetry operational, complete your infrastructure skills: