How To - Build a Modbus Server from Scratch (ESP32 + Raspberry Pi + SHT41) | BAS & SCADA Integration
- Matthew Love
- Mar 19
- 9 min read
Updated: 7 days ago
In this video, I walk through a complete, real-world example of building a custom Modbus TCP/IP server device from the ground up - from programming in Arduino and connecting the wires, to seeing live values and overriding equipment on a BAS or SCADA screen. All for under $20.
Contents
Introduction
Providing two paths, using both an ESP32 and Raspberry Pi, I interface with an SHT41 temperature & humidity sensor over I2C, then design and implement a Modbus server to expose that data in a meaningful way. I also add a simple fan control sequence based on a configurable temperature setpoint to illustrate interactive Modbus integration, complete with an I2C LCD display for local feedback.
This is a full start-to-finish example aimed at anyone looking to better understand how Modbus works in practical applications. It's also a great way to tinker with embedded devices and Arduino projects. All instructions, setup, and code are provided.
This is part of an ongoing series focused on practical Modbus applications and real-world integrations. More projects coming soon.
In the meantime, check out our prior video on How to Setup Modbus in WebCTRL, which covers many universal Modbus concepts.
Key Concepts & Takeaways
By the end of this video, you will understand:
Modbus client/server relationship
Modbus register types and uses for each
Communicating precision, non-integer values over Modbus as both scaled integers and 32-bit floats, and why one method may be used over another
On/off deadband control
Introduction to Arduino, ESP32, and Raspberry Pi platforms
Scan interval and update rate of a process
Testing and verification with third-party tools - Modscan
Systems integration for user-friendly bi-directional control
Objective
Design and build a low-cost Modbus server device (RTU) that controls a fan relative to a temperature setpoint.
Sequence of Operations (SOO)
Inputs
SHT41
Temperature (°C) – Process variable
Humidity (%RH)
Push button
Outputs
Fan relay – Control Variable
LCD screen information:
Temperature
Humidity
Fan setpoint
Fan Mode: Auto or Override
Logic
Automatic
The fan shall energize when the space temperature is above the fan setpoint.
The fan shall de-energize once the temperature falls below the fan setpoint minus the deadband.
The minimum allowable deadband shall be 0.5 degrees.
Override
The fan shall always give priority to override commands.
Network integration
The control data shall be available via Modbus:
Analog Output
Temperature
Humidity
Analog Input
Fan setpoint
Deadband
Binary Output
Push Button status
Fan command status
Binary Input
Fan override mode – Auto/Hand
Fan override command – Off/On
Memory Map
This is the resulting memory map to satisfy and support the SOO, emphasizing the key concept of scaled integer values versus double-word, 32-bit float types to communicate precision, non-integer data.

In integrating a Modbus device, it is common to see the data sheet specify a non-integer value expressed as an integer with 'scale x' notation.
An example, from the Neptronic EVCB VAV controller that offers Modbus:
This design choice (or constraint) is a conservationist method of yesteryear when resources - both memory and CPU power - were much scarcer than today. In the Modbus specification, each analog register is allocated 16 bits (a word), another artifact of time long gone, reflecting the protocol's 1979 origin. 16 bits is generally synonymous with the Integer data type in most programming languages and platforms (as well as IEC 61131-3 PLC Standard) but is more properly labeled word in this context to align with the Modbus specification.
Being 16 bits, an integer or word variable can have a maximum value of 65,535 when unsigned, or -32,767 to 32,767 when signed. This is usually more than enough to express simple, low precision data (such as room temperature or counting button presses), but becomes inflexible with large numbers or when exponents or precision are required. A word register can cleverly be shifted to communicate a float value by the sender multiplying the value by 10 or 100 to preserve the decimal portion and the receiver reversing the operation to normalize the data. The key is for the systems integrator to know this is being done and to what order of magnitude, requiring it to be specified in the device documentation.
For further precision, two consecutive 16-bit word registers are concatenated to express larger integers (greater than 65,000) or greater precision in float values. 32-bits then allows for IEEE 754 single-precision floating-point format to express extremely large numbers (≈ 3.4028235 × 1038) and up to seven decimal places.
In fact, data types of any size can be sent over Modbus, but the encoding must be documented externally as Modbus has no internal documentation or object exploration mechanism, like BACnet or OPC-UA.
Hardware used:
LCD screen - 4x20 PCF8574 driver chip with I2C comms ($5) - optional
Raspberry Pi ($35+) + SD card
Arduino Sketch Code:
// *** Libraries ***
#include <WiFi.h> // Library for WiFi connectivity on the ESP32 board
#include <ModbusIP_ESP8266.h> // Library for Modbus API on ESP32
#include "Adafruit_SHT4x.h" // Adafruit library for SHT4x sensors
#include <Wire.h>
#include <LiquidCrystal_I2C.h> // Library for 16x4 LCD display
#define max(a,b) ((a)>(b)?(a):(b))
// *** I2C Device Instantiation ***
Adafruit_SHT4x sht4 = Adafruit_SHT4x(); // Instantiating SHT4x class
LiquidCrystal_I2C lcd(0x27, 20, 4); // Set the LCD address to 0x27 for a 16 chars and 4 line display
// *** Modbus Server Configuration ***
ModbusIP mb; //ModbusIP server object from ModbusIP_ESP8266 library
// *** Modbus Registers Offsets ***
const int FAN_OVRD = 0; // Coil 0 - 0000
const int FAN_COIL = 1; // Coil 1 - 0001
const int PUSH_BUTTON = 0; // Discrete Input 0 - 10000
const int FAN_CMD = 1; // Discrete Input 1 - 10001
const int TEMPERATURE = 0; // Input Register 0 - 30000
const int HUMIDITY = 1; // Input Register 1 - 30001
const int FAN_SETPT = 0; // Holding Register 0 - 40000
// *** Assign I/O Pins ***
const int FAN_COIL_PIN = 16; // GPIO16
const int PUSH_BUTTON_PIN = 4; // GPIO4
// *** WiFi Network Credentials ***
const char* SSID = "ssid"; // WiFi network SSID
const char* PASSPHRASE = "password"; // WiFi network passphrase
// Timing loop values
unsigned long timestamp = 0; // Value to hold millis() of last loop event
unsigned long delaytime = 1000; // Frequency of loop update, in milliseconds
union FloatToWords { // Union to re-interpret variables as both floats and UINT16 for Modbus double-wide register compatibility
float f; // float (32-bit) - 2 words (16-bits each) - IEEE 754 float value
uint16_t w[2]; // Array of word of size 2
};
sensors_event_t humidity, temp; // Variables to hold queried data from SHT41 sensor
FloatToWords degF; // Variable to hold temperature value from SHT41 sensor, after converted from °C to °F
FloatToWords humRH; // Variable to hold humidity value from SHT41 sensor
FloatToWords fanSetpoint = {.f = 70}; // Variable to hold fan setpoint, holding register, initialized to 70°F
unsigned int deadband = 10; // Variable to hold deadband for fan on/off control, °F, scaled 10x
unsigned int minDB = 5; // Minimum deadband threshold, scaled 10x
bool fanRun; // Final output to fan relay coil
bool fanAuto; // Fan command determination for Auto mode
bool fanOverride; // Toggle fan into override mode
bool fanOverrideCmd; // The ON/OFF command of override mode
bool buttonPushed; // Status if button is pushed
char tempStr[4]; // Temporary buffer strings to hold °C to °F calculation data and formatting for display
char buffer[20]; // String buffer used to concatenate with tempStr
void setup() { // Initialization - Executed once per boot
// *** COMMUNICATIONS INITIALIZATIONS
Serial.begin(115200); // Initialize serial stream for IDE Serial Monitor, 115200 baud, optional
// *** Wi-Fi Initialization ***
WiFi.begin(SSID, PASSPHRASE); // Initialize Wi-Fi connection, SSID credentials
while (WiFi.status() != WL_CONNECTED) { // print '. . . .' until connected to Wi-Fi network
delay(500);
Serial.print(".");
}
// Upon successful connection to WiFi network...
Serial.println("");
Serial.println("WiFi connected");
Serial.println("IP address: ");
Serial.println(WiFi.localIP()); // Serial Monitor: Print the IP address that the router assigned to the ESP32, for debugging purposes
// I2C Communications Bus Initialization
sht4.begin(); // Initialize communications with SHT41 sensor
lcd.init(); // Initialize LCD screen
lcd.backlight(); // Turn LCD backlight on
// I2C Bus Diagnostics to print to Serial Monitor
Wire.beginTransmission(0x44); // I2C hard-coded address of SHT41, 0x44
if (Wire.endTransmission() != 0) {
Serial.println("I2C Bus ERROR: SHT41 Not found!");
} else {
Serial.println("I2C Bus Event: SHT41 detected.");
}
Wire.beginTransmission(0x27); // I2C hard-coded address of LCD display, 0x27
if (Wire.endTransmission() != 0) {
Serial.println("I2C Bus ERROR: LCD Not found!");
} else {
Serial.println("I2C Bus Event: LCD detected.");
}
// *** Modbus Setup ***
mb.server(); // Initialize Modbus Server
// Allocating memory for Modbus Registers
mb.addCoil(0, false, 2); // Add 5 Coils - function inputs: offset, value, number of registers
mb.addIsts(0, false, 2); // Add 5 Input Status
mb.addIreg(0, 0, 8); // Add 8 Input Registers
mb.addHreg(0, 0, 3); // Add 5 Holding Registers
// Setting pinMode for I/Os (Fan Relay Coil output and Push Button input)
pinMode(FAN_COIL_PIN, OUTPUT);
pinMode(PUSH_BUTTON_PIN, INPUT_PULLDOWN); // Using internal pullup resistors to eliminate indeterminate state
}
void loop() { // This part loops forever
mb.task(); // Update Modbus registers - Putting outside of interval loop so Modbus is responsive and does not time out to Client
if ((millis() - timestamp) > delaytime) { // If time since last loop event is greater than the loop interval, then...
sht4.getEvent(&humidity, &temp); // Function call to update humidity and temp values from SHT41
// TEMPERATURE
degF.f = ((temp.temperature / 5) * 9) + 32; // Convert temperature from °C, as sent by SHT41, to degF -> Stored as float
lcd.setCursor(0, 0); // Set LCD cursor at left-most column of top row.
dtostrf(degF.f, 3, 1, tempStr); // Input value, number of digits, precision, output buffer - Convert float value to string to print to LCD
sprintf(buffer, "Temperature: %s", tempStr); // Replaces %s with tempStr variable and stores in buffer variable.
lcd.print(buffer); // Print buffer string (float value) to LCD screen
lcd.write(223); lcd.print("F"); // Write ° symbol (223) and F char to LCD screen, immediately following float value
// HUMIDITY
humRH.f = humidity.relative_humidity; // Store RH% sensor value into humRH variable as float
lcd.setCursor(0, 1); // Set LCD cursor at left-most column of 2nd row
lcd.print("Humidity: "); // Set up to display humidity value to LCD
lcd.print(humRH.f, 1); // No need to condition variable, already normalized from 0-100%
lcd.print("%RH"); // Label with engineering units
// Read Inputs
buttonPushed = digitalRead(PUSH_BUTTON_PIN); // Read input pin status corresponding to push button
// Update Inputs - From Client to Server
fanOverride = mb.Coil(FAN_OVRD); // Put fan into override mode
fanOverrideCmd = mb.Coil(FAN_COIL); // To override fan off or on
if (mb.Hreg(0) != 0 || mb.Hreg(1) != 0) { // Checking that Modbus Hregs have been written to, so default 0s do not overwrite initialized values
fanSetpoint.w[0] = mb.Hreg(0); // Setpoint at which to turn fan on,
fanSetpoint.w[1] = mb.Hreg(1); // split into 2 registers (2 words -> 1 float)
deadband = max(minDB, mb.Hreg(2)); // Expose deadband variable over Modbus, minimum 0.5°F
mb.Hreg(2, deadband); // Setting HReg back to effect value used, to be read back over Modbus
}
lcd.setCursor(0,2); // Set LCD cursor at left-most column of 3rd row
lcd.print("Fan setpoint: "); // Set up to display active fan setpoint, as received from Modbus client, to LCD
lcd.print(fanSetpoint.f, 1);
lcd.write(223); lcd.print("F"); // Write ° symbol (223) and F char to LCD screen, immediately following float value
if (degF.f > fanSetpoint.f) { // Deadband control for fan on/off in automatic mode
fanAuto = true;
} else if (degF.f < (fanSetpoint.f - deadband/10)) {
fanAuto = false;
}
if (fanOverride) { // Priority structure for automatic vs override logic
fanRun = fanOverrideCmd;
lcd.setCursor(0, 3); // Set LCD cursor at left-most column of 4th row
lcd.print("FAN IN HAND");
} else {
fanRun = fanAuto;
lcd.setCursor(0, 3);
lcd.print("FAN IN AUTO");
}
// Write outputs
digitalWrite(FAN_COIL_PIN, fanRun); // Write fanRun state to output pin for fan relay coil
// Update Outputs - From Server to Client
mb.Ists(PUSH_BUTTON, buttonPushed); // PUSH_BUTTON = 0 Discrete Input Register -- Status of push button
mb.Ists(FAN_CMD, fanRun); // FAN_CMD = 1 DI Register -- Indicating that ran is commanded to run
mb.Ireg(TEMPERATURE, degF.f); // TEMPERATURE = 0 Input Register -- Passing float value into Int, truncated
mb.Ireg(HUMIDITY, humRH.f); // HUMIDITY = 1 Input Register -- Passing float value into Int, truncated
mb.Ireg(2, degF.f * 100); // Temperature -- Passing float value, scale 100, into Int, truncated.
mb.Ireg(3, humRH.f * 100); // Humidity -- Passing float value, scale 100, into Int, truncated.
mb.Ireg(4, degF.w[0]); // Temperature -- Passing float value directly, across two 16-bit registers
mb.Ireg(5, degF.w[1]); // Temperature -- Word #2
mb.Ireg(6, humRH.w[0]); // Humidity -- Passing float value directly, across two 16-bit registers
mb.Ireg(7, humRH.w[1]); // Humidity -- Word #2
timestamp = millis(); // Update timestamp for loop timer
}
}Links of interest:
Hardware
Links for I2C Reference:


Comments