Dirt Nerd: Know What Your Soil Needs Before You Grow

by ayushmaan45 in Outside > Backyard

1178 Views, 13 Favorites, 0 Comments

Dirt Nerd: Know What Your Soil Needs Before You Grow

InShot_20260804_175710017.gif
20260804_172421.jpg
20260804_173757.jpg
20260804_173752.jpg

Hi! I'm Ayushmaan, a B.Tech Electrical and Electronics Engineering student who loves building things, especially projects that bring together electronics, design, and a bit of code.

For this project, I wanted to build something around a pretty simple question: if I want to grow a particular plant, how do I know whether the soil I have is actually good for it?

Most soil monitoring projects can give you readings like moisture, temperature, or pH, but those numbers alone don't mean much if you don't know what your plant actually needs. So, I decided to take this a step further and built a portable soil analyzer that not only measures the soil but also helps make sense of those readings.

The device measures different soil parameters and sends them to a web dashboard. From there, you can select the crop or plant you want to grow, and the dashboard compares the current soil conditions with what that plant prefers. It then points out what's good, what might be lacking, and what you can do to improve the soil.

I wanted the whole thing to be simple enough that you don't need to understand soil chemistry just to figure out whether your backyard is ready for some tomatoes.

So, in this Instructable, I'll take you through how I built the electronics, designed the enclosure, put together the firmware and dashboard, and eventually tested the whole thing with some actual soil.

Let's get started!

Supplies

WhatsApp Image 2026-08-04 at 3.50.37 PM (2).jpeg
WhatsApp Image 2026-08-04 at 3.50.38 PM (2).jpeg

Electronics

  1. Seeed Studio XIAO ESP32-C6
  2. 7-in-1 RS485 NPK soil sensor
  3. MAX485 RS485-to-UART module
  4. One WS2812 addressable RGB LED
  5. External power supply suitable for the soil sensor
  6. USB-C data cable
  7. Jumper wires
  8. Small screws for joining the enclosure
  9. Optional 120-ohm RS485 termination resistor

Fabrication

  1. Anycubic Kobra S1 3D printer
  2. Tri-colour PLA filament
  3. Sandpaper in a few grit
  4. Green spray paint
  5. Basic hand tools

Software

  1. Fusion 360
  2. Arduino IDE 2.x
  3. Next.js and React
  4. Node.js

How the Project Works

Dirt Nerd has four main parts: the soil sensor, the communication electronics, the ESP32 firmware and the web dashboard.

The 7-in-1 probe is inserted into the soil and measures moisture, temperature, pH, EC and NPK values.

It sends the measurements using RS485 Modbus communication.

The MAX485 converts that differential RS485 signal into UART signals that the XIAO ESP32-C6 can understand.

The ESP32 reads seven Modbus registers, converts the raw numbers into useful units and stores the most recent valid reading.

It then creates a small HTTP server on the local Wi-Fi network.

The website requests the readings from the ESP32 through the /data endpoint.

The complete data path is:

Soil
7-in-1 RS485 sensor
MAX485 module
XIAO ESP32-C6
↓ Wi-Fi and HTTP JSON
Dirt Nerd website
Crop compatibility and recommendations

The addressable LED provides a simple connection indicator.

  1. Red means the ESP32 is not connected to Wi-Fi.
  2. Green means it has connected and started the local web server.

The dashboard can operate in Demo or Live mode.

Demo mode is useful for demonstrating the interface without the physical sensor. The readings can also be changed manually from Settings.

Live mode requests the latest values from the IP address shown in the ESP32 Serial Monitor.

After the readings appear, the user selects a crop. Changing the crop changes the stored ideal ranges, compatibility message and recommendations.

The current prototype stores the crop profiles locally. This keeps the interface usable without depending on an external plant database.

Designing the Enclosure

Screenshot 2026-08-04 171641.png
Screenshot 2026-08-04 171656.png
Screenshot 2026-08-04 171848.png

I designed the enclosure in Fusion 360.

My first idea was to make the body resemble a water droplet because the project is connected to soil, plants and water.

As I continued changing the curves and making room for the electronics, the enclosure unintentionally started looking a little like a squid.

It was not part of the original plan, but I liked that it gave the project its own character, so I kept the shape.

The case was divided into two screw-together halves.

While modelling it, I had to leave space for the XIAO ESP32-C6, MAX485 module, wiring, status LED and the sensor cable.

I also added screw points around the enclosure so the two sides could be opened again if the electronics needed adjustment.

The curved exterior made the product look friendlier, but it also meant checking internal clearance carefully. Components that fit inside a rectangular box can interfere with curved walls near the edges.

Before printing, I checked the component dimensions and used the section view in Fusion 360 to look for collisions.

3D Printing

ezgif-595ceb233beb8020.gif
WhatsApp Image 2026-08-04 at 3.50.38 PM.jpeg
WhatsApp Image 2026-08-04 at 3.50.38 PM (1).jpeg

I printed the enclosure on an Anycubic Kobra S1 using tri-colour PLA.

I originally chose the filament because I expected the changing colours to make the curved enclosure look more interesting.

For a practical starting profile, I used settings suitable for a normal PLA enclosure:

  1. 0.20 mm layer height
  2. 3 wall loops
  3. 4 top and bottom layers
  4. 15% gyroid infill
  5. Supports only where required by the curved overhangs
  6. Brim for the parts with a smaller contact area
  7. Approximately 210°C nozzle temperature
  8. Approximately 60°C bed temperature

These values may need minor adjustment for a different PLA brand or slicer profile.

I oriented the two halves to keep the visible outer surfaces as clean as possible and to reduce unnecessary support material inside the component area.

The parts printed successfully, but the tri-colour effect did not look as good as I had imagined.

The colour changes distracted from the shape and made the surface appear less consistent. Instead of reprinting the complete enclosure immediately, I decided to finish the existing print with paint.

Painting and Finishing

InShot_20260804_155304268.gif
WhatsApp Image 2026-08-04 at 3.50.37 PM.jpeg
WhatsApp Image 2026-08-04 at 3.50.37 PM (1).jpeg
InShot_20260804_155540105.gif

I began by removing the supports and cleaning the edges of both printed halves.

The surfaces were sanded gradually until the most visible layer lines and rough areas were reduced.

I paid extra attention to the seam where the two sides meet because any unevenness there would remain obvious after assembly.

After sanding, I removed the dust and applied a light primer suitable for plastic.

The primer helped reveal scratches and low areas that were difficult to see on the tri-colour filament.

I sanded those areas again and then applied several light coats of green spray paint.

Light coats worked better than one heavy coat because they reduced runs and preserved the enclosure details.

The final green finish looked much cleaner and matched the gardening theme far better than the original tri-colour surface.

I allowed the paint to cure completely before installing the electronics so the finish would not be marked during assembly.

Electronics

WhatsApp Image 2026-08-04 at 3.50.36 PM (2).jpeg
WhatsApp Image 2026-08-04 at 3.50.36 PM (3).jpeg
FYNF95HMS6P30Q8.jpg

XIAO ESP32-C6

I selected the XIAO ESP32-C6 because it is compact, has built-in Wi-Fi and still provides enough pins for the RS485 interface and status LED.

Its small size also made it easier to fit everything inside the custom enclosure.

7-in-1 Soil Sensor

The soil probe combines seven measurements in one body: moisture, temperature, pH, electrical conductivity, nitrogen, phosphorus and potassium.

Using one industrial-style probe reduced the number of separate sensors and wires inside the project.

The sensor communicates over RS485 rather than producing seven separate analogue signals. This is useful for longer cables and outdoor environments, but it means the ESP32 needs an RS485 interface.

MAX485 Module

The MAX485 converts the sensor’s RS485 communication into UART serial data.

Its DE and /RE pins are tied together so one ESP32 pin can switch the module between transmitting a request and receiving the response.

A traditional MAX485 module normally runs at 5V. Its RO output should not be connected directly to a 3.3V ESP32 input.

I used level shifting between the module and XIAO. A 3.3V-compatible MAX3485 module can also be used as a cleaner alternative.

WS2812 LED

I added a single WS2812 RGB LED because it can display the device state using only one data pin.

The LED is red while Wi-Fi is disconnected and changes to green after the ESP32 connects.

Power and Ground

The soil sensor uses its own suitable external supply.

The XIAO, MAX485, sensor supply and LED must share a common ground. Without a common reference, RS485 communication can become unreliable even when the A and B wires are correct.

Circuit Diagram

WhatsApp Image 2026-08-04 at 3.50.36 PM.jpeg
InShot_20260804_160152583.gif
InShot_20260804_155850139.gif

I used the following XIAO pin assignment:

FunctionXIAO pin
----------------------------------------
RS485 receive D7
RS485 transmit D6
MAX485 direction cont D3
WS2812 data D2

XIAO to MAX485

XIAO ESP32-C6 MAX485 Purpose
------------------------------------------------------------------------------
D6 DI Sends Modbus commands
D7 RO through level shifting Receives sensor responses
D3 DE and /RE joined together Changes communication direction
5V VCC Powers the MAX485 module
GND GND Common ground

MAX485 to Soil Sensor

MAX485 Soil sensor
---------------------------------------------------
A A or A+
B B or B-
GND Sensor supply negative/common ground

If the sensor does not respond, swap A and B once. Different manufacturers occasionally label the pair in opposite ways.

WS2812 Connection

XIAO ESP32-C6 WS2812
D2 DIN
5V VCC
GND GND

A 3.3V-to-5V logic-level converter is recommended on the WS2812 data line when the LED is powered from 5V.

Do not connect the positive output of a 9–24V sensor supply to the XIAO.

Final Assembly

InShot_20260804_160634365.gif
WhatsApp Image 2026-08-04 at 3.50.35 PM.jpeg
InShot_20260804_160436399.gif

Once the painted parts were ready, I placed all the electronics inside the enclosure.

I positioned the XIAO ESP32-C6 and MAX485 so that the USB connection, sensor wiring and status LED remained accessible.

The wires were arranged away from the screw posts and the outer edges of the case.

This is important because a wire caught between the two halves can prevent the enclosure from closing properly or damage the insulation.

Before closing the case, I powered the circuit once and checked that the LED turned red and then green after Wi-Fi connected.

I also opened the ESP32 /data address in a browser to confirm that the electronics still worked after installation.

The two enclosure halves were then aligned and screwed together.

Using screws instead of glue means the case can be reopened if the sensor wiring, firmware or electronics need to be changed later.

After tightening the screws, I checked the seam around the enclosure and confirmed that no cables were being pinched.

Understanding the Dashboard

Dirt-Nerd-Dashboard-Flowchart.png

Before getting into the firmware and website code, I want to quickly walk through the dashboard from a user’s point of view.

The website is more than a page that displays sensor values. Its main job is to take the readings coming from the soil analyzer and explain whether that soil is suitable for a particular crop.

The complete interface follows this flow:

Landing Page
Start Soil Analysis
Sensor Reading Animation
Soil Dashboard
Select a Crop
Compatibility Report
Recommendations
Soil Health Score
Export or Download the Report

1. Landing Page

The website opens with a simple landing page containing the Dirt Nerd logo, project name and the main tagline:

Know your soil before you grow.

I deliberately kept this screen minimal. The user only sees a short description and the Start Soil Analysis button, rather than being immediately presented with a crowded dashboard.

The settings button can also be used to switch between Demo and Live modes. In Live mode, the IP address shown by the ESP32 in Serial Monitor is entered here.

2. Starting the Analysis

Pressing Start Soil Analysis begins the reading sequence.

In Live mode, the website first contacts the ESP32 using its local IP address. The ESP32 then returns the latest readings from the RS485 soil sensor.

Instead of immediately jumping to the results, I added a short analysis animation that moves through each measurement:

Connecting to Soil Analyzer
Reading Moisture Sensor
Measuring Soil Temperature
Measuring Soil pH
Checking Electrical Conductivity
Reading Nitrogen
Reading Phosphorus
Reading Potassium
Generating Soil Report

This makes the interaction feel closer to using an actual analyzer rather than simply opening another data page.

3. Sensor Dashboard

After the animation finishes, the main dashboard appears.

The first section displays all seven soil parameters:

  1. Soil moisture
  2. Soil temperature
  3. Soil pH
  4. Electrical conductivity
  5. Nitrogen
  6. Phosphorus
  7. Potassium

Each parameter has its own card containing the current value, unit, description and status.

For example, moisture may be marked as Low, while temperature may be shown as Ideal. This makes it possible to understand the general condition of the soil without studying every number individually.

A Refresh Readings button requests a new set of values from the ESP32.

4. Selecting a Crop

Raw soil readings do not mean much without context. A pH or moisture level that works for one plant may not be ideal for another.

Below the sensor cards, the user can select the crop they want to grow.

The available options include:

  1. Tomato
  2. Chilli
  3. Basil
  4. Mint
  5. Strawberry
  6. Coriander
  7. Spinach
  8. Cucumber
  9. Rose
  10. Marigold

Selecting a different crop updates the analysis automatically.

The sensor readings remain the same because the soil has not changed. What changes is the ideal range used to evaluate those readings.

5. Compatibility Report

The compatibility report compares the current soil against the preferred conditions of the selected crop.

It displays:

  1. Overall suitability percentage
  2. Compatibility status
  3. Current value for each parameter
  4. Ideal range for the selected crop
  5. Individual parameter status

A typical comparison looks like this:

Parameter | Current | Ideal | Status
-------------------------------------------------------------
Moisture | 34% | 45–60% | Low
Temperature | 26.4°C | 22–30°C | Ideal
pH | 6.9 | 6.0–6.5 | Slightly high
Nitrogen | 42 mg/kg | 60–90 mg/kg | Improve

This is where the dashboard starts turning sensor readings into something genuinely useful.

Instead of only telling the user that the soil contains a certain amount of nitrogen, it explains whether that amount is suitable for the plant they selected.

6. Soil Health Score

Alongside crop compatibility, the dashboard shows an overall soil health score using a circular progress indicator.

This score gives a general overview of the soil condition using factors such as:

  1. Soil structure
  2. Nutrient levels
  3. Water balance
  4. pH balance

Crop compatibility and soil health are shown separately because they answer different questions.

The compatibility score asks:

Is this soil suitable for the selected crop?

The soil health score asks:

What is the overall condition of this soil?

A soil sample can be reasonably healthy while still needing adjustments for a specific crop.

7. Recommendations

The recommendations section converts the report into a practical action plan.

Depending on the readings and selected crop, the dashboard may suggest actions such as:

  1. Adding compost or vermicompost
  2. Improving moisture consistency
  3. Increasing potassium before flowering
  4. Monitoring the pH after adding amendments

I wanted this part to be direct and easy to follow. The goal is not to overwhelm the user with agricultural data, but to explain what they can actually do with the result.

Each recommendation also includes a priority such as Priority, This Week, Before Flowering or Monitor.

8. Nutrient Visualization

The dashboard includes a bar chart for nitrogen, phosphorus, potassium and electrical conductivity.

This provides a quicker visual comparison than reading individual values from the cards.

When the selected crop changes, the rest of the analysis updates around that crop’s preferred soil conditions.

9. Saving and Sharing the Report

Once the analysis is complete, the user can:

  1. Export the readings as a JSON file
  2. Copy and share the report summary
  3. Download the report as a PDF
  4. Analyze another plant

The exported JSON is useful if the readings need to be stored or used in another project. The PDF option creates a more readable report that can be saved or printed.

10. Demo and Live Modes

The dashboard includes two data modes.

Demo Mode uses sample readings and is useful for exploring the interface without connecting the physical device.

Live Mode requests actual readings from the ESP32. The user enters the IP address displayed in Serial Monitor, tests the connection and then starts the analysis.

This made it possible to continue working on the website even when the complete sensor hardware was not connected, while keeping the same interface for the final device.

The dashboard is also responsive, so the same workflow can be used from a phone, tablet or computer connected to the same local network as the ESP32.

Now that the complete interface is clear, the next step is programming the XIAO ESP32-C6 to read the sensor and provide the data required by this dashboard.

Programming the ESP32

Screenshot 2026-08-04 165719.png

With the electronics assembled, the next step was programming the XIAO ESP32-C6.

The firmware has four main jobs:

  1. Read all seven soil parameters through Modbus RTU.
  2. Connect the device to Wi-Fi.
  3. Provide the readings as JSON through a local web server.
  4. Use the WS2812 LED to show the Wi-Fi status.

The complete firmware is attached with the project files, but I’ve broken down the important sections below.

1. Installing the Required Libraries

Before compiling the code, install these libraries through the Arduino IDE Library Manager:

  1. ModbusMaster by Doc Walker
  2. Adafruit NeoPixel by Adafruit

The Wi-Fi, WebServer and mDNS libraries are included with the ESP32 board package.

I used the following includes:

#include <Arduino.h>
#include <WiFi.h>
#include <WebServer.h>
#include <ESPmDNS.h>
#include <ModbusMaster.h>
#include <Adafruit_NeoPixel.h>

In Arduino IDE, select:

Tools → Board → ESP32 Arduino → XIAO_ESP32C6

I used Serial Monitor at 115200 baud.

2. Wi-Fi and Pin Configuration

The Wi-Fi credentials are stored at the beginning of the sketch.

I have replaced mine with placeholders here, so enter the credentials of the network you want to use:

const char* WIFI_SSID = "YOUR_WIFI_NAME";
const char* WIFI_PASSWORD = "YOUR_WIFI_PASSWORD";

For the hardware connections, I used D6 and D7 as the UART pins, D3 to control the MAX485 direction and D2 for the WS2812.

constexpr uint8_t RS485_RX_PIN = D7;
constexpr uint8_t RS485_TX_PIN = D6;
constexpr uint8_t RS485_DIR_PIN = D3;

constexpr uint8_t RGB_DATA_PIN = D2;
constexpr uint8_t RGB_PIXEL_COUNT = 1;

The MAX485 DE and /RE pins are connected together and controlled using D3.

3. Modbus Configuration

My sensor uses Modbus RTU to send all seven readings.

The configuration is kept in one place so it can easily be changed if a different sensor uses another baud rate, slave address or register layout.

constexpr uint32_t SENSOR_BAUD = 9600;
constexpr uint8_t SENSOR_SLAVE_ID = 1;

constexpr uint16_t FIRST_REGISTER = 0x0000;
constexpr uint8_t REGISTER_COUNT = 7;

constexpr bool USE_INPUT_REGISTERS = false;

constexpr unsigned long SENSOR_INTERVAL_MS = 2000;
constexpr unsigned long WIFI_RETRY_INTERVAL_MS = 10000;

Setting USE_INPUT_REGISTERS to false makes the firmware use Modbus function 0x03, which reads holding registers.

If your sensor manual specifies function 0x04, change it to:

constexpr bool USE_INPUT_REGISTERS = true;

4. Creating the Main Objects

Next, I created separate objects for UART communication, Modbus, the web server and the WS2812.

HardwareSerial soilSerial(1);
ModbusMaster modbus;
WebServer server(80);

Adafruit_NeoPixel statusPixel(
RGB_PIXEL_COUNT,
RGB_DATA_PIN,
NEO_GRB + NEO_KHZ800
);

The web server runs on port 80, so the readings can be accessed using the ESP32’s IP address without adding a separate port number.

5. Storing the Sensor Readings

Instead of keeping seven unrelated global variables, I stored everything inside one structure.

struct SoilReadings {
float moisture = 0.0f;
float temperature = 0.0f;
float ph = 0.0f;
float ec = 0.0f;

uint16_t nitrogen = 0;
uint16_t phosphorus = 0;
uint16_t potassium = 0;

bool valid = false;
uint8_t modbusResult = 0xFF;
unsigned long updatedAt = 0;
};

SoilReadings soil;

The valid value tells the web dashboard whether the sensor is responding, while modbusResult helps with debugging communication errors.

6. Wi-Fi Status LED

I wanted the device status to be understandable without opening Serial Monitor.

The RGB LED remains red while the ESP32 is disconnected and turns green after it connects to Wi-Fi.

void setStatusPixel(bool wifiConnected) {
const uint32_t color = wifiConnected
? statusPixel.Color(0, 90, 0)
: statusPixel.Color(110, 0, 0);

statusPixel.setPixelColor(0, color);
statusPixel.show();
}

I kept the brightness fairly low because the LED only needs to work as an indicator.

statusPixel.begin();
statusPixel.setBrightness(55);
setStatusPixel(false);

7. Controlling the MAX485

RS485 is half-duplex, which means the MAX485 cannot transmit and receive at the same time.

Before sending a Modbus request, the firmware enables transmission. As soon as the request is sent, it switches the module back into receive mode.

void preTransmission() {
digitalWrite(RS485_DIR_PIN, HIGH);
delayMicroseconds(200);
}

void postTransmission() {
delayMicroseconds(200);
digitalWrite(RS485_DIR_PIN, LOW);
}

These functions are registered with the Modbus library during setup:

modbus.preTransmission(preTransmission);
modbus.postTransmission(postTransmission);

Without this direction control, the ESP32 may send the request correctly but never receive the sensor’s response.

8. Reading the Seven Soil Parameters

The firmware requests seven consecutive registers beginning at address 0x0000.

bool readSoilSensor() {
uint8_t result;

if (USE_INPUT_REGISTERS) {
result = modbus.readInputRegisters(
FIRST_REGISTER,
REGISTER_COUNT
);
} else {
result = modbus.readHoldingRegisters(
FIRST_REGISTER,
REGISTER_COUNT
);
}

soil.modbusResult = result;

If the request fails, I mark the data as invalid and print the Modbus result in Serial Monitor.

if (result != ModbusMaster::ku8MBSuccess) {
soil.valid = false;

Serial.printf(
"Modbus read failed. Result: 0x%02X\n",
result
);

return false;
}

If the request succeeds, the response buffer contains all seven measurements.

const uint16_t rawMoisture =
modbus.getResponseBuffer(0);

const int16_t rawTemperature =
static_cast<int16_t>(
modbus.getResponseBuffer(1)
);

const uint16_t rawEc =
modbus.getResponseBuffer(2);

const uint16_t rawPh =
modbus.getResponseBuffer(3);

The raw values then have to be converted into their actual units.

soil.moisture = rawMoisture / 10.0f;
soil.temperature = rawTemperature / 10.0f;
soil.ec = rawEc / 1000.0f;
soil.ph = rawPh / 10.0f;

soil.nitrogen =
modbus.getResponseBuffer(4);

soil.phosphorus =
modbus.getResponseBuffer(5);

soil.potassium =
modbus.getResponseBuffer(6);

soil.valid = true;
soil.updatedAt = millis();

The register order used here is:

Register 0 → Moisture
Register 1 → Temperature
Register 2 → Electrical conductivity
Register 3 → pH
Register 4 → Nitrogen
Register 5 → Phosphorus
Register 6 → Potassium

Finally, I print the values to Serial Monitor. This made it easier to test the electronics before involving the website.

Serial.println("\n--- Soil reading ---");

Serial.printf(
"Moisture: %.1f %%\n",
soil.moisture
);

Serial.printf(
"Temperature: %.1f C\n",
soil.temperature
);

Serial.printf("pH: %.1f\n", soil.ph);
Serial.printf("EC: %.3f mS/cm\n", soil.ec);

Serial.printf(
"Nitrogen: %u mg/kg\n",
soil.nitrogen
);

Serial.printf(
"Phosphorus: %u mg/kg\n",
soil.phosphorus
);

Serial.printf(
"Potassium: %u mg/kg\n",
soil.potassium
);

return true;
}

Different versions of these sensors sometimes use different registers or scaling. If the sensor responds but the readings appear in the wrong order, this is the section that needs to be adjusted according to its Modbus manual.

9. Converting the Readings to JSON

The website needs a predictable format, so I convert the readings into a JSON object.

String readingsJson() {
String json;
json.reserve(360);

json += "{";

json += "\"moisture\":" +
String(soil.moisture, 1) + ",";

json += "\"temperature\":" +
String(soil.temperature, 1) + ",";

json += "\"ph\":" +
String(soil.ph, 1) + ",";

json += "\"ec\":" +
String(soil.ec, 3) + ",";

json += "\"nitrogen\":" +
String(soil.nitrogen) + ",";

json += "\"phosphorus\":" +
String(soil.phosphorus) + ",";

json += "\"potassium\":" +
String(soil.potassium) + ",";

json += "\"sensorConnected\":" +
String(soil.valid ? "true" : "false") + ",";

json += "\"wifiConnected\":" +
String(
WiFi.status() == WL_CONNECTED
? "true"
: "false"
) + ",";

json += "\"ip\":\"" +
WiFi.localIP().toString() + "\",";

json += "\"updatedAtMs\":" +
String(soil.updatedAt) + ",";

json += "\"modbusResult\":" +
String(soil.modbusResult);

json += "}";

return json;
}

A normal response looks like this:

{
"moisture": 34.2,
"temperature": 26.4,
"ph": 6.9,
"ec": 1.2,
"nitrogen": 42,
"phosphorus": 38,
"potassium": 156,
"sensorConnected": true,
"wifiConnected": true,
"ip": "192.168.1.105",
"updatedAtMs": 24518,
"modbusResult": 0
}

The website mainly uses the first seven values. The remaining fields are useful for checking the device and diagnosing connection problems.

10. Allowing the Website to Request Data

Because the website runs on the computer while the API runs on the ESP32, the browser treats them as separate origins.

I added CORS headers so the localhost dashboard is allowed to request the sensor data.

void addCorsHeaders() {
server.sendHeader(
"Access-Control-Allow-Origin",
"*"
);

server.sendHeader(
"Access-Control-Allow-Methods",
"GET, OPTIONS"
);

server.sendHeader(
"Access-Control-Allow-Headers",
"Content-Type"
);

server.sendHeader(
"Cache-Control",
"no-store"
);
}

The no-store header prevents the browser from showing an older cached reading.

11. Creating the /data Endpoint

The main endpoint used by Dirt Nerd is /data.

void handleData() {
addCorsHeaders();

server.send(
200,
"application/json",
readingsJson()
);
}

I also added a smaller /health endpoint for checking whether the ESP32 and sensor are responding.

void handleHealth() {
addCorsHeaders();

String json = "{\"status\":\"";
json += soil.valid ? "ok" : "sensor_error";

json += "\",\"ip\":\"" +
WiFi.localIP().toString() +
"\"}";

server.send(
200,
"application/json",
json
);
}

The endpoints are registered when the server starts:

void startWebServer() {
server.on("/", HTTP_GET, handleRoot);
server.on("/data", HTTP_GET, handleData);
server.on("/health", HTTP_GET, handleHealth);

server.on("/", HTTP_OPTIONS, handleOptions);
server.on("/data", HTTP_OPTIONS, handleOptions);
server.on("/health", HTTP_OPTIONS, handleOptions);

server.onNotFound([]() {
addCorsHeaders();

server.send(
404,
"application/json",
"{\"error\":\"Not found\"}"
);
});

server.begin();
Serial.println("HTTP server started");
}

I also included a small diagnostic page at /. This lets me open the ESP32’s IP directly in a browser and check the sensor without starting the full Dirt Nerd dashboard.

12. Connecting to Wi-Fi

During startup, the RGB LED remains red while the ESP32 tries to connect.

void connectWifi() {
Serial.printf(
"Connecting to Wi-Fi: %s\n",
WIFI_SSID
);

WiFi.mode(WIFI_STA);
WiFi.setAutoReconnect(true);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);

const unsigned long started = millis();

while (
WiFi.status() != WL_CONNECTED &&
millis() - started < 20000
) {
setStatusPixel(false);
delay(350);
Serial.print('.');
}

Serial.println();

After connecting, the LED turns green and the IP address is printed in Serial Monitor.

const bool connected =
WiFi.status() == WL_CONNECTED;

setStatusPixel(connected);
previousWifiState = connected;

if (connected) {
Serial.println("Wi-Fi connected");

Serial.print(
"Dirt Nerd IP address: http://"
);
Serial.println(WiFi.localIP());

Serial.print("JSON endpoint: http://");
Serial.print(WiFi.localIP());
Serial.println("/data");

if (MDNS.begin("dirt-nerd")) {
Serial.println(
"Local name: http://dirt-nerd.local"
);
}
} else {
Serial.println(
"Wi-Fi connection timed out; "
"automatic retries will continue."
);
}
}

The numeric IP is the address entered into the Dirt Nerd website.

13. Automatic Wi-Fi Reconnection

I didn’t want the device to require a reset whenever the router or hotspot temporarily disconnected.

void monitorWifi() {
const bool connected =
WiFi.status() == WL_CONNECTED;

if (connected != previousWifiState) {
setStatusPixel(connected);
previousWifiState = connected;

if (connected) {
Serial.print(
"Wi-Fi reconnected. IP: http://"
);
Serial.println(WiFi.localIP());
} else {
Serial.println("Wi-Fi disconnected");
}
}

if (
!connected &&
millis() - lastWifiRetry >=
WIFI_RETRY_INTERVAL_MS
) {
lastWifiRetry = millis();

Serial.println(
"Retrying Wi-Fi connection..."
);

WiFi.disconnect();
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
}
}

This function also keeps the RGB status accurate after startup.

14. Setup

The setup() function initializes the status LED, MAX485, UART, Modbus connection, Wi-Fi and web server.

void setup() {
Serial.begin(115200);
delay(1200);

Serial.println("\nStarting Dirt Nerd...");

statusPixel.begin();
statusPixel.setBrightness(55);
setStatusPixel(false);

pinMode(RS485_DIR_PIN, OUTPUT);
digitalWrite(RS485_DIR_PIN, LOW);

soilSerial.begin(
SENSOR_BAUD,
SERIAL_8N1,
RS485_RX_PIN,
RS485_TX_PIN
);

modbus.begin(
SENSOR_SLAVE_ID,
soilSerial
);

modbus.preTransmission(preTransmission);
modbus.postTransmission(postTransmission);

connectWifi();
startWebServer();
readSoilSensor();
}

15. Main Loop

The main loop stays fairly small.

void loop() {
server.handleClient();
monitorWifi();

if (
millis() - lastSensorRead >=
SENSOR_INTERVAL_MS
) {
lastSensorRead = millis();
readSoilSensor();
}

delay(2);
}

The ESP32 reads the sensor every two seconds while also responding to browser requests and monitoring the Wi-Fi connection.

Once the firmware is uploaded, open Serial Monitor at 115200 baud. After the RGB LED turns green, copy the IP address shown in the terminal.

The sensor data should then be available at:

http://ESP32-IP/data

At this point, the hardware is producing structured data over the local network. In the website step, I’ll show how Dirt Nerd requests this endpoint and turns those raw readings into the dashboard, crop comparison and recommendations.



Making the Webpage

Screen_Recording_20260804_164227_SamsungBrowser-ezgif.com-video-to-gif-converter.gif
RrqxW1KCfg.gif
brave_GpM6S6HDwu.gif

I built the Dirt Nerd website using Next.js, React, TypeScript and CSS. My main goal was to keep the interface simple enough to use beside a plant while still displaying all the information collected by the sensor.

The website supports two operating modes:

  1. Demo mode uses editable sample readings.
  2. Live mode requests actual readings from the ESP32.

The main project files are:

app/
├── globals.css
├── layout.tsx
├── page.tsx
└── sensor-data.ts

The complete website is attached to this step as a ZIP file. The following sections explain the most important parts of the code.

Storing the Sensor Data

I kept the sensor readings in a separate sensor-data.ts file. This allows the same dashboard components to work with both sample values and data received from the ESP32.

export type SensorKey =
| "moisture"
| "temperature"
| "ph"
| "ec"
| "nitrogen"
| "phosphorus"
| "potassium";

export const sensorData = {
moisture: {
label: "Soil moisture",
value: "34",
unit: "%",
status: "Low"
},

temperature: {
label: "Temperature",
value: "26.4",
unit: "°C",
status: "Ideal"
},

ph: {
label: "Soil pH",
value: "6.9",
unit: "pH",
status: "Good"
},

ec: {
label: "Conductivity",
value: "1.2",
unit: "mS/cm",
status: "Good"
},

nitrogen: {
label: "Nitrogen",
value: "42",
unit: "mg/kg",
status: "Low"
},

phosphorus: {
label: "Phosphorus",
value: "38",
unit: "mg/kg",
status: "Good"
},

potassium: {
label: "Potassium",
value: "156",
unit: "mg/kg",
status: "Medium"
}
};

When the website receives live data, it only replaces the values inside this object. The rest of the interface does not need to know whether the readings came from Demo mode or the physical sensor.

Managing the Website State

The main page keeps track of the current screen, selected crop, operating mode, ESP32 address and sensor readings.

const [screen, setScreen] =
useState("landing");

const [crop, setCrop] =
useState("tomato");

const [mode, setMode] =
useState("demo");

const [ipAddress, setIpAddress] =
useState("");

const [deviceStatus, setDeviceStatus] =
useState("demo");

const [readings, setReadings] =
useState(sensorData);

const [draftReadings, setDraftReadings] =
useState(sensorData);

The screen value switches the interface between the landing page, analysis animation and final dashboard.

Saving the Device Settings

The selected operating mode and ESP32 address are stored in the browser. This prevents the user from having to enter the address again after every refresh.

useEffect(() => {
const savedMode =
localStorage.getItem("dirt-nerd-mode");

const savedIp =
localStorage.getItem("dirt-nerd-ip");

if (savedMode === "live") {
setMode("live");
setDeviceStatus("idle");
}

if (savedIp) {
setIpAddress(savedIp);
}
}, []);

The settings are saved using:

function saveSettings() {
localStorage.setItem(
"dirt-nerd-mode",
mode
);

localStorage.setItem(
"dirt-nerd-ip",
ipAddress.trim()
);

if (mode === "demo") {
setReadings(draftReadings);
}

setDeviceStatus(
mode === "demo" ? "demo" : "idle"
);

setSettingsOpen(false);
}

Adding Custom Demo Readings

Demo mode includes input fields for all seven soil parameters. These were especially useful while designing the dashboard because I could test different values without connecting the physical sensor every time.

const customFields = [
{
key: "moisture",
min: 0,
max: 100,
step: 1
},
{
key: "temperature",
min: 0,
max: 60,
step: 0.1
},
{
key: "ph",
min: 0,
max: 14,
step: 0.1
},
{
key: "ec",
min: 0,
max: 10,
step: 0.1
},
{
key: "nitrogen",
min: 0,
max: 500,
step: 1
},
{
key: "phosphorus",
min: 0,
max: 500,
step: 1
},
{
key: "potassium",
min: 0,
max: 500,
step: 1
}
];

The following function updates one reading while keeping the remaining data unchanged:

function changeReading(key, value) {
setDraftReadings((current) => ({
...current,

[key]: {
...current[key],
value: value
}
}));
}

These custom fields are only displayed in Demo mode. In Live mode, the values come directly from the ESP32.

Preparing the ESP32 Address

The user only needs to enter the numeric address displayed in the Serial Monitor.

For example:

192.168.1.105

The website automatically converts it into:

http://192.168.1.105/data

This is handled using the following function:

function normalizedDeviceUrl() {
const clean = ipAddress
.trim()
.replace(/\/$/, "");

if (!clean) {
return "";
}

const base =
/^https?:\/\//i.test(clean)
? clean
: "http://" + clean;

if (/\/data$/i.test(base)) {
return base;
}

return base + "/data";
}

This also prevents /data from being added twice if the complete endpoint has already been entered.

Receiving Data From the ESP32

In Live mode, the website sends an HTTP request to the ESP32 and waits for a JSON response.

async function fetchLiveReadings() {
const url = normalizedDeviceUrl();

if (!url) {
setDeviceStatus("error");
return false;
}

setDeviceStatus("testing");

try {
const response = await fetch(url, {
headers: {
Accept: "application/json"
},

signal:
AbortSignal.timeout(6000)
});

if (!response.ok) {
throw new Error(
"Device unavailable"
);
}

const data =
await response.json();

const next = {
...sensorData
};

Object.keys(sensorData).forEach(
(key) => {
if (data[key] !== undefined) {
next[key] = {
...sensorData[key],
value: String(data[key])
};
}
}
);

setReadings(next);
setDeviceStatus("connected");

return true;
} catch (error) {
setDeviceStatus("error");
return false;
}
}

The ESP32 returns data in the following format:

{
"moisture": 34.2,
"temperature": 26.4,
"ph": 6.9,
"ec": 1.2,
"nitrogen": 42,
"phosphorus": 38,
"potassium": 156,
"sensorConnected": true,
"wifiConnected": true
}

The website uses the first seven fields for the dashboard. The connection fields are mainly useful while testing and diagnosing the device.

Starting the Analysis

The same analysis button works in both modes.

async function runAnalysis() {
if (mode === "live") {
const connected =
await fetchLiveReadings();

if (!connected) {
return;
}
}

setStep(0);
setScreen("analyzing");
}

In Demo mode, the website immediately begins the animation using the custom values. In Live mode, it first checks the ESP32 connection and downloads a fresh set of readings.

Creating the Reading Animation

The animation follows the actual measurements performed by the soil sensor.

const analysisSteps = [
"Connecting to Soil Analyzer",
"Reading moisture sensor",
"Measuring soil temperature",
"Measuring soil pH",
"Checking electrical conductivity",
"Reading nitrogen",
"Reading phosphorus",
"Reading potassium",
"Generating soil report"
];

A timer moves through the list before opening the dashboard:

useEffect(() => {
if (screen !== "analyzing") {
return;
}

if (step >= analysisSteps.length) {
const finished =
window.setTimeout(() => {
setScreen("dashboard");
}, 700);

return () =>
window.clearTimeout(finished);
}

const timer =
window.setTimeout(() => {
setStep((current) =>
current + 1
);
}, step === 0 ? 650 : 430);

return () =>
window.clearTimeout(timer);
}, [screen, step]);

This animation does not delay the actual sensor communication. In Live mode, the data is requested first, and the animation then provides clear feedback while the report is being prepared.

Updating the Crop Report

The selected crop is stored in a state variable:

const [crop, setCrop] =
useState("tomato");

const selected = crops[crop];

When a crop button is pressed, the website changes that value:

function selectCrop(cropKey) {
setCrop(cropKey);
}

The compatibility percentage, ideal ranges, status message and recommendations all read from the selected crop object. React then updates the report automatically without reloading the page.

For the current prototype, the crop profiles are stored locally. This keeps the website usable without an external plant database or internet connection.

Exporting the Data

The Export Data button creates a JSON file containing the operating mode, readings and selected crop.

function exportData() {
const report = {
mode: mode,
sensorData: readings,
crop: selected
};

const file = new Blob(
[
JSON.stringify(
report,
null,
2
)
],
{
type: "application/json"
}
);

const link =
document.createElement("a");

link.href =
URL.createObjectURL(file);

link.download =
"dirt-nerd-" +
crop +
".json";

link.click();

URL.revokeObjectURL(
link.href
);
}

For the PDF button, I used the browser’s print dialog:

function downloadReport() {
window.print();
}

The user can select Save as PDF from the print window. A separate print stylesheet hides navigation buttons and other unnecessary controls.

Mobile Optimization

The desktop version displays all seven readings across the page. On tablets, the layout changes to four columns.

@media (max-width: 1100px) {
.sensor-grid {
grid-template-columns:
repeat(4, 1fr);
}

.crop-picker {
grid-template-columns:
repeat(5, 1fr);
}

.report-grid {
grid-template-columns: 1fr;
}
}

On phones, each reading becomes a compact horizontal card:

@media (max-width: 700px) {
.dash-shell {
width: calc(100% - 24px);
padding-top: 28px;
}

.sensor-grid {
grid-template-columns: 1fr;
gap: 8px;
}

.sensor-card {
display: grid;

grid-template-columns:
38px 1fr auto;

grid-template-rows:
auto auto;

column-gap: 12px;
align-items: center;

padding: 13px 14px;
}

.sensor-card p,
.mini-gauge {
display: none;
}
}

The crop selector becomes horizontally scrollable:

@media (max-width: 700px) {
.crop-picker {
display: flex;
overflow-x: auto;
gap: 8px;

scroll-snap-type:
x mandatory;
}

.crop-picker button {
min-width: 116px;
height: 66px;

scroll-snap-align:
start;
}
}

The settings panel also becomes a bottom sheet, which is easier to use with one hand:

@media (max-width: 700px) {
.modal-backdrop {
padding: 0;
align-items: end;
}

.settings-modal {
width: 100%;
max-height: 92vh;
overflow-y: auto;

border-radius:
24px 24px 0 0;
}

.mode-selector,
.ip-row {
grid-template-columns: 1fr;
}
}

Running the Website

After extracting the attached website ZIP, open a terminal inside the project folder and install the dependencies:

npm install

Start the local development server:

npm run dev

The website will normally open at:

http://localhost:3000

To use Live mode, the computer and ESP32 must be connected to the same Wi-Fi network. Open Settings, select Live mode, enter the numeric ESP32 address and press Test.

I currently run the interface through localhost when connecting directly to the analyzer. A Vercel deployment uses HTTPS, while the ESP32 provides a local HTTP endpoint, and some browsers block that combination. Localhost avoids this problem and is the most reliable setup for the current version.

Problems Faced

Understanding the Sensor Registers

The biggest electronics challenge was that 7-in-1 RS485 sensors do not all use exactly the same register order or scaling.

A sensor may return a valid Modbus response while still placing pH, EC or NPK values in different registers from the layout used in the code.

The solution was to keep the register addresses and scaling in one function so they could be changed after checking the sensor manual.

RS485 A and B Labelling

RS485 A and B labels are not always consistent between manufacturers.

If the wiring and settings look correct but the sensor does not respond, swapping A and B once is a useful test.

Logic-Level Difference

The XIAO uses 3.3V GPIO, while a traditional MAX485 module normally operates at 5V.

Connecting its RO output directly to the ESP32 could damage the input. I accounted for this with level shifting instead of assuming the modules were electrically compatible.

Changing IP Address

The ESP32’s local IP can change when the router reconnects.

This means an address saved in the website may eventually stop working even though the device is connected.

The Test button helps identify this quickly. A DHCP reservation in the router can also keep the address fixed.

Local HTTP and Vercel HTTPS

The website works well on Vercel as a demo, but direct ESP32 communication is more complicated.

Vercel uses HTTPS while the ESP32 serves an HTTP address inside the local network. Browsers may block an HTTPS page from requesting that insecure local endpoint.

For the current build, running the website on localhost is the most reliable solution.

Mobile Dashboard Layout

The original desktop-style cards became too narrow on a phone.

Instead of only reducing the font size, I changed the sensor cards into compact horizontal rows and made the crop selector horizontally scrollable.

This preserved readability and made the controls easier to use outdoors.

Enclosure Clearance

The curved enclosure left less room near the edges than expected.

I had to pay attention to the screw posts, USB connector and cable bends while arranging the electronics.

Tri-Colour PLA Finish

The tri-colour filament did not produce the appearance I expected.

Rather than discarding the print, I sanded and painted it. The green finish ended up matching the project much better.

Testing the Device

InShot_20260804_174701270.gif
InShot_20260804_175053278.gif

I divided testing into communication, sensor response and website testing.

First, I powered the device without opening the dashboard.

The WS2812 started red and changed to green after the ESP32 connected to Wi-Fi.

I opened Serial Monitor at 115200 baud and confirmed that the board printed its local IP address.

I then opened the following address in a browser:

http://ESP32-IP/data

This confirmed that the ESP32 was returning JSON and that all seven measurement fields were present.

Next, I placed the probe into soil and watched whether the readings changed in a sensible direction when the soil condition changed.

The main purpose of this stage was to find obviously incorrect register mapping, scaling or communication errors rather than to claim laboratory accuracy.

I then started the website on localhost, selected Live mode and entered the same IP address.

The Test button confirmed the connection, and starting an analysis transferred the readings to the dashboard cards.

I changed between the available crops to check that the compatibility message, comparison table and recommendations updated without reloading the page.

I also tested the interface at desktop and phone widths.

The sensor cards changed into mobile rows, the crop options became horizontally scrollable and the Settings window opened as a bottom sheet.

Finally, I tested the Export Data, Share Report and Download PDF controls.

The soil sensor still needs to be calibrated according to its manufacturer’s procedure before the values should be treated as agronomically accurate.

For a more formal comparison, the next stage would be to test several documented soil samples and compare Dirt Nerd’s readings with calibrated reference instruments or laboratory results.

Final Thoughts

20260804_172651(0).jpg
20260804_172623.jpg
20260804_173548.jpg
20260804_173433.jpg

Dirt Nerd ended up being much more than a sensor connected to an ESP32.

The part I enjoyed most was bringing the electronics, firmware, interface and enclosure together into one product-like prototype.

The project also taught me that displaying a measurement is only half the job. The information becomes much more useful when the user can understand what it means for the plant they want to grow.

I learned a lot about RS485 communication, Modbus registers, voltage-level differences, local HTTP APIs and responsive interface design.

The enclosure also went through its own unexpected design journey. It did not remain the clean water-droplet shape I first imagined, but the slightly squid-like final form made the project more recognisable.

There are several things I would improve in a future version.

I would calibrate the sensor against reliable reference measurements, expand the crop database, calculate every comparison directly from live readings and add a better method for Wi-Fi setup without storing credentials in the firmware.

For remote access, I would also replace the direct local HTTP connection with a secure MQTT or cloud-based data path.

I would like to make the internal electronics mounting more organised and possibly add a rechargeable battery so the analyzer can be used without a separate power cable.

For now, Dirt Nerd works as a complete prototype that can measure soil, display the results and turn them into a more understandable plant-specific report.

If you build your own version, check the manual for your exact soil sensor, keep the voltage levels safe and treat the first set of readings as the beginning of calibration rather than the final answer.

Thanks for reading, and I hope this project gives you a useful starting point for building your own connected soil analyzer.