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
- Host Computer: Raspberry Pi 4 (2GB+) or Raspberry Pi 5.
- DIN-Rail Energy Meter: Eastron SDM630 Modbus-V2 (3-phase) or Eastron SDM120 Modbus (single-phase 230V).
- Differential Transceiver: SP3485 (native 3.3V) or an isolated RS-485 HAT (e.g., Waveshare 2-CH RS485 HAT).
- Cabling: 24 AWG shielded twisted pair (Belden 9841 or Cat6 pair) + two 120 Ω 0.25W metal film resistors.
- 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:
Edit /boot/firmware/cmdline.txt to remove console=serial0,115200.
Reboot the host:
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:
Verify service telemetry logs:
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:
- High-Availability K3s Edge Cluster: Deploy a multi-node, self-healing Kubernetes cluster to host Grafana, Prometheus, and time-series databases.
- Mini Weather Station Workshop: Expand your environmental telemetry repertoire with local I2C MEMS sensors.