Environmental monitoring with Modbus protocol, Raspberry Pi and ESP32: essential guide with REST API

Introduction

This project, focusing on the Modbus protocol, represents a perfect example of how modern technology can be used to create an efficient and scalable environmental monitoring system, using inexpensive and widely available components such as the Raspberry Pi and the ESP32. The heart of the system is a Raspberry Pi equipped with a DHT22 sensor, which cyclically measures the temperature and humidity of the surrounding environment. This data is then transmitted in real time via the Modbus protocol using a WiFi connection. The choice of Modbus as the communication protocol reflects the need for a reliable and standardized method for data transmission in industrial and domestic contexts.

The project is not limited to the simple collection and transmission of data. An ESP32 module receives data from the Raspberry Pi via Wi-Fi and processes it further. The ESP32 is programmed to record data to a log file within its SPIFFS (SPI Flash File System) file system. Each pair of data (temperature and humidity) is enriched with an accurate timestamp, obtained from an NTP (Network Time Protocol) server, ensuring that each recording is associated with the exact time of detection.

Another fundamental aspect of the project is the efficient management of storage space. Because data is constantly being logged, the log file can grow rapidly. To prevent excessive use of available memory, the ESP32 includes a mechanism that continuously monitors the size of the log file. When the file exceeds a predefined size, the system automatically removes the oldest rows, preserving only the most recent and relevant data. This approach ensures that the system can operate autonomously and continuously without the need for frequent intervention.

Finally, the project includes three REST APIs, accessible via Wi-Fi, which allow you to interact with the system in a simple and intuitive way. The datetime API provides the current system date and time, synchronized via NTP, ensuring that users can get accurate time information. The checkfile API checks for the presence of the log file in the SPIFFS file system, useful for debugging operations. The readfile API allows you to read the contents of the log file, allowing users to access the collected data remotely.

In summary, this project not only demonstrates how to use open-source technologies and accessible components to create an environmental monitoring system, but also highlights the importance of data management and accessibility via web interfaces. With its combination of robustness, flexibility and ease of use, it represents a practical and versatile implementation, applicable to a wide range of both industrial and domestic scenarios.

As usual the code for the ESP32 was written via the excellent PlatformIO IDE. For more information on the file system used, I invite you to take a look at the article File management on ESP32: SPIFFS and LittleFS compared.

The Modbus protocol

Modbus is an industrial communication protocol born in 1979 on the initiative of Modicon (now Schneider Electric), one of the first companies to develop PLC (Programmable Logic Controller). The protocol was designed to allow industrial automation devices, such as sensors, actuators and controllers, to communicate with each other in a standardized and efficient way.

Origin and purpose of Modbus

The protocol was born in response to the need for a universal method to connect different devices in an industrial automation system. Before its creation, manufacturers used proprietary protocols, making it difficult to integrate devices from different brands. Modbus was conceived as an open protocol, easily implemented on various platforms, and designed to work on serial networks such as RS-232 and RS-485. This has enabled widespread adoption, making Modbus a de facto standard in the industry.

Uses of Modbus

Modbus is mainly used to connect field devices with a PLC or SCADA (Supervisory Control and Data Acquisition) system. It is ideal for applications where it is necessary to monitor and control process variables such as temperatures, pressures, levels and speeds in real time. The protocol supports various modes, including Modbus RTU, which is used on serial networks, and Modbus TCP/IP, which allows communication on modern Ethernet networks.

Why Modbus is still in use

Despite the advent of more modern technologies, Modbus is still widely used for several reasons:

  • Simplicity: Modbus is easy to implement and requires minimal resources, making it ideal for embedded devices with limited capabilities.
  • Compatibility: as an open standard, Modbus is supported by a wide range of devices and manufacturers, making it easy to integrate into existing systems.
  • Reliability: with decades of industry use, Modbus has proven to be a robust and reliable protocol.
  • Flexibility: Modbus can run on various networks (serial, Ethernet, etc.), making it versatile for different industrial applications.

Evolution and future

Over the years, Modbus has been adapted to run on new networks, such as Ethernet, and has seen the introduction of versions such as Modbus Plus and Modbus TCP/IP. Its flexibility and continued compatibility with legacy devices ensure that it remains relevant even in modern systems. However, with the emergence of new protocols such as OPC-UA, Modbus may gradually give way to more advanced solutions in more complex applications.

In summary, Modbus is a fundamental pillar in the world of industrial automation, thanks to its simplicity, reliability and ability to integrate a wide range of devices into an industrial control environment.

Our project also involves the use of some REST APIs. Let’s see in more detail what it is.

REST APIs

REST (Representational State Transfer) APIs are a very widespread communication paradigm, especially in the context of the Internet of Things (IoT), which allows interaction between different systems through a standardized and easy-to-use interface. Introduced by Roy Fielding in the early 2000s, REST APIs are based on architectural principles that leverage the HTTP protocol to allow devices, services and applications to communicate with each other in a scalable, efficient and platform-independent way.

Purpose of REST APIs

REST APIs are used to expose functionality of a system or service to another application, be it a mobile app, a web application or an IoT device. This is done via CRUD (Create, Read, Update, Delete) operations that map directly to HTTP operations: POST, GET, PUT/PATCH, DELETE. In practice, these APIs allow you to read, create, modify and delete resources on a remote server, all using simple and easily understandable HTTP requests.

Types of REST APIs

REST APIs can be classified into different types depending on the type of resource they manage:

  • JSON REST API: responses are provided in JSON (JavaScript Object Notation) format, the most common and readable format for transmitting structured data.
  • XML REST API: although less common than JSON, some REST APIs use XML (eXtensible Markup Language) to represent resources.
  • Streaming REST APIs: used to stream large streams of real-time data, such as video or IoT sensor data.

Importance of REST APIs in IoT

In the context of IoT, REST APIs are critical for several reasons:

  • Interoperability: allow heterogeneous devices to communicate with each other, regardless of their operating system or programming language.
  • Scalability: thanks to their lightweight architecture, REST APIs are easily scalable, making them ideal for IoT environments with an increasing number of connected devices.
  • Ease of deployment: REST APIs are simple to implement and understand, making IoT application development faster.
  • Accessibility: they allow devices to be accessible from anywhere, facilitating the remote management and control of IoT systems, through global networks such as the Internet.

Why REST APIs matter

REST APIs represent a bridge between the physical and digital worlds. In the IoT, where devices generate and consume large amounts of data, REST APIs provide a structured and standardized way to access this data and use it in different applications. They facilitate integration between new and legacy systems, improving interoperability and reducing development complexity. Furthermore, thanks to their flexibility, REST APIs can be used for a wide range of applications, from home control to the management of large industrial plants.

REST APIs are a key element for developing modern IoT solutions. They offer a universal interface that simplifies communication between devices and services, ensuring flexibility, scalability and ease of use. Thanks to their global adoption, they represent a de facto standard for creating interconnected and interoperable IoT ecosystems.

If you are particularly interested in the use of REST APIs, I invite you to take a look at the article How to build a REST API server with ESP32 which explains how to create a web server with the ESP32 so that it exposes a set of REST APIs to be used to interact with the device in a to receive or send data.

To test the REST APIs in this project we will use a well-known software: Postman.

Postman

Postman is a powerful and versatile tool used to test and develop REST APIs, essential for anyone working with server-client communications, especially in the Internet of Things (IoT) space. Thanks to its intuitive interface, Postman allows you to send GET, POST, PUT, DELETE HTTP requests and view the responses in real time. This facilitates debugging and verifying the correct functioning of the API, making it an indispensable tool for developers and engineers.

One of the main features of Postman is its ability to simulate API requests precisely, allowing you to test interactions between different devices or between a device and a remote server. This is particularly useful when developing IoT applications, where devices often need to communicate with cloud services or exchange data in real time. Furthermore, Postman supports the creation of collections of requests, which can be saved and reused, making the testing process more efficient and structured.

In summary, Postman is not just a simple client for testing APIs, but a real development tool that supports the entire API lifecycle, from design to testing to monitoring. In the context of IoT, its ability to manage and test REST APIs efficiently makes it an indispensable ally to ensure that devices can communicate with each other seamlessly. By integrating Postman into your workflow, you can ensure that the APIs you develop are robust, reliable, and ready to be deployed in production environments.

DHT22

The DHT22 is a digital sensor used to measure temperature and humidity, widely used for its ease of use and accuracy. Internally, the DHT22 is composed of a capacitive element to measure humidity and a thermistor to detect temperature. The sensor is capable of providing humidity readings with an accuracy of ±2-5% and temperature with an accuracy of ±0.5°C, covering a range of -40°C to +80°C for temperature and a range of 0% to 100% for humidity.

The DHT22 communicates via a one-way digital serial protocol that uses a single wire to transmit data. Communication begins with the microcontroller sending a start signal, followed by a response from the DHT22. The sensor responds by sending 40 bits of data, representing humidity (16 bits), temperature (16 bits), and a checksum (8 bits) to verify the integrity of the transmitted data.

The data signal is encoded using a time format in which the duration of each pulse determines whether the transmitted bit is a 0 or a 1. This protocol, while simple, requires careful timing management by the microcontroller to ensure correct reading of the data.

The DHT22 is popular for applications requiring reliable temperature and humidity measurement in domestic or industrial settings, due to its balance of accuracy, cost and ease of use. However, the relatively slow read speed (about once every 2 seconds) makes it less suitable for applications that require rapid updates.

What components do we need?

The list of components is not particularly long:

  • a breadboard to connect the Raspberry PI to other components
  • some DuPont wires (male – male, male – female, female – female)
  • a DHT22 sensor
  • a 4.7kΩ resistor
  • an ESP32 NodeMCU
  • a (micro) SD card of no more than 32GB formatted in FAT32
  • a possible USB WiFi dongle for the Raspberry
  • and, of course, a Raspberry!

The SD card I used is 32GB. The ESP32 used is distributed by the company AZ-Delivery.

The project has been successfully tested on a Raspberry PI 3 Model B but it is not excluded that it will also work on other Raspberry models.

Project implementation

The electrical diagram

Before creating the actual circuit let’s take a look at the pinouts of the Raspberry used:

Pinout of the Raspberry Pi 3 Model B
Pinout of the Raspberry Pi 3 Model B

So let’s see the pinout of the DHT22 sensor:

DHT22 pinout
DHT22 pinout

At this point we can see the connection of the DHT22 to the Raspberry via a diagram created with Fritzing:

Diagram of the connection between the Raspberry and the DHT22 of our datalogger on Modbus protocol
Diagram of the connection between the Raspberry and the DHT22 of our datalogger on Modbus protocol

Power is taken from the 3.3V line while the data output of the DHT22 is connected to pin 7 of the Raspberry, corresponding to GPIO 4.

It is important to power the DHT22 at 3.3V as if pin 7 were to receive a 5V signal it would be irremediably damaged.

Preparing the Raspberry

In order to use the Raspberry it is necessary to take some preliminary steps and install some software.

Let’s start immediately with the installation of the operating system.

The operating system chosen is a distribution made specifically to run on all types of Raspberry, even the older ones. The tests were done on a Raspberry PI 3 Model B.

If the Raspberry does not have a native wireless connection you can use a WiFi dongle to insert into one of its USB sockets.

Let’s download and install the operating system on the SD card

Download the latest version of the operating system at https://www.raspberrypi.com/software/operating-systems/

and take you to the Raspberry Pi OS (Legacy) section. You will download a version that has no graphical environment so that it is as lightweight as possible:

The chosen distribution
The chosen distribution

The downloaded file will be compressed in xz format. To unzip it on Linux you will first need to install the tool:

sudo dnf install xz           su CentOS/RHEL/Fedora Linux.
sudo apt install xz-utils     su Ubuntu/Debian

and then give the command line:

xz -d -v filename.xz

where filename.xz is the name of the file you just downloaded containing the operating system.

On Windows it will be sufficient to use one of these tools: 7-Zip, winRAR, WinZip.

The result will be a file with img extension which is the image to flash on the Raspberry SD card.

To flash the image on the SD card you will use the Balena Etcher tool which works on both Linux, Windows and MACOS.

Its use is very simple: simply select the image to flash, the destination SD card and press the Flash button.

This is what its interface looks like:

The interface of the Balena Etcher tool
The interface of the Balena Etcher tool

The image to be flashed is set on the left, the SD card to be flashed in the centre, and the button to start the flashing operation on the right.

At the end of the operation the SD card will contain two partitions: boot and rootfs. In the device manager on Linux a menu like this appears:

Device menu on Linux
Device menu on Linux

Windows will also show a menu like this: from your file explorer, under This computer you will see the 2 partitions.

Now, with a text editor, create a file on your computer that you will call wpa_supplicant.conf and which you will edit like this:

ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev
update_config=1
country=«your_ISO-3166-1_two-letter_country_code»
network={
     ssid="«your_SSID»"
     psk="«your_PSK»"
     key_mgmt=WPA-PSK
}

You will need to replace the following items:

  • «your_ISO-3166-1_two-letter_country_code» with the identifier of your country (for example for Italy it is IT)
  • «your_SSID» with the SSID name of your WiFi network
  • «your_PSK» with the WiFi network password

At this point, you will need to create an empty file that you will call ssh (without any extension).

The new distributions do not have the classic pi user with raspberry password so, to be able to enter SSH, we must provide another way.

With a working Raspberry we need to create a file called userconf which will contain the user we want to create with an encrypted version of the password we want to assign to him. The format will therefore be username:password-hash.

Suppose we want to keep the user pi, we need to create the password-hash. Suppose we want to create the hash of the raspberry password, again on the Raspberry where we created the userconf file. We must issue the following command from the shell:

echo "raspberry" | openssl passwd -6 -stdin

This command will return the raspberry password hash. For example it could be a string like this:

$6$ROOQWZkD7gkLyZRg$GsKVikua2e1Eiz3UNlxy1jsUFec4j9wF.CQt12mta/6ODxYJEB6xuAZzVerM3FU2XQ27.1tp9qJsqqXtXalLY.

This is the raspberry password hash that I calculated on my Raspberry.

Our userconf file will then contain the following string:

pi:$6$ROOQWZkD7gkLyZRg$GsKVikua2e1Eiz3UNlxy1jsUFec4j9wF.CQt12mta/6ODxYJEB6xuAZzVerM3FU2XQ27.1tp9qJsqqXtXalLY.

PLEASE NOTE: it is necessary to calculate the hash with a Raspberry because the hash calculated with the computer uses another algorithm which would not allow the Raspberry we are preparing to recognize the password.

Alternatively you can download from the link below the userconf file that I created to have a pi user with a raspberry password.

Now open the boot partition on the SD card and copy the three files wpa_supplicant.conf, ssh and userconf into it. Safely remove the SD card from the computer and insert it into the Raspberry.

Turn on the Raspberry, wait a few minutes. To be able to log in to the Raspberry via ssh, you will need to find out what its IP is (the one that the router assigned to it via DHCP).

To do this, simply issue the command from a PC shell:

ping raspberrypi.local 

valid on both Linux and Windows (after installing Putty on Windows).

On my PC the Raspberry responds like this:

Raspberry response to ping
Raspberry response to ping

This makes me understand that the assigned IP is 192.168.43.27.

Alternatively you can use the Angry IP Scanner tool or you can access your router settings to see the devices connected via WiFi and find out what IP the Raspberry has.

To log in to the Raspberry via ssh, issue the shell command (obviously in your case the IP will be different from this):

with raspberry password. On Windows you need Putty.

Once inside the Raspberry, issue the following commands to update the software:

sudo apt update
sudo apt upgrade

The password is, as mentioned, raspberry.

Let’s configure the timezone

To configure the timezone, issue the command:

sudo raspi-config

to the Raspberry shell. Suppose you want to set the time zone of Rome (here I will give the example of the time zone of Rome since I live in Italy, you will have to use the time zone of your country).

A screen like this will appear:

Initial screen of the sudo raspi-config command
Initial screen of the sudo raspi-config command

Select the localization option and click OK:

Selected the localization option
Selected the localization option

Then select the timezone option and click OK:

Selected timezone option
Selected timezone option

Now select the geographical area and click OK:

Selected geographic area
Selected geographic area

Finally select the city and click OK:

Selected city
Selected city

Done!

Restart the Raspberry by issuing the command:

sudo reboot

and, after a few minutes, log back into ssh as you did before.

Give the command

date

The Raspberry should now show the correct date and time.

Let’s set the static IP

To ensure that the Raspberry always has the same IP address, we need to set it to be static. In my tests I set it to 192.168.1.190. If we didn’t do this, the router would assign it a different IP at each reboot which would force us to change the IP address of the Modbus server in the ESP32 sketch each time.

We will proceed in two steps:

  • we will set the fixed IP in the Raspberry
  • we will set the router to reserve that address for our Raspberry

For the first point, issue the command:

nano /etc/dhcpcd.conf

to open the dhcpcd.conf file and edit it.

At the end of the file you will need to add a block like this:

interface [INTERFACE]
static_routers=[ROUTER IP]
static domain_name_servers=[DNS IP]
static ip_address=[STATIC IP ADDRESS YOU WANT]/24

where:

  • [INTERFACE] is the name of the WiFi interface (in our case it will be wlan0)
  • [ROUTER IP] is the address of our router (usually it’s something like 192.168.0.1 or 192.168.1.1). You can find it by entering the administration interface of your modem/router
  • [DNS IP] is the address of the DNS server, which generally coincides with the [ROUTER IP] parameter of the modem/router
  • [STATIC IP ADDRESS YOU WANT] it is the IP address that we want to assign as a fixed IP to the Raspberry

So, assuming that [ROUTER IP] = [DNS IP] = 192.168.1.1 and that [STATIC IP ADDRESS YOU WANT] = 192.168.1.190, the block will look like this:

interface wlan0
static_routers=192.168.1.1
static domain_name_servers=192.168.1.1
static ip_address=192.168.1.190/24

Restart the Raspberry with the command

sudo reboot

and then log in via ssh again, this time with IP 192.168.1.190.

As a second step we will set the router so that it reserves the address 192.168.1.190 for our Raspberry. Each modem/router is different from the others but they are more or less similar. I’ll show here what mine looks like.

To enter I type the address 192.168.1.1 (because my modem has this IP) on the browser and, after giving the administrator password, I arrive at the main screen. From here I have to look for the access control screen.

Adding a static IP for the Raspberry
Adding a static IP for the Raspberry

There will be a button to add a static IP: add the chosen IP combined with the MAC address of the Raspberry WiFi card. However, I recommend that you consult the instruction manual of your modem/router for this operation.

Now check that the Raspberry connects to the network by issuing the command:

ping www.google.com

If you get the response to the ping the network is connected. If you get a message like “Network is unreachable” issue the command

sudo route add default gw [ROUTER IP]  

where [ROUTER IP] is the gateway which in our case is the router IP, i.e. 192.168.1.1

At this point we are ready to install the necessary libraries. Let’s start by installing a very useful Python tool called pip by giving the command:

sudo apt-get install python3-pip

Once the installation is complete, we proceed to install the actual library that will read the DHT22 by giving the command:

sudo pip3 install Adafruit_DHT

If you want to immediately test the sensor to make sure everything works you can take the following script:

import Adafruit_DHT

# Sensor configuration
DHT_SENSOR = Adafruit_DHT.DHT22
DHT_PIN = 4  # GPIO4 of Raspberry Pi

def read_dht22():
    humidity, temperature = Adafruit_DHT.read_retry(DHT_SENSOR, DHT_PIN)
    if humidity is not None and temperature is not None:
        return round(temperature, 2), round(humidity, 2)
    else:
        return None, None

if __name__ == "__main__":
    temp, hum = read_dht22()
    if temp is not None and hum is not None:
        print(f"Temperature: {temp}°C, Humidity: {hum}%")
    else:
        print("Error reading DHT22")

copy it and paste it into a file on your Raspberry home that you can call, for example, testdht.py. To create the file you can issue the command:

touch testdht.py

and then edit it with nano by issuing the command:

nano testdht.py

To save the file with nano give the command CTRL + O and to exit nano give the command CTRL + X.

By issuing the command sudo python testdht.py the script will have to print the current temperature and humidity values. If it doesn’t, recheck the sensor connections.

PLEASE NOTE: make sure that the DHT22 power terminal is connected to pin 1 (the 3.3V power supply) of the Raspberry so as not to damage pin 7 where the sensor output is connected. If pin 7 were to receive a 5V signal it would be irremediably damaged.

Let’s continue by installing the ModBus library by issuing the command:

sudo pip3 install pymodbus==2.5.3

At this point, still in the Raspberry home, create a file named modbus_server_raspberry.py and, using nano, fill it with the following code:

from pymodbus.server.sync import StartTcpServer
from pymodbus.device import ModbusDeviceIdentification
from pymodbus.datastore import ModbusSequentialDataBlock
from pymodbus.datastore import ModbusSlaveContext, ModbusServerContext
import random
import time
import logging
from threading import Thread
import struct
import Adafruit_DHT

# Sensor configuration
DHT_SENSOR = Adafruit_DHT.DHT22
DHT_PIN = 4  # GPIO4 of Raspberry Pi

logging.basicConfig()
log = logging.getLogger()
log.setLevel(logging.DEBUG)

def updating_writer(context):
    while True:

        humidity, temperature = Adafruit_DHT.read_retry(DHT_SENSOR, DHT_PIN)
        temperature = round(temperature, 2)
        humidity = round(humidity, 2)

        temp_int = struct.unpack('>I', struct.pack('>f', temperature))[0]
        temp_high = (temp_int >> 16) & 0xFFFF
        temp_low = temp_int & 0xFFFF

        humidity = int(humidity)

        context[0].setValues(3, 0, [temp_high, temp_low, humidity])
        
        log.debug(f"Updated registers with temperature: {temperature} and humidity: {humidity}")
        time.sleep(1)

store = ModbusSlaveContext(
    di=ModbusSequentialDataBlock(0, [17]*100),
    co=ModbusSequentialDataBlock(0, [17]*100),
    hr=ModbusSequentialDataBlock(0, [17]*100),
    ir=ModbusSequentialDataBlock(0, [17]*100))
context = ModbusServerContext(slaves=store, single=True)

identity = ModbusDeviceIdentification()
identity.VendorName = 'pymodbus'
identity.ProductCode = 'PM'
identity.VendorUrl = 'http://github.com/bashwork/pymodbus/'
identity.ProductName = 'pymodbus Server'
identity.ModelName = 'pymodbus Server'
identity.MajorMinorRevision = '1.0'

updater = Thread(target=updating_writer, args=(context,))
updater.start()

try:
    StartTcpServer(context, identity=identity, address=("0.0.0.0", 502))
except Exception as e:
    log.error(f"Error starting server: {e}")

Launch it now with the command

sudo python3 modbus_server_raspberry.py

You should have an output like this:

The modbus_server_raspberry.py script running on the Raspberry
The modbus_server_raspberry.py script running on the Raspberry

NOTE: we have not yet put the sketch into operation in ESP32 so the output of the python script is quite simple. When the sketch for the ESP32 is also running, the messages on the Raspberry shell will be more complex as they will also include the DEBUG messages of the communication between the two devices.

Now let’s see how the python script works.

Initially the necessary libraries are imported:

from pymodbus.server.sync import StartTcpServer
from pymodbus.device import ModbusDeviceIdentification
from pymodbus.datastore import ModbusSequentialDataBlock
from pymodbus.datastore import ModbusSlaveContext, ModbusServerContext
import random
import time
import logging
from threading import Thread
import struct
import Adafruit_DHT

The DHT22 is then configured to work on GPIO 4 and the logging system is configured for debugging (the messages that are printed by the script on the Raspberry shell):

# Sensor configuration
DHT_SENSOR = Adafruit_DHT.DHT22
DHT_PIN = 4  # GPIO4 of Raspberry Pi

logging.basicConfig()
log = logging.getLogger()
log.setLevel(logging.DEBUG)

We then find the updating_writer function which is the center of everything. Its contents run in an infinite loop as it is in the block

while True:

Initially the temperature and humidity values ​​are read from the sensor:

humidity, temperature = Adafruit_DHT.read_retry(DHT_SENSOR, DHT_PIN)
temperature = round(temperature, 2)
humidity = round(humidity, 2)

Then follows an elaboration of the temperature value (which is a float):

temp_int = struct.unpack('>I', struct.pack('>f', temperature))[0]
temp_high = (temp_int >> 16) & 0xFFFF
temp_low = temp_int & 0xFFFF

this portion of code performs a series of operations to convert a float value (in this case the temperature) into a 32-bit binary representation, and then splits these 32 bits into two parts of 16 bits each. This is useful when you need to transmit data over a protocol that only supports 16-bit registers, such as Modbus.

The humidity value is converted into an integer to simplify the code since, as an integer, it does not need further processing:

humidity = int(humidity)

The following instruction:

context[0].setValues(3, 0, [temp_high, temp_low, humidity])

writes the temperature and humidity values ​​to a specific memory block of the Modbus system. Specifically, it stores two parts of the temperature and humidity value in holding registers, starting from address 0. This allows the data to be easily accessed or transmitted to other devices in the network.

This is followed by a log function that prints a message containing the current temperature and humidity values ​​and a timing instruction that introduces a one-second pause:

log.debug(f"Updated registers with temperature: {temperature} and humidity: {humidity}")
time.sleep(1)

Once the function is finished we arrive at the block:

store = ModbusSlaveContext(
    di=ModbusSequentialDataBlock(0, [17]*100),
    co=ModbusSequentialDataBlock(0, [17]*100),
    hr=ModbusSequentialDataBlock(0, [17]*100),
    ir=ModbusSequentialDataBlock(0, [17]*100))
context = ModbusServerContext(slaves=store, single=True)

This portion of code configures the Modbus context for a slave device. In particular:

  • ModbusSlaveContext defines a memory area organized into four data blocks: di (Discrete Inputs), co (Coils), hr (Holding Registers), and ir (Input Registers). Each block is initialized with 100 registers, all with the default value of 17.
  • ModbusSequentialDataBlock is used to manage these data blocks, allowing sequential access to the registers.
  • context = ModbusServerContext(slaves=store, single=True) creates a server context that uses the slave configured above, indicating that the server manages a single slave.

This setup is essential to allow the device to manage Modbus requests in a structured and accessible way.

The following part:

identity = ModbusDeviceIdentification()
identity.VendorName = 'pymodbus'
identity.ProductCode = 'PM'
identity.VendorUrl = 'http://github.com/bashwork/pymodbus/'
identity.ProductName = 'pymodbus Server'
identity.ModelName = 'pymodbus Server'
identity.MajorMinorRevision = '1.0'

configures the Modbus device identity. These details are used to clearly identify the device in a Modbus network, making it easier for other nodes in the network to manage and recognize the device.

The following code:

updater = Thread(target=updating_writer, args=(context,))
updater.start()

creates and starts a separate thread to run the updating_writer function in parallel with the rest of the program to be able to update the Modbus registers continuously and independently from the rest of the code, improving the efficiency and responsiveness of the system.

The last part:

try:
    StartTcpServer(context, identity=identity, address=("0.0.0.0", 502))
except Exception as e:
    log.error(f"Error starting server: {e}")

starts a Modbus TCP server that listens to all network interfaces (“0.0.0.0“) on port 502, using the previously defined context and device identity. If an error occurs, an error message is logged that includes a description of the exception.

Let’s create the PlatformIO project

We have already seen the procedure for creating a PlatformIO project in the article How to create a project for NodeMCU ESP8266 with PlatformIO.

Although it refers to the ESP8266 board, the procedure is similar.
Simply, when choosing the platform, you will have to choose the AZ-Delivery ESP-32 Dev Kit C V4.

Do not install any of the libraries mentioned in the article.

Now edit the platformio.ini file to add these two lines:

monitor_speed = 115200
upload_speed = 921600

so that the file looks like this:

[env:az-delivery-devkit-v4]
platform = espressif32
board = az-delivery-devkit-v4
framework = arduino
monitor_speed = 115200
upload_speed = 921600

and add the SPI, SPIFFS, NTPClient, eModbus and WiFiManager libraries so that the file looks like this:

[env:az-delivery-devkit-v4]
platform = espressif32
board = az-delivery-devkit-v4
framework = arduino
monitor_speed = 115200
upload_speed = 921600
lib_deps = 
	SPI
	SPIFFS
    arduino-libraries/NTPClient
    https://github.com/eModbus/eModbus.git
    wnatth3/WiFiManager @ 2.0.16-rc.2

You can download the project from the following link:

unzip it, take the main.cpp file and replace it in place of the one you have in the previously created project.

Then, in the project you created, create a folder named data at the same level as the src folder and create an empty file inside it that you will call logdata.txt. This file will be transferred (with a procedure that we will see shortly) into the SPIFFS file system internal to the board.

How to transfer the logdata.txt file to the SPIFFS file system

The operation is quite simple. It is necessary to open a new terminal on PlatformIO with the button indicated in the figure:

Button to open a new terminal
Button to open a new terminal

write the following command:

pio run --target uploadfs

and press the ENTER key. If everything goes well the file will be transferred to the SPIFFS file system. If necessary, stop viewing via Serial Monitor as it may conflict with the upload operation as it uses (and sometimes monopolizes) the communications port.

PLEASE NOTE: it is important that the logdata.txt file is located, as already mentioned at the beginning, in a folder called data at the same level as the src folder.

Loading the sketch, however, follows the normal way.

How the sketch works

Now let’s see how the sketch works.

The sketch begins with including the necessary libraries:

#include <Arduino.h>

#include <SPI.h>
#include <ModbusClientTCP.h>
#include <NTPClient.h>
#include <SPIFFS.h>
#include <WebServer.h>
#include <WiFiManager.h> 

A webserver is then instantiated which listens on port 80 and manages the REST API:

WebServer server(80);

Then follows the line:

#define PRINTFILE

which defines a “macro”. This macro is used to activate, as a debug, the printing of the file present in the file system so as to be able to view it.

The parts designated for this print are enclosed within #ifdef PRINTFILE….#endif blocks. If the PRINTFILE macro is defined, these pieces of code are included in the sketch and, consequently, compiled together with everything else. If the PRINTFILE macro is not defined, as in the following case:

// #define PRINTFILE

where it is commented out, the parts in the #ifdef PRINTFILE….#endif blocks will not be part of the sketch and will not be compiled. Consequently the file will not be printed on the Serial Monitor.

Then follows the variable storageFilename which contains the name of the storage file, the constant maxFileSize which defines its maximum size and the constant logInterval which establishes every how many milliseconds the size of the file must be checked:

String storageFilename = "/logdata.txt";

const size_t maxFileSize = 1024; // Max size of the storage file
const unsigned long logInterval = 5000; // Storage file size check interval in milliseconds

This is followed by a #ifdef PRINTFILE….#endif block that we talked about earlier:

#ifdef PRINTFILE
  const unsigned long printInterval = 20000; // Print interval of storage file contents in milliseconds (for debugging only)
#endif

If the PRINTFILE macro is defined, the line const unsigned long printInterval = 20000; is included in the file and compiled. It defines the constant printInterval which establishes every how many milliseconds the storage file must be printed on the Serial Monitor.

Then follow the definitions of the previousLogMillis and previousPrintMillis variables used to calculate the timing of file size control and file printing respectively:

unsigned long previousLogMillis = 0;
unsigned long previousPrintMillis = 0;

Subsequently, the wifiClient is defined which is used to instantiate the Modbus MBclient client, the ntpUDP for the NTP server from which we obtain date and time and the timeClient which sets a European server with the UTC for Italy (which takes into account the daylight saving time because it is in effect at the time of writing this article) and the synchronization time interval (60000 milliseconds):

WiFiClient wifiClient;
ModbusClientTCP MBclient(wifiClient);
WiFiUDP ntpUDP;
// Sets an offset of 3600 * 2 = 7200 for daylight saving time (3600 for standard time), referring to Italy
NTPClient timeClient(ntpUDP, "europe.pool.ntp.org", 7200, 60000);

Then follow the prototypes of the removeOldEntries, checkFileSize, printFileContent functions which are implemented below:

void removeOldEntries();
void checkFileSize();
void printFileContent();

We then meet the most important function of the sketch, the handleData function:

void handleData(ModbusMessage response, uint32_t token) {
    Serial.print("Response received with token: ");
    Serial.println(token);

    if (response.getError() == SUCCESS) {
        float temperature;
        uint16_t humidity;
        uint16_t tempHigh, tempLow;

      if (response.get(3, tempHigh) && response.get(5, tempLow) && response.get(7, humidity)) {
          uint32_t tempRaw = (tempHigh << 16) | tempLow;
          temperature = *(float*)&tempRaw;

          Serial.printf("Temperature: %.2f, Humidity: %d\n", temperature, humidity);

          unsigned long epochTime = timeClient.getEpochTime();
          struct tm *ptm = gmtime((time_t *)&epochTime);

          // Formats the date and time in YYYY:MM:DD:HH:MM:SS format
          char timeBuffer[20];
          sprintf(timeBuffer, "%04d:%02d:%02d:%02d:%02d:%02d",
                  ptm->tm_year + 1900, ptm->tm_mon + 1, ptm->tm_mday,
                  ptm->tm_hour, ptm->tm_min, ptm->tm_sec);

          Serial.println(timeBuffer);

          File file = SPIFFS.open(storageFilename, FILE_APPEND);
          if (file) {
            file.printf("%s:%.2f:%d\n", timeBuffer, temperature, humidity);
            file.close();
          } else {
            Serial.println("Failed to open file for writing");
          }
      } else {
          Serial.println("Error retrieving values from response");
      }
  } else {
      Serial.print("Failed to read Modbus registers. Error: ");
      Serial.println(response.getError());
  }
}

This function receives the data from the Modbus, if the reception fails it prints an error message otherwise it defines the variables used for the humidity (16-bit unsigned integer type) and for the temperature (float type) which is obtained after appropriate reception of the two 16-bit parts that compose it and which will be written in the two tempHigh and tempLow variables (also of the 16-bit unsigned integer type):

float temperature;
uint16_t humidity;
uint16_t tempHigh, tempLow;

An if block then follows which, with its condition and the following lines, decodes the values ​​received from the Modbus:

if (response.get(3, tempHigh) && response.get(5, tempLow) && response.get(7, humidity)) {
          uint32_t tempRaw = (tempHigh << 16) | tempLow;
          temperature = *(float*)&tempRaw;

          Serial.printf("Temperature: %.2f, Humidity: %d\n", temperature, humidity);

This if checks whether data from Modbus registers can be correctly read. The response.get() function extracts values ​​from the specified registers (3, 5 and 7) and assigns them to the tempHigh, tempLow, and humidity variables. The condition will be true only if all three readings are successful.

The next line combines the two 16-bit values, tempHigh and tempLow, into a single 32-bit value. tempHigh is shifted to the left by 16 bits (<< 16), while tempLow remains in the least significant 16 bits. The result is a 32-bit value representing the temperature in binary format.

In the next line the combined tempRaw value is reinterpreted as a float. This is done by forcing the compiler to treat tempRaw as a pointer to a float, then converting the 32-bit binary value to a floating-point decimal number (the actual temperature).

The last highlighted line above prints the temperature and humidity values ​​on the Serial Monitor. Temperature is displayed with two decimal places (%.2f), while humidity, which is an integer, is displayed without decimals (%d).

Also in the if block follows the part dedicated to the timestamp:

unsigned long epochTime = timeClient.getEpochTime();
struct tm *ptm = gmtime((time_t *)&epochTime);

// Formats the date and time in YYYY:MM:DD:HH:MM:SS format
char timeBuffer[20];
sprintf(timeBuffer, "%04d:%02d:%02d:%02d:%02d:%02d",
        ptm->tm_year + 1900, ptm->tm_mon + 1, ptm->tm_mday,
        ptm->tm_hour, ptm->tm_min, ptm->tm_sec);

Serial.println(timeBuffer);

This part creates the timestamp in YYYY:MM:DD:HH:MM:SS format to be placed alongside the temperature and humidity records in the storage file.

Then follows the actual writing on the file which is opened in append mode. On it, as the data arrives, a line is written containing the time stamp and the temperature and humidity values. All values ​​are separated by the “:” character to create a CSV file:

File file = SPIFFS.open(storageFilename, FILE_APPEND);
          if (file) {
            file.printf("%s:%.2f:%d\n", timeBuffer, temperature, humidity);
            file.close();
          } else {
            Serial.println("Failed to open file for writing");
          }

Then follows the setup function.

The serial port is immediately initialized:

// Open serial communications and wait for port to open:
Serial.begin(115200);
while (!Serial) {
; // wait for serial port to connect.
}
Serial.println("Serial is ready.");

and then the SPIFFS file system is initialized:

if (!SPIFFS.begin(true)) {
Serial.println("An error has occurred while mounting SPIFFS");
return;
}
Serial.println("SPIFFS mounted successfully");


delay(1000);

If SPIFFS initialization fails, execution stops.

Then follows the part that manages the WiFi connection:

// WiFi.mode(WIFI_STA); // explicitly set mode, esp defaults to STA+AP
// it is a good practice to make sure your code sets wifi mode how you want it.


//WiFiManager, Local intialization. Once its business is done, there is no need to keep it around
WiFiManager wm;

// reset settings - wipe stored credentials for testing
// these are stored by the esp library
// wm.resetSettings();

// Automatically connect using saved credentials,
// if connection fails, it starts an access point with the specified name ( "AutoConnectAP"),
// if empty will auto generate SSID, if password is blank it will be anonymous AP (wm.autoConnect())
// then goes into a blocking loop awaiting configuration and will return success result

bool res;
// res = wm.autoConnect(); // auto generated AP name from chipid
res = wm.autoConnect("AutoConnectAP"); // anonymous ap
//res = wm.autoConnect("ESP32_AP","password"); // password protected ap

if(!res) {
    Serial.println("Failed to connect");
    // ESP.restart();
} 
else {
    //if you get here you have connected to the WiFi    
    Serial.println("Connected...yeey :)");
    Serial.println("My WiFi IP is: ");
    Serial.print(WiFi.localIP());
    Serial.println();
}

As you can see, it contains various settings related to the WiFi server to first configure the device as an Access Point and then, once the network credentials have been entered, to connect it to our wireless network. We will see in more detail how the ESP32 connects to the WiFi network in a subsequent paragraph.

Now follows the management of the three REST APIs which are readfile, datetime and checkfile:

// API REST readfile: reads the data stored by the file system
server.on("/readfile", HTTP_GET, []() {
    Serial.println("API REST readfile...");
    String fileContent;
    File file = SPIFFS.open(storageFilename, FILE_READ);
    if (file) {
        while (file.available()) {
            fileContent += char(file.read());
        }
        file.close();
        server.send(200, "text/plain", fileContent);
    } else {
        server.send(500, "text/plain", "Failed to open file");
    }
});

The readfile opens the file for reading, reads it character by character and returns it as output to the client.

// API REST datetime: returns the current system date and time in YYYY:MM:DD:HH:MM:SS
server.on("/datetime", HTTP_GET, []() {
Serial.println("API REST datetime...");
unsigned long epochTime = timeClient.getEpochTime();
struct tm *ptm = gmtime((time_t *)&epochTime);

char timeBuffer[20];
sprintf(timeBuffer, "%04d:%02d:%02d:%02d:%02d:%02d", 
        ptm->tm_year + 1900, ptm->tm_mon + 1, ptm->tm_mday, 
        ptm->tm_hour, ptm->tm_min, ptm->tm_sec);
String dateTimeString = String(timeBuffer) + "\n";
server.send(200, "text/plain", dateTimeString);
});

The datetime reads the NTP server and obtains the date and time, returning them to the client in YYYY:MM:DD:HH:MM:SS format.

// API REST checkfile: checks if the storage file exists
server.on("/checkfile", HTTP_GET, []() {
Serial.println("API REST checkfile...");
if (SPIFFS.exists(storageFilename)) {
    server.send(200, "text/plain", "File exists\n");
} else {
    server.send(404, "text/plain", "File not found\n");
}
});

The checkfile checks whether the storage file is present or not. It is used as debugging in case of storage failures.

The webserver for the REST API is then started:

  server.begin();
  Serial.println("Web Server started");

The Modbus server is then started:

MBclient.onDataHandler(handleData);
MBclient.setTarget(IPAddress(192, 168, 1, 190), 502);  // Sets the IP address and port of the Raspberry Pi that transmits ModBus data
MBclient.begin();
Serial.println("Modbus TCP Client started");

In it the Raspberry IP is set (we have already seen in a previous paragraph how to make the Raspberry IP fixed) and port 502, typical of the Modbus protocol.

Finally the timeClient is started:

timeClient.begin();

Then follows the loop function.

Initially, the webserver function that manages REST requests is called and the currentMillis variable is defined with the current value in milliseconds:

server.handleClient();

unsigned long currentMillis = millis();

Then follows an if block that every logInterval milliseconds executes the checkFileSize function:

if (currentMillis - previousLogMillis >= logInterval) {
    previousLogMillis = currentMillis;
    checkFileSize();
}

Then comes the block

#ifdef PRINTFILE
    if (currentMillis - previousPrintMillis >= printInterval) {
    previousPrintMillis = currentMillis;
    printFileContent();
    }
#endif

which is included and compiled, as already mentioned above, if the PRINTFILE macro is defined and which is used to print the storage file on the Serial Monitor every printInterval milliseconds.

Finally, the timeClient is updated and a Modbus request is sent from the client (in our case the ESP32) to the server device (our Raspberry) to read the data contained in the server’s Holding Registers:

timeClient.update();

Serial.println("Sending Modbus request...");
MBclient.addRequest(1, 1, READ_HOLD_REGISTER, 0, 3);

delay(1000);

Once the loop is finished we have the last three functions.

void checkFileSize() {
  File file = SPIFFS.open(storageFilename, FILE_READ);
  if (file) {
    size_t fileSize = file.size();
    Serial.print("Current file size: ");
    Serial.println(fileSize);
    file.close();

    if (fileSize > maxFileSize) {
      removeOldEntries();
    }
  }
}

The checkFileSize function checks the size of the file. If it is greater than the value set in maxFileSize, call the removeOldEntries function. The maxFileSize constant is set to 1024. Obviously this value can be decreased or increased (but taking into account the fact that the device’s file system is limited and an excessively large value could lead to its blocking). I recommend varying this value by trial and error to find the optimal one for your purposes.

void removeOldEntries() {
  Serial.println("File too big. Resizing...");
  File file = SPIFFS.open(storageFilename, FILE_READ);
  if (file) {
    String content;
    while (file.available()) {
      content += char(file.read());
    }
    file.close();

    int linesToRemove = 10; // Number of rows to remove
    int index = 0;
    for (int i = 0; i < linesToRemove; i++) {
      index = content.indexOf('\n', index) + 1;
    }
    content = content.substring(index);

    file = SPIFFS.open(storageFilename, FILE_WRITE);
    if (file) {
      file.print(content);
      file.close();
    }
  }
}

The removeOldEntries function opens the file and removes the first linesToRemove lines, i.e. the oldest. In this case the value is equal to 10 but can be changed as needed.

The last function

void printFileContent() {
  File file = SPIFFS.open(storageFilename, FILE_READ);
  if (file) {
    Serial.println("File content:");
    while (file.available()) {
      Serial.write(file.read());
    }
    file.close();
    Serial.println();
  } else {
    Serial.println("Failed to open file for reading");
  }
}

reads the storage file and prints its contents to the Serial Monitor.

Once we understand how the sketch works, all we have to do is upload it to the ESP32 (without forgetting to upload the logdata.txt file with the procedure explained above). Let’s activate the Serial Monitor and connect the board to WiFi following the next paragraph.

How to connect the board to the Internet

After uploading the sketch to the board, open the Serial Monitor to see the messages coming from the device.

First the board goes into Access Point mode and will provide us with an IP address that we will use shortly. This operation is used to connect the board to the Internet without having to enter the WiFi network parameters (SSID and password) in the code.

The board provides us with its IP address
The board provides us with its IP address

In this case the IP address is 192.168.4.1.

At this point the ESP32 is in Access Point mode (with AutoConnectAP SSID) and we need to connect our computer to the AutoConnectAP network. If we go to the networks menu of our computer, we should also see the AutoConnectAP network in the list of wireless networks.

List of available WiFi networks
List of available WiFi networks

Connect your computer to the AutoConnectAP network. Then go to your browser and enter the IP previously provided by the ESP32 (which in this example is 192.168.4.1)

You will see a screen like this:

The browser screen for choosing the network
The browser screen for choosing the network

Click the ConfigureWiFi button. It will show you the available networks:

List of available networks
List of available networks

Choose your network’s SSID:

Choose your network
Choose your network

Enter your network password and click the save button:

Enter your password
Enter your password

The board's response
The board’s response

The ESP32 module keeps the access parameters stored even if you turn it off, it will remember them when restarting and will automatically reconnect without having to repeat this procedure. Only if you reset it by uncommenting this line

// wm.resetSettings();

will lose the connection parameters.

Among the various messages that will be printed on the Serial Monitor, the WiFiManager library will also tell us which IP has been assigned to it by the WiFi modem.

Please note: the device can only memorize one network. If you later connect it to another network, it will forget the settings of the previous network.

It is important to note down the IP communicated by the ESP32 and which was assigned to it by the WiFi modem because we will need it to use the REST API.

At this point you can launch the python script on the Raspberry as we have already seen with the command sudo python3 modbus_server_raspberry.py

The output of the script will be much more complex than the previous time as it also contains the debug messages for the communication between the two devices:

Python script output
Python script output

The PlatformIO Serial Monitor will instead show us an output like this:

Sketch output to PlatformIO
Sketch output to PlatformIO

If the PRINTFILE macro is defined you will see the storage file print on the Serial Monitor every printInterval milliseconds.

We can see how it works in the following video:

Let’s test REST APIs

Once the ESP32 has been connected to the WiFi network it will provide us with its IP address via the PlatformIO Serial Monitor, as visible in the following figure:

We obtain the IP of the board
We obtain the IP of the board

In this case the IP assigned by the router to the board is 192.168.1.128. It is absolutely not certain that in your case the same IP will be assigned (in fact, it will most likely be different). This IP will be used to compose the REST API.

As already mentioned above, to interact with the board we need special software called Postman. After installing the program, we are ready to use it.

This is what its home screen looks like:

Postman home screen
Postman home screen

In the main window there is a bar where you will need to enter the API.

To the left of this bar there is a drop-down menu that allows you to choose the type of API (for example GET, POST, PUT…).

Now choose the GET type and insert the API checkfile that will have the following format:

http://IP_ESP32/checkfile

For example, in this case, since the assigned IP is 192.168.1.128, the API URL will be:

http://192.168.1.128/checkfile

Obviously you will have to enter the IP address assigned to your ESP32.

Press the Send button on the right.

The API will return a message telling you whether the storage file is present or not, as shown in the following image:

checkfile API
checkfile API

Now let’s try the datetime API with

http://192.168.1.128/datetime

We see the result in the following image:

datetime API
datetime API

Let’s try the readfile API now:

http://192.168.1.128/readfile
readfile API
readfile API

The following video gives us a live demonstration of how the rest API works:

In conclusion, this project represents a practical and complete example of how to integrate open-source technologies to create an efficient and automated environmental monitoring system. Using components such as the Raspberry Pi, the ESP32, and the Modbus protocol, we were able to build a system capable of collecting data, storing it securely, and making it accessible via REST API. The simplicity of implementation, combined with the flexibility and power of these technologies, demonstrates how it is possible to create advanced IoT solutions with accessible tools. This project not only illustrates interoperability between devices, but also highlights the importance of efficient data management and data accessibility, key features in any modern IoT system.

Newsletter

If you want to be informed about the release of new articles, subscribe to the newsletter. Before subscribing to the newsletter read the page Privacy Policy (UE)

If you want to unsubscribe from the newsletter, click on the link that you will find in the newsletter email.

Enter your name
Enter your email

0 0 votes
Article Rating
guest
0 Comments
Oldest
Newest Most Voted
Inline Feedbacks
View all comments
0
Would love your thoughts, please comment.x
Scroll to Top