Industrial Modbus SCADA
Build a lightweight SCADA system to monitor and control PLCs, VFDs, and other Modbus devices. Read holding registers for process values, write coils for remote control, log all data to PostgreSQL, and visualize on a real-time dashboard with gauges and charts.
Flow Architecture
=== READ PATH (Monitoring) ===
[Inject: 1s poll] --> [Modbus Read] --> [Function: Scale Values] --> [PostgreSQL: INSERT] --> [Chart]
(HR 40001-40010) (raw to eng. units) (readings table) |
| [Gauge]
v
[Debug: Values]
=== WRITE PATH (Control) ===
[Dashboard Button] --> [Function: Build Command] --> [Modbus Write]
"Start Pump" (coil address + value) (write coil) What You'll Need
Hardware
- • Raspberry Pi or Linux PC running EdgeFlow
- • Modbus TCP device (PLC, VFD, energy meter) on network
- • Or: RS-485 USB adapter for Modbus RTU devices
- • Ethernet or RS-485 cabling as needed
Software
- • EdgeFlow installed with Modbus module
- • PostgreSQL 14+ installed
- • Device register map (from manufacturer docs)
- • Optional: Modbus simulator for testing (diagslave, ModRSsim2)
Modbus Register Map
The register map defines how raw Modbus values map to real-world process values. This example uses a typical PLC/process controller layout. Adjust addresses and scale factors based on your actual device documentation.
| Register | Name | Data Type | Scale | Unit | Range |
|---|---|---|---|---|---|
| 40001 | Temperature | INT16 | ÷ 10 | °C | -40 to 150 |
| 40002 | Pressure | UINT16 | ÷ 100 | bar | 0 to 25 |
| 40003 | Flow Rate | UINT16 | ÷ 10 | L/min | 0 to 500 |
| 40004 | Tank Level | UINT16 | ÷ 10 | % | 0 to 100 |
| 40005 | Motor Speed | UINT16 | × 1 | RPM | 0 to 3600 |
| 40006 | Motor Current | UINT16 | ÷ 100 | A | 0 to 50 |
| 40007 | Voltage | UINT16 | ÷ 10 | V | 0 to 480 |
| 40008 | Power | UINT16 | × 10 | W | 0 to 50000 |
| 40009 | Run Hours | UINT16 | × 1 | hours | 0 to 65535 |
| 40010 | Status Word | UINT16 | bitmask | -- | Bit 0=Run, 1=Fault, 2=Ready |
Important: Register Addressing
Modbus register 40001 maps to protocol address 0 (zero-based). Some devices use 1-based addressing. Check your device documentation. In EdgeFlow, you configure the protocol address (0-based) and the node handles the offset automatically.
Step-by-Step Setup
Identify Your Modbus Registers
Obtain the register map from your device manufacturer. You need to know the register addresses, data types, and scaling factors. Use the table above as a template and fill in your actual register information.
Configure Network or Serial Connection
For Modbus TCP, ensure the device is reachable on your network. For Modbus RTU, connect the RS-485 adapter and identify the serial port.
# Test Modbus TCP connectivity
ping 192.168.1.100
# For RTU, find the serial port
ls /dev/ttyUSB*
# Typically: /dev/ttyUSB0
# Test with modbus-cli (optional)
# pip install modbus-cli
modbus --host 192.168.1.100 --port 502 read 40001 10 Set Up PostgreSQL
Create the database and table schema for storing SCADA readings:
-- Create database
CREATE DATABASE scada_monitor;
-- Connect and create table
\c scada_monitor;
CREATE TABLE readings (
id SERIAL PRIMARY KEY,
timestamp TIMESTAMPTZ DEFAULT NOW(),
temperature DECIMAL(5,1),
pressure DECIMAL(5,2),
flow_rate DECIMAL(6,1),
tank_level DECIMAL(4,1),
motor_speed INTEGER,
motor_current DECIMAL(5,2),
voltage DECIMAL(5,1),
power INTEGER,
run_hours INTEGER,
status_word INTEGER
);
-- Create index for time-based queries
CREATE INDEX idx_readings_timestamp
ON readings (timestamp DESC);
-- Optional: auto-delete old data (keep 90 days)
CREATE EXTENSION IF NOT EXISTS pg_cron;
SELECT cron.schedule('cleanup-old-readings',
'0 3 * * *',
$$DELETE FROM readings WHERE timestamp < NOW() - INTERVAL '90 days'$$
); Import the Flow
Copy the flow JSON below. In EdgeFlow, go to Menu → Import, paste the JSON, and click Import.
Configure and Deploy
Double-click the Modbus node to set the host/port (TCP) or serial port/baud (RTU). Update the PostgreSQL connection credentials. Configure the register map in the function node if your device uses different addresses. Then click Deploy.
Function Node: Scale Raw Values
This function converts raw Modbus register values to engineering units using the register map configuration:
// Scale raw Modbus register values to engineering units
// Input: msg.payload = array of 10 raw UINT16 values from registers 40001-40010
var raw = msg.payload;
// Register map: [index, name, scale_divisor, unit, signed]
var registerMap = [
[0, "temperature", 10, "C", true ],
[1, "pressure", 100, "bar", false],
[2, "flow_rate", 10, "L/min", false],
[3, "tank_level", 10, "%", false],
[4, "motor_speed", 1, "RPM", false],
[5, "motor_current", 100, "A", false],
[6, "voltage", 10, "V", false],
[7, "power", 0.1, "W", false],
[8, "run_hours", 1, "hours", false],
[9, "status_word", 1, "", false]
];
var scaled = {};
registerMap.forEach(function(reg) {
var idx = reg[0];
var name = reg[1];
var scale = reg[2];
var unit = reg[3];
var signed = reg[4];
var value = raw[idx];
// Handle signed INT16
if (signed && value > 32767) {
value = value - 65536;
}
// Apply scaling
if (scale >= 1) {
scaled[name] = Math.round((value / scale) * 100) / 100;
} else {
scaled[name] = Math.round(value / scale);
}
scaled[name + "_unit"] = unit;
});
// Decode status word bits
var statusWord = raw[9];
scaled.status = {
running: !!(statusWord & 0x01),
fault: !!(statusWord & 0x02),
ready: !!(statusWord & 0x04),
auto: !!(statusWord & 0x08),
remote: !!(statusWord & 0x10)
};
scaled.timestamp = new Date().toISOString();
msg.payload = scaled;
return msg; Configuration Details
| Node | Property | Value | Notes |
|---|---|---|---|
| modbus-read | host | 192.168.1.100 | Modbus device IP |
| modbus-read | port | 502 | Default Modbus TCP port |
| modbus-read | unitId | 1 | Modbus slave address |
| modbus-read | functionCode | 3 (Read Holding) | FC3 for holding registers |
| modbus-read | startAddress | 0 | 0-based (maps to 40001) |
| modbus-read | quantity | 10 | Read 10 consecutive registers |
| postgresql | host | localhost | PostgreSQL server |
| postgresql | database | scada_monitor | Database name |
| inject | interval | 1000ms | Poll every 1 second |
Complete Flow JSON
Copy and import this flow into EdgeFlow via Menu → Import.
{
"name": "Industrial Modbus SCADA",
"nodes": [
{
"id": "inject_poll",
"type": "inject",
"name": "Poll 1s",
"interval": 1,
"intervalUnit": "s",
"repeat": true,
"x": 120,
"y": 180
},
{
"id": "modbus_read",
"type": "modbus-read",
"name": "Read Holding Registers",
"server": "modbus_tcp_server",
"functionCode": "3",
"startAddress": 0,
"quantity": 10,
"x": 340,
"y": 180
},
{
"id": "modbus_tcp_server",
"type": "modbus-tcp-config",
"name": "PLC",
"host": "192.168.1.100",
"port": 502,
"unitId": 1,
"reconnectTimeout": 5000
},
{
"id": "func_scale",
"type": "function",
"name": "Scale to Eng. Units",
"code": "var raw = msg.payload;\nvar registerMap = [[0,'temperature',10,'C',true],[1,'pressure',100,'bar',false],[2,'flow_rate',10,'L/min',false],[3,'tank_level',10,'%',false],[4,'motor_speed',1,'RPM',false],[5,'motor_current',100,'A',false],[6,'voltage',10,'V',false],[7,'power',0.1,'W',false],[8,'run_hours',1,'hours',false],[9,'status_word',1,'',false]];\nvar scaled = {};\nregisterMap.forEach(function(reg) { var value = raw[reg[0]]; if (reg[4] && value > 32767) value -= 65536; scaled[reg[1]] = reg[2] >= 1 ? Math.round((value / reg[2]) * 100) / 100 : Math.round(value / reg[2]); });\nvar sw = raw[9]; scaled.status = { running: !!(sw & 1), fault: !!(sw & 2), ready: !!(sw & 4) };\nscaled.timestamp = new Date().toISOString();\nmsg.payload = scaled;\nreturn msg;",
"x": 580,
"y": 180
},
{
"id": "pg_insert",
"type": "postgresql",
"name": "Log to PostgreSQL",
"host": "localhost",
"port": 5432,
"database": "scada_monitor",
"user": "edgeflow",
"password": "YOUR_PASSWORD",
"query": "INSERT INTO readings (temperature, pressure, flow_rate, tank_level, motor_speed, motor_current, voltage, power, run_hours, status_word) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)",
"queryParams": "payload.temperature,payload.pressure,payload.flow_rate,payload.tank_level,payload.motor_speed,payload.motor_current,payload.voltage,payload.power,payload.run_hours,payload.status_word",
"x": 820,
"y": 140
},
{
"id": "gauge_temp",
"type": "gauge",
"name": "Temperature",
"group": "SCADA Dashboard",
"label": "Temperature",
"unit": "°C",
"min": -40,
"max": 150,
"property": "payload.temperature",
"x": 820,
"y": 220
},
{
"id": "gauge_pressure",
"type": "gauge",
"name": "Pressure",
"group": "SCADA Dashboard",
"label": "Pressure",
"unit": "bar",
"min": 0,
"max": 25,
"property": "payload.pressure",
"x": 820,
"y": 290
},
{
"id": "chart_trend",
"type": "chart",
"name": "Trend Chart",
"group": "SCADA Dashboard",
"chartType": "line",
"label": "Process Trends",
"x": 820,
"y": 360
},
{
"id": "debug_values",
"type": "debug",
"name": "Scaled Values",
"x": 820,
"y": 430
},
{
"id": "btn_start",
"type": "button",
"name": "Start Pump",
"group": "SCADA Dashboard",
"label": "Start Pump",
"color": "#10b981",
"x": 120,
"y": 500
},
{
"id": "btn_stop",
"type": "button",
"name": "Stop Pump",
"group": "SCADA Dashboard",
"label": "Stop Pump",
"color": "#ef4444",
"x": 120,
"y": 570
},
{
"id": "func_write_start",
"type": "function",
"name": "Coil ON",
"code": "msg.payload = { address: 0, value: true };\nreturn msg;",
"x": 340,
"y": 500
},
{
"id": "func_write_stop",
"type": "function",
"name": "Coil OFF",
"code": "msg.payload = { address: 0, value: false };\nreturn msg;",
"x": 340,
"y": 570
},
{
"id": "modbus_write",
"type": "modbus-write",
"name": "Write Coil",
"server": "modbus_tcp_server",
"functionCode": "5",
"x": 580,
"y": 535
}
],
"connections": [
{ "from": "inject_poll", "to": "modbus_read" },
{ "from": "modbus_read", "to": "func_scale" },
{ "from": "func_scale", "to": "pg_insert" },
{ "from": "func_scale", "to": "gauge_temp" },
{ "from": "func_scale", "to": "gauge_pressure" },
{ "from": "func_scale", "to": "chart_trend" },
{ "from": "func_scale", "to": "debug_values" },
{ "from": "btn_start", "to": "func_write_start" },
{ "from": "btn_stop", "to": "func_write_stop" },
{ "from": "func_write_start", "to": "modbus_write" },
{ "from": "func_write_stop", "to": "modbus_write" }
]
} Expected Output
The function node outputs scaled engineering values every second:
{
"payload": {
"temperature": 42.5,
"temperature_unit": "C",
"pressure": 3.82,
"pressure_unit": "bar",
"flow_rate": 125.3,
"flow_rate_unit": "L/min",
"tank_level": 67.8,
"tank_level_unit": "%",
"motor_speed": 1480,
"motor_speed_unit": "RPM",
"motor_current": 12.45,
"motor_current_unit": "A",
"voltage": 398.2,
"voltage_unit": "V",
"power": 8650,
"power_unit": "W",
"run_hours": 12847,
"run_hours_unit": "hours",
"status": {
"running": true,
"fault": false,
"ready": true
},
"timestamp": "2026-02-12T14:30:00.000Z"
}
} Troubleshooting
Modbus connection timeout
Verify the device IP and port are correct. Check that no firewall is blocking port 502. Ensure the Modbus device is powered on and connected to the network. For RTU, verify baud rate, parity, and stop bits match the device settings.
Illegal Function or Illegal Data Address error
The register addresses do not match the device. Double-check the register map. Some devices use 0-based addressing while EdgeFlow uses 0-based protocol addresses. Verify the function code (FC3 for holding registers, FC4 for input registers, FC1 for coils).
Values seem wrong or out of range
Check the scale factors in the function node. Verify the data type (signed vs unsigned). Some devices use 32-bit values across two registers (FLOAT32) -- you may need to combine two consecutive UINT16 values. Check byte order (Big-Endian vs Little-Endian).
PostgreSQL insert fails
Verify database credentials and that the table schema matches the INSERT statement. Check that PostgreSQL is running and accepting connections. Review the EdgeFlow debug console for specific SQL error messages.