top of page
Search

How To - Build a Modbus Server from Scratch (ESP32 + Raspberry Pi + SHT41) | BAS & SCADA Integration

  • Writer: Matthew Love
    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

Key Concepts & Takeaways

Objective

Memory Map

Hardware Used

Arduino Sketch Code


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:

In this device, all registers are exposed as Holding Registers, even digitals, differentiated by RO/W for Read-Only or Writeable.

Note the multiple usage of the Integer data type: 'Bit String' (packed bits), Scale 10, and Scale 100.

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:


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:


 
 
 

Comments


bottom of page