DEV Community

JackHack
JackHack

Posted on

Build a Smart Plant Watering System with ESP32 — Schedules Stored in the Cloud

Every maker who's built an automated plant watering system has hit the same wall: you hardcode the schedule, flash the firmware, and it works — until you realize you need to water the tomatoes more often, or skip the succulents on rainy days. Back to the IDE, recompile, reflash, wait.

What if the ESP32 just asked a file on the internet what to do?

This article walks through building a smart plant watering system where the watering schedule lives in a JSON file hosted in the cloud. The ESP32 fetches the schedule over HTTPS, decides whether to water, and activates a pump. You change the schedule from your phone, your laptop, or a curl command — the device picks it up on its next cycle. No reflashing. No server. No database.


What You'll Build

A system with two halves:

  • The device — an ESP32 connected to a soil moisture sensor and a water pump (via relay). It wakes up, fetches the watering schedule from a hosted JSON file, checks whether it's time to water and whether the soil is dry enough, and runs the pump if needed.
  • The schedule — a JSON file you can edit from anywhere. It defines when to water, how long to run the pump, and a soil moisture threshold below which watering is skipped (the soil is already wet enough).

The JSON file is the single source of truth. The device reads it; you write to it. That's the whole architecture.


What You Need

Hardware

  • ESP32 development board (any variant)
  • Capacitive soil moisture sensor (analog output)
  • 5V relay module (single channel)
  • Small submersible water pump (3–5V) or a 12V pump with appropriate relay
  • Jumper wires and breadboard
  • Power supply for the pump (if not USB-powered)

Wiring

Soil Moisture Sensor VCC  → 3.3V
Soil Moisture Sensor GND  → GND
Soil Moisture Sensor AOUT → GPIO 34 (analog input)

Relay IN   → GPIO 26
Relay VCC  → 5V (from VIN or USB)
Relay GND  → GND

Pump       → Relay NO (normally open) terminal + external power
Enter fullscreen mode Exit fullscreen mode

Note: GPIO 34 is input-only on most ESP32 boards, which is perfect for the analog sensor. The relay switches the pump's power circuit — the ESP32 never powers the pump directly.

Software

  • Arduino IDE with the ESP32 board package installed
  • ArduinoJson library (v7+) — install via Library Manager

The JSON Schedule Structure

Before writing firmware, let's design the schedule file. Keep it simple — the ESP32 has limited memory and you want fast parsing.

{
  "enabled": true,
  "timezone_offset": 2,
  "moisture_threshold": 40,
  "pump_duration_seconds": 5,
  "schedules": [
    { "hour": 7, "minute": 0 },
    { "hour": 19, "minute": 30 }
  ],
  "last_watered": "",
  "status": "idle"
}
Enter fullscreen mode Exit fullscreen mode

Here's what each field does:

Field Purpose
enabled Master switch. Set to false to pause all watering without deleting schedules.
timezone_offset Hours offset from UTC, so schedule times match your local clock.
moisture_threshold If soil moisture (0–100%) is above this value, skip watering — the soil is wet enough.
pump_duration_seconds How long to run the pump each time.
schedules Array of times (24h format) when the device should check and water.
last_watered Timestamp of the last watering event — updated by the device.
status Current device state — updated by the device after each cycle.

This structure gives you full remote control: change watering times, adjust the moisture threshold for different seasons, or disable the system entirely — all by editing a JSON file.


Setting Up the JSON File

We need a place to host this file that:

  • Serves it publicly over HTTPS (so the ESP32 can GET it)
  • Accepts authenticated writes (so the ESP32 can update last_watered and status)
  • Supports partial updates so the device doesn't overwrite your schedule changes

JSONhost.com handles all three. It's a hosted JSON service — read publicly, write with a token, supports JSON Pointer targeting for updating individual fields. It's built for exactly this kind of device-to-cloud workflow.

Steps:

  1. Sign up at jsonhost.com.
  2. Create a new JSON file (via the web interface or the Management API).
  3. Paste the schedule JSON above as the initial content.
  4. In the JSON's admin settings, enable POST access and copy the API Authorization token.
  5. Note the JSON file's unique ID — you'll need it for the API URL.

You can also create the file via the API:

curl -X POST "https://jsonhost.com/api/v1/json/manage/create" \
  -H "Authorization: YOUR_USER_TOKEN" \
  -H "Content-Type: application/json" \
  -H "X-Alias: plant-watering" \
  -H "X-Description: Smart watering schedule for ESP32" \
  -d '{
    "enabled": true,
    "timezone_offset": 2,
    "moisture_threshold": 40,
    "pump_duration_seconds": 5,
    "schedules": [
      { "hour": 7, "minute": 0 },
      { "hour": 19, "minute": 30 }
    ],
    "last_watered": "",
    "status": "idle"
}'
Enter fullscreen mode Exit fullscreen mode

Test that it's accessible:

curl -X GET "https://jsonhost.com/json/YOUR_JSON_ID"
Enter fullscreen mode Exit fullscreen mode

You should see the full schedule JSON in the response.


The ESP32 Firmware

Now for the device code. The firmware does this in a loop:

  1. Connect to Wi-Fi
  2. Fetch the schedule from the JSON API
  3. Check if watering is enabled
  4. Check if the current time matches a scheduled slot
  5. Read the soil moisture sensor
  6. If it's time and the soil is dry enough, run the pump
  7. Update the last_watered and status fields in the JSON
  8. Sleep and repeat

Here's the complete sketch:

#include <WiFi.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <time.h>

// ── Configuration ────────────────────────────────────────
const char* WIFI_SSID     = "YOUR_WIFI_SSID";
const char* WIFI_PASSWORD = "YOUR_WIFI_PASSWORD";

const char* JSON_URL   = "https://jsonhost.com/json/YOUR_JSON_ID";
const char* API_TOKEN  = "YOUR_API_TOKEN";

const int MOISTURE_PIN = 34;   // Analog input
const int RELAY_PIN    = 26;   // Relay control
const int CHECK_INTERVAL_MS = 60000; // Check every 60 seconds

// Moisture sensor calibration (adjust for your sensor)
const int MOISTURE_DRY = 3200;  // Sensor value in dry air
const int MOISTURE_WET = 1400;  // Sensor value in water

// ── Setup ────────────────────────────────────────────────
void setup() {
  Serial.begin(115200);
  pinMode(RELAY_PIN, OUTPUT);
  digitalWrite(RELAY_PIN, LOW); // Pump off

  // Connect to Wi-Fi
  WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
  Serial.print("Connecting to Wi-Fi");
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.println(" connected.");

  // Sync time via NTP
  configTime(0, 0, "pool.ntp.org", "time.nist.gov");
  Serial.print("Syncing time");
  while (time(nullptr) < 100000) {
    delay(500);
    Serial.print(".");
  }
  Serial.println(" done.");
}

// ── Read soil moisture as percentage (0 = dry, 100 = wet) ──
int readMoisturePercent() {
  int raw = analogRead(MOISTURE_PIN);
  int percent = map(raw, MOISTURE_DRY, MOISTURE_WET, 0, 100);
  return constrain(percent, 0, 100);
}

// ── Fetch the schedule JSON via GET ─────────────────────
String fetchSchedule() {
  HTTPClient http;
  http.begin(JSON_URL);
  int httpCode = http.GET();

  String payload = "";
  if (httpCode == 200) {
    payload = http.getString();
  } else {
    Serial.printf("GET failed, code: %d\n", httpCode);
  }
  http.end();
  return payload;
}

// ── Update a field via POST to a JSON Pointer path ──────
void updateField(const char* field, const char* value) {
  HTTPClient http;
  String url = String(JSON_URL) + "/" + field;
  http.begin(url);
  http.addHeader("Authorization", API_TOKEN);
  int httpCode = http.POST(value);
  if (httpCode != 200) {
    Serial.printf("POST %s failed, code: %d\n", field, httpCode);
  }
  http.end();
}

// ── Activate the pump ───────────────────────────────────
void runPump(int durationSeconds) {
  Serial.printf("Pump ON for %d seconds\n", durationSeconds);
  digitalWrite(RELAY_PIN, HIGH);
  delay(durationSeconds * 1000);
  digitalWrite(RELAY_PIN, LOW);
  Serial.println("Pump OFF");
}

// ── Main loop ───────────────────────────────────────────
void loop() {
  String json = fetchSchedule();
  if (json.isEmpty()) {
    Serial.println("No schedule data. Retrying in 60s.");
    delay(CHECK_INTERVAL_MS);
    return;
  }

  // Parse the JSON
  JsonDocument doc;
  DeserializationError err = deserializeJson(doc, json);
  if (err) {
    Serial.printf("JSON parse error: %s\n", err.c_str());
    delay(CHECK_INTERVAL_MS);
    return;
  }

  bool enabled = doc["enabled"] | false;
  int tzOffset = doc["timezone_offset"] | 0;
  int threshold = doc["moisture_threshold"] | 50;
  int pumpDuration = doc["pump_duration_seconds"] | 5;

  if (!enabled) {
    Serial.println("Watering is disabled.");
    updateField("status", "\"disabled\"");
    delay(CHECK_INTERVAL_MS);
    return;
  }

  // Get current local time
  time_t now = time(nullptr) + (tzOffset * 3600);
  struct tm* local = gmtime(&now);
  int currentHour = local->tm_hour;
  int currentMinute = local->tm_min;

  Serial.printf("Local time: %02d:%02d\n", currentHour, currentMinute);

  // Check if current time matches any schedule slot
  bool isScheduled = false;
  JsonArray schedules = doc["schedules"];
  for (JsonObject slot : schedules) {
    int h = slot["hour"] | -1;
    int m = slot["minute"] | -1;
    if (h == currentHour && m == currentMinute) {
      isScheduled = true;
      break;
    }
  }

  if (!isScheduled) {
    Serial.println("Not a scheduled time. Waiting.");
    updateField("status", "\"idle\"");
    delay(CHECK_INTERVAL_MS);
    return;
  }

  // Read soil moisture
  int moisture = readMoisturePercent();
  Serial.printf("Soil moisture: %d%%\n", moisture);

  if (moisture > threshold) {
    Serial.println("Soil is wet enough. Skipping.");
    updateField("status", "\"skipped - soil wet\"");
    delay(CHECK_INTERVAL_MS);
    return;
  }

  // Water the plant
  runPump(pumpDuration);

  // Build a timestamp string
  char timestamp[25];
  snprintf(timestamp, sizeof(timestamp),
    "\"%04d-%02d-%02dT%02d:%02d:%02dZ\"",
    local->tm_year + 1900, local->tm_mon + 1, local->tm_mday,
    local->tm_hour, local->tm_min, local->tm_sec);

  // Update status and last_watered in the hosted JSON
  updateField("last_watered", timestamp);
  updateField("status", "\"watered\"");

  Serial.println("Watering complete. Status updated.");
  delay(CHECK_INTERVAL_MS);
}
Enter fullscreen mode Exit fullscreen mode

What's Happening in the Code

  • fetchSchedule() — A simple GET request to the JSON URL. No authentication needed — JSONhost serves GET requests publicly.
  • updateField() — Uses POST with a JSON Pointer path to update a single field without overwriting the entire schedule. For example, posting to /json/YOUR_ID/status replaces only the status field.
  • Schedule matching — The device checks once per minute. If the current hour and minute match any entry in the schedules array, it proceeds to the moisture check.
  • Moisture gating — Even if it's the right time, the pump only runs if the soil is dry enough. This prevents overwatering after rain or manual watering.

Changing the Schedule Remotely

Here's where it gets useful. Want to add a midday watering during a heatwave? Just update the JSON:

curl -X POST "https://jsonhost.com/json/YOUR_JSON_ID/schedules" \
  -H "Authorization: YOUR_API_TOKEN" \
  -d '[
    { "hour": 7, "minute": 0 },
    { "hour": 13, "minute": 0 },
    { "hour": 19, "minute": 30 }
  ]'
Enter fullscreen mode Exit fullscreen mode

Going on vacation and want to increase pump duration?

curl -X POST "https://jsonhost.com/json/YOUR_JSON_ID/pump_duration_seconds" \
  -H "Authorization: YOUR_API_TOKEN" \
  -d '10'
Enter fullscreen mode Exit fullscreen mode

Pause everything?

curl -X POST "https://jsonhost.com/json/YOUR_JSON_ID/enabled" \
  -H "Authorization: YOUR_API_TOKEN" \
  -d 'false'
Enter fullscreen mode Exit fullscreen mode

Or just open the JSONhost web interface and edit the values directly. The ESP32 will pick up the changes on its next 60-second cycle.

You can also use JSON Patch (RFC 6902) for atomic multi-field updates — for example, changing the schedule and the threshold in a single request:

curl -X PATCH "https://jsonhost.com/json/YOUR_JSON_ID" \
  -H "Authorization: YOUR_API_TOKEN" \
  -d '[
    {
      "op": "replace",
      "path": "/moisture_threshold",
      "value": 30
    },
    {
      "op": "replace",
      "path": "/pump_duration_seconds",
      "value": 8
    }
  ]'
Enter fullscreen mode Exit fullscreen mode

Monitoring the System

Since the device writes status and last_watered back to the same JSON file, you can check on it from anywhere:

curl -s "https://jsonhost.com/json/YOUR_JSON_ID/status"
# "watered"

curl -s "https://jsonhost.com/json/YOUR_JSON_ID/last_watered"
# "2026-04-11T07:00:12Z"
Enter fullscreen mode Exit fullscreen mode

You could also build a simple HTML page that fetches and displays this data — but honestly, the raw JSON responses are already pretty useful for a quick check.


Tips and Improvements

Calibrate your moisture sensor. Capacitive sensors vary a lot. Stick yours in dry air and note the analog reading (MOISTURE_DRY), then submerge the tip in water (MOISTURE_WET). Update the constants in the code.

Use deep sleep for battery-powered setups. Replace the delay() at the end of loop() with esp_deep_sleep() to dramatically reduce power consumption. The ESP32 will wake up, check the schedule, and go back to sleep.

Add multiple zones. Extend the JSON with an array of zones, each with its own sensor pin, relay pin, and schedule. The firmware loops through each zone independently.

Set up a webhook. On Premium or Pro plans, JSONhost can fire a webhook whenever the JSON is updated. You could use this to trigger a push notification when the schedule changes, confirming the device will pick up your edits.


Wrapping Up

The core idea here is simple: separate the schedule from the firmware. By storing the watering logic in a hosted JSON file, you turn a rigid embedded system into something you can reconfigure from a browser tab.

JSONhost.com makes this easy because the read path is public (no token on the device for GETs), the write path is authenticated, and you can target individual fields with JSON Pointers instead of replacing the whole document. For a small IoT project, that's all the backend you need.

Your plants don't care where the schedule comes from. They just want water at the right time.

Top comments (0)