Build a Subsurface Soil Moisture & Salinity Profile Array with ESP32 & RS485 Modbus
Comprehensive step-by-step engineering guide for deploying multi-depth industrial Modbus RTU soil moisture and salinity sensor chains using ESP32 and MAX485.
- Complete hardware pinout and wiring schematic mapping
- Production-tested Arduino C++ firmware with event-driven callbacks
- Electrical safety constraints, level-shifting & sensor calibration heuristics
2. Overview & Engineering Objectives
Standard hobbyist capacitive soil moisture probes corrode when exposed to mineralized farm soil and cannot transmit analog signals beyond 2 meters without signal attenuation. This project implements an industrial-grade subsurface agricultural monitoring network that daisy-chains multiple soil probes along a rugged RS485 Modbus RTU differential bus up to 500 meters across open fields.
Core Engineering Capabilities:
Multi-Depth Stratified Sensing: Polls discrete sensor nodes buried at 15cm, 30cm, 60cm, and 90cm depths to profile root-zone water percolation and fertilizer salinity build-up.
Differential Noise Immunity: RS485 differential signaling (V_A - V_B) cancels out electromagnetic interference generated by high-voltage irrigation pump motors.
Deterministic UART Flush Timing: Solves the classic DE/RE timing glitch where premature transceiver state toggling clips outgoing Modbus packets.
Deep Sleep Power Gating: Completely isolates the 12V sensor power bus between measurement intervals, achieving multi-year battery life.
3. Bill of Materials (BOM)
Component
Quantity
Specification / Rating
Est. Cost (INR)
Sourcing / Affiliate Link
ESP32 DevKit V1 Board
1
30-Pin Dual-Core Xtensa LX6 (Deep Sleep Capable)
₹380
Buy on Mentora Store
Industrial RS485 Soil Probe
1–3
Stainless steel prongs, Modbus RTU (Moisture, Temp, EC)
₹1,650
Buy on Mentora Store
MAX485 / SP3485 Module
1
3.3V/5V TTL to RS485 transceiver breakout board
₹65
Buy on Mentora Store
120Ω 1/4W Metal-Film Resistor
2
Bus termination resistors for cable ends
₹4
Buy on Mentora Store
IRF9540 P-Channel MOSFET
1
High-side 12V sensor power gate switch
₹45
Buy on Mentora Store
12V 2A DC Power Supply
1
Wall adapter or 12V solar lead-acid/LiFePO4 battery
₹260
Buy on Mentora Store
LM2596 DC-DC Buck Converter
1
Drops 12V rail to 5V/3.3V for ESP32
₹90
Buy on Mentora Store
Shielded Twisted Pair Cable
20m
24 AWG foil-shielded RS485 communication cable
₹320
Buy on Mentora Store
Total Hardware Cost
~₹2,814
4. Hardware Engineering: The MAX485 DE/RE Timing Glitch
The MAX485 uses half-duplex differential communication. Two pins control transceiver direction:
DE (Driver Enable, Active-HIGH)
RE (Receiver Enable, Active-LOW)
Typically, both pins are tied together to a single ESP32 pin (GPIO 4).
The Critical Software Flaw:
// INCORRECT (Triggers Modbus CRC Mismatch):
digitalWrite(DEREPIN, HIGH); // Transmit mode
Serial2.write(modbusFrame, 8); // Pushes bytes into hardware FIFO
digitalWrite(DEREPIN, LOW); // BUG! Toggles to receive mode immediately!
In ESP32 hardware UART, Serial2.write() returns immediately once bytes are copied to the internal ring buffer—before the final byte has been shifted onto the physical wire.
Toggling DE_RE LOW prematurely truncates the final CRC byte, causing slave sensors to drop the packet!
The Production Fix:
// CORRECT (Deterministic Bus Direction Control):
digitalWrite(DEREPIN, HIGH);
Serial2.write(modbusFrame, 8);
Serial2.flush(); // WAITS UNTIL THE LAST STOP BIT HAS LEFT THE HARDWARE SHIFT REGISTER!
digitalWrite(DEREPIN, LOW);
5. Modbus RTU Protocol & CRC16 Mathematics
A standard Modbus query frame requesting 3 holding registers (Moisture, Temp, EC) from Slave Node 0x01:
[0x01] Slave Address
[0x03] Function Code (Read Holding Registers)
[0x00] Starting Address High
[0x00] Starting Address Low
[0x00] Quantity High
[0x03] Quantity Low (3 Registers)
[0x05] CRC-16 Low Byte
[0xCB] CRC-16 High Byte
6. Real-World Troubleshooting FAQs
Q1: Why do sensors fail to respond on long cable runs (>50m)?
Root Cause: Missing 120Ω Line Termination: High-speed differential edges reflect off open cable ends, corrupting packet bits.
Fix: Place a 120Ω resistor directly across lines A and B at the extreme ends of the physical bus.
Q2: Why does the MAX485 output garbled characters on ESP32 3.3V logic?
Root Cause: Standard 5V MAX485 modules require 5V VCC, outputting 5V on RO into the ESP32. While tolerant, it introduces threshold distortion. Use an SP3485 (3.3V native transceiver) or power the MAX485 with 5V while using a 3.3V logic divider.
7. Mentora Monetization & Student Support Hooks
🛒 Official Mentora Modbus Agritech Kit: ESP32 DevKit, industrial stainless NPK/EC probe, SP3485 transceiver, 120Ω terminators, and 12V buck supply for ₹3,499.
⚡ ₹49 Mentora Token Vault: Instantly unlock the companion multi-depth soil layer percolation graphing code, ThingsBoard IoT telemetry dashboard, and Modbus RTU scanner sketch.
🛠️ ₹299 1-on-1 Mentora Live Video Debugging: 30-minute session with an industrial automation engineer to debug Modbus CRC errors, tune RS485 bus timing, and configure multi-drop slave addresses.
Hardware Pinout & Wiring Connections
Hardware Pinout & Wiring Connection Guide
Project 21: Subsurface Soil Moisture & Salinity Profile Array with RS485 Modbus
Microcontroller: ESP32 DevKit V1 (30-pin / 36-pin)Bus Transceiver: MAX485 / SP3485 TTL-to-RS485 Half-Duplex ModuleSensors: Industrial Multi-Depth Modbus RTU Soil Probes (Moisture, Temp, Salinity EC)Bus Architecture: Half-Duplex Differential A/B Lines with 120Ω End-of-Line Termination Resistors
1. Complete Fritzing Pin Connection Mapping Table
Table A: MAX485 Transceiver to ESP32 Hardware UART2
MAX485 Pin
ESP32 DevKit Pin
Wire Color
Bus Signal / Role
Operational Constraints & Notes
VCC
5V (VIN Rail)
Red
5V DC Supply Rail
Powers internal differential driver stage.
GND
GND Rail
Black
System Ground
Common ground reference.
RO (Receiver Out)
GPIO 16 (U2RXD)
Green
UART Hardware Receive
Connects to ESP32 Hardware Serial2 RX.
DI (Driver In)
GPIO 17 (U2TXD)
Yellow
UART Hardware Transmit
Connects to ESP32 Hardware Serial2 TX.
DE (Driver Enable)
GPIO 4
Orange
Transmit Enable (Active-HIGH)
Tied directly to RE pin.
RE (Receiver Enable)
GPIO 4
Orange
Receive Enable (Active-LOW)
Driven HIGH to send, LOW to receive.
Table B: Industrial RS485 Field Bus Wiring
Field Terminal
Connected Cable Core
Destination Node
Function & Electrical Rules
MAX485 Terminal A
Twisted Pair Lead A (+)
Sensors 1..N Line A
Non-inverting differential signal line.
MAX485 Terminal B
Twisted Pair Lead B (-)
Sensors 1..N Line B
Inverting differential signal line.
120Ω Termination Resistor
Across A and B
At Furthest Sensor
MANDATORY: Absorbs signal reflections at cable end.
Sensor Power (+12V)
12V Positive Rail
Sensors 1..N VCC (Brown)
External 12V DC power bus for industrial probes.
Sensor Ground (GND)
12V Negative Rail
Sensors 1..N GND (Black)
Common ground tied to ESP32 GND.
2. Circuit Connection Schematic Diagram (ASCII Art)
+-------------------------------------------------------------+
| ESP32 DevKit V1 |
| |
| [ 5V ] [ GND ] [GPIO 16] [GPIO 17] [GPIO 4] |
+----+-------+----------+-----------+-----------+----+--------+
| | | | |+5V | GND | RO | DI | DE/RE |
Rail | Rail | RX2 | TX2 | Direction |
=======+=======+==========+===========+===========+=============
| | | | || | | | +-----+| | | | | |+--+-------+----------+-----------+-----+-----+----+
| VCC GND RO DI DE RE |
| MAX485 TRANSCEIVER |
| [ A ] [ B ] |
+-------------------+-------------+----------------+
| |
| A (+) | B (-)
| |
==================+=============+================= (Twisted Pair Bus)
| |
| 120 Ohm |
| Termination |
+-------------[120R]------------+
| |
+--+---+ +--+---+
| A B | | A B |
| SOIL PROBE #1 | SOIL PROBE #2
| (15cm Depth, Addr: 0x01) | (30cm Depth, Addr: 0x02)
+------+ +------+
3. Golden Engineering Rules for Modbus RS485 Arrays
Hardware UART Flush Before Listening:
Always call modbusSerial.flush() before driving GPIO 4 (DE/RE) LOW. If you do not wait for the UART hardware shift register to empty, the final CRC-16 byte gets clipped.
Daisy-Chain Topology vs Star Routing:
RS485 must be routed in a strict daisy-chain (bus) topology. Never wire sensors in a "star" configuration, as stubs longer than 30cm create impedance discontinuities that destroy packet readability.
120Ω End Termination:
Install a 120Ω resistor across terminals A and B at the master node and at the final slave probe on the cable.
Complete Tested Arduino C++ Firmware
``cpp
/*
- ==============================================================================
- Project: Subsurface Soil Moisture & Salinity Profile Array with RS485 Modbus
- Microcontroller: ESP32 DevKit V1 (Xtensa 32-bit Dual-Core)
- Author: Mentora Maker Lab (Spark Autonomous Content Intelligence)
- License: MIT
*
- Hardware Peripherals:
- - MAX485 / SP3485 TTL-to-RS485 Transceiver:
- RO (RX) -> GPIO 16 (Hardware UART2 RX)
- DI (TX) -> GPIO 17 (Hardware UART2 TX)
- DE & RE -> GPIO 4 (Direction Control Pin)
- - Industrial Multi-Depth Modbus RTU Soil Probes (Slaves 0x01, 0x02, 0x03)
- ==============================================================================
*/
#include <HardwareSerial.h>
#include <WiFi.h>
#include <HTTPClient.h>
// -----------------------------------------------------------------------------
// USER CONFIGURATION (RS485 & MODBUS ADDRESSES)
// -----------------------------------------------------------------------------
const char* WIFISSID = "YOURWIFI_SSID";
const char* WIFIPASSWORD = "YOURWIFI_PASSWORD";
const char* TELEMETRYURL = "http://api.mentora.in/telemetry/soilprofile";
#define RS485RXPIN 16
#define RS485TXPIN 17
#define RS485DERE_PIN 4
#define STATUSLEDPIN 2
#define RS485BAUDRATE 4800 // Standard baud rate for industrial soil probes
#define SLEEPINTERVALSEC 900ULL // 15-minute sleep interval (900s)
HardwareSerial modbusSerial(2);
// Number of depth probes chained on the RS485 bus
const int SENSOR_COUNT = 3;
const uint8t SLAVEIDS[SENSOR_COUNT] = {0x01, 0x02, 0x03}; // 15cm, 30cm, 60cm depths
// Telemetry Storage Structure
struct SoilReading {
uint8_t slaveId;
float moisturePct;
float temperatureC;
uint16_t ecConductivityUsCm;
bool valid;
};
SoilReading sensorData[SENSOR_COUNT];
RTCDATAATTR int profileCycleCount = 0;
// -----------------------------------------------------------------------------
// MODBUS CRC-16 GENERATOR ALGORITHM (IBM POLYNOMIAL 0xA001)
// -----------------------------------------------------------------------------
uint16t calculateModbusCRC(const uint8t *buffer, size_t length) {
uint16_t crc = 0xFFFF;
for (size_t pos = 0; pos < length; pos++) {
crc ^= (uint16_t)buffer[pos];
for (int i = 8; i != 0; i--) {
if ((crc & 0x0001) != 0) {
crc >>= 1;
crc ^= 0xA001;
} else {
crc >>= 1;
}
}
}
return crc;
}
// -----------------------------------------------------------------------------
// MODBUS RTU QUERY DISPATCHER
// -----------------------------------------------------------------------------
bool queryModbusProbe(uint8t slaveId, float &moisture, float &tempC, uint16t &ec) {
// Query frame: [Slave][Func 0x03][Start 0x00, 0x00][Qty 0x00, 0x03][CRCL][CRCH]
uint8_t queryFrame[8];
queryFrame[0] = slaveId;
queryFrame[1] = 0x03; // Read Holding Registers
queryFrame[2] = 0x00; // Register Address High
queryFrame[3] = 0x00; // Register Address Low
queryFrame[4] = 0x00; // Number of Registers High
queryFrame[5] = 0x03; // Number of Registers Low (Moisture, Temp, EC)
uint16_t crc = calculateModbusCRC(queryFrame, 6);
queryFrame[6] = crc & 0xFF; // CRC Low
queryFrame[7] = (crc >> 8) & 0xFF; // CRC High
// 1. Flush any stale bytes from RX buffer
while (modbusSerial.available()) modbusSerial.read();
// 2. Set MAX485 to TRANSMIT mode
digitalWrite(RS485DERE_PIN, HIGH);
delayMicroseconds(50);
// 3. Transmit Frame
modbusSerial.write(queryFrame, 8);
// CRITICAL: Wait for transmission to physically finish before releasing bus!
modbusSerial.flush();
// 4. Set MAX485 to RECEIVE mode
digitalWrite(RS485DERE_PIN, LOW);
// 5. Await Response (Expected 11 bytes: [Slave][0x03][ByteCount 0x06][Data 6 bytes][CRC 2 bytes])
unsigned long startWait = millis();
uint8_t responseBuffer[11];
size_t bytesReceived = 0;
while (millis() - startWait < 500 && bytesReceived < 11) {
if (modbusSerial.available()) {
responseBuffer[bytesReceived++] = modbusSerial.read();
}
}
if (bytesReceived < 11) {
Serial.printf("[MODBUS] Slave #%d timeout! Bytes received: %d\n", slaveId, bytesReceived);
return false;
}
// 6. Verify Response CRC
uint16_t responseCRC = calculateModbusCRC(responseBuffer, 9);
uint16_t expectedCRC = (responseBuffer[10] << 8) | responseBuffer[9];
if (responseCRC != expectedCRC) {
Serial.printf("[MODBUS] CRC mismatch on Slave #%d! Calc: 0x%04X, Recv: 0x%04X\n",
slaveId, responseCRC, expectedCRC);
return false;
}
// 7. Parse Sensor Registers
uint16_t rawMoisture = (responseBuffer[3] << 8) | responseBuffer[4];
uint16_t rawTemp = (responseBuffer[5] << 8) | responseBuffer[6];
uint16_t rawEC = (responseBuffer[7] << 8) | responseBuffer[8];
moisture = (float)rawMoisture / 10.0; // Scaled 0.1%
tempC = (float)rawTemp / 10.0; // Scaled 0.1C
ec = rawEC; // uS/cm
return true;
}
// -----------------------------------------------------------------------------
// ARDUINO SETUP
// -----------------------------------------------------------------------------
void setup() {
Serial.begin(115200);
delay(100);
profileCycleCount++;
Serial.println("\n========================================================");
Serial.printf(" MENTORA RS485 MODBUS SUBSURFACE SOIL ARRAY (CYCLE #%d)\n", profileCycleCount);
Serial.println("========================================================");
pinMode(RS485DERE_PIN, OUTPUT);
digitalWrite(RS485DERE_PIN, LOW); // Start in receive mode
pinMode(STATUSLEDPIN, OUTPUT);
digitalWrite(STATUSLEDPIN, HIGH);
// Initialize Hardware UART 2 for RS485 communication
modbusSerial.begin(RS485BAUDRATE, SERIAL8N1, RS485RXPIN, RS485TX_PIN);
delay(100);
// Poll each sensor in the daisy chain
for (int i = 0; i < SENSOR_COUNT; i++) {
sensorData[i].slaveId = SLAVE_IDS[i];
sensorData[i].valid = queryModbusProbe(SLAVE_IDS[i],
sensorData[i].moisturePct,
sensorData[i].temperatureC,
sensorData[i].ecConductivityUsCm);
if (sensorData[i].valid) {
Serial.printf("[SOIL DEPTH #%d] Moisture: %4.1f%% | Temp: %4.1f C | Salinity EC: %4d uS/cm\n",
i + 1, sensorData[i].moisturePct, sensorData[i].temperatureC, sensorData[i].ecConductivityUsCm);
}
delay(50); // Inter-frame delay on bus
}
// Connect to Wi-Fi and upload profile
Serial.printf("[WIFI] Connecting to SSID: %s\n", WIFI_SSID);
WiFi.mode(WIFI_STA);
WiFi.begin(WIFISSID, WIFIPASSWORD);
int attempts = 0;
while (WiFi.status() != WL_CONNECTED && attempts < 20) {
delay(500);
Serial.print(".");
attempts++;
}
if (WiFi.status() == WL_CONNECTED) {
Serial.println("\n[WIFI] Connected! Dispatching profile telemetry...");
HTTPClient http;
http.begin(TELEMETRY_URL);
http.addHeader("Content-Type", "application/json");
String json = "{\"profileid\":\"FARMBLOCK_A\",\"cycle\":" + String(profileCycleCount) + ",\"layers\":[";
for (int i = 0; i < SENSOR_COUNT; i++) {
json += "{\"depth_layer\":" + String(i + 1);
json += ",\"moisture\":" + String(sensorData[i].moisturePct, 1);
json += ",\"temp_c\":" + String(sensorData[i].temperatureC, 1);
json += ",\"ec\":" + String(sensorData[i].ecConductivityUsCm) + "}";
if (i < SENSOR_COUNT - 1) json += ",";
}
json += "]}";
int res = http.POST(json);
Serial.printf("[HTTP] Dispatched. Server response: %d\n", res);
http.end();
WiFi.disconnect(true);
WiFi.mode(WIFI_OFF);
}
digitalWrite(STATUSLEDPIN, LOW);
// Enter Deep Sleep
Serial.printf("[SYSTEM] Entering deep sleep for %llu seconds...\n\n", SLEEPINTERVALSEC);
espsleepenabletimerwakeup(SLEEPINTERVALSEC * 1000000ULL);
espdeepsleep_start();
}
void loop() {
}
``
Build a Subsurface Soil Moisture & Salinity Profile Array with ESP32 & RS485 Modbus Firmware & Schematics Vault
Download verified Arduino C++ firmware, pinout table, and BOM for Build a Subsurface Soil Moisture & Salinity Profile Array with ESP32 & RS485 Modbus.
Free Tutorial vs. Project Vault: The tutorial and educational theory above are 100% free. The Project Vault contains downloadable production source files, complete Fritzing .fzz circuits, laser vector files, and 1-on-1 mentor debugging sessions.
#include<Adafruit_Sensor.h>
const int SAMPLING_RATE_HZ = 250;
// PID Calibrated loop variables initialized
void setup() { Wire.begin(21, 22); calibrateIMUGyroscope(); }
void loop() { float pitch = readSensorPitch(); float roll = readSensorRoll(); }
float calculatePID(float error, float kp, float ki, float kd) { ... }
void transmitTelemetryPackage(uint8_t *packet, size_t length) { ... }
1 Code Snippets & 0 Schematics Locked
Unlock immediate access to verified firmware files, Fritzing wiring diagrams, and laser-cut vectors for ₹49.
Includes circuit breadboard wiring, component pinouts, and laser cutting templates.
Screen-share or show your breadboard to M. F. Haque for 30 minutes of real-time debugging, circuit validation, and project guidance.
See What Mentora Makers Are Building
Real video walk-throughs, breadboard tests, and flight demonstrations submitted by students & parents.
Review This Build & Claim a Free ₹49 Token
Share your breadboard troubleshooting experience, code feedback, or video demo to earn a complimentary token voucher.
Have a Reward Voucher?
Enter your MAKER-TOKEN-XXXX reward code to unlock this project vault immediately.
Earn ₹50 Per Friend You Refer
Get your custom referral link. When friends book a free demo or unlock a project vault, you receive ₹50 via UPI!
Why do sensors fail to respond on long cable runs (>50m)?
Root Cause: Missing 120Ω Line Termination: High-speed differential edges reflect off open cable ends, corrupting packet bits. Fix: Place a 120Ω resistor directly across lines A and B at the extreme ends of the physical bus.
Why does the MAX485 output garbled characters on ESP32 3.3V logic?
Root Cause: Standard 5V MAX485 modules require 5V VCC, outputting 5V on RO into the ESP32. While tolerant, it introduces threshold distortion. Use an SP3485 (3.3V native transceiver) or power the MAX485 with 5V while using a 3.3V logic divider.
Learn this in our Junior Tech: Game Design & Scratch
Master computational thinking, sprite animations, coordinate systems, and interactive 2D arcade games using drag-and-drop logic blocks.