A closed terrarium is a tiny world in a jar: plants, moss, soil and its own weather. This capstone gives it a caretaker. A DHT11 measures the air temperature and humidity, a DS18B20 measures the soil, and a relay switches the terrarium's light on in the morning and off in the evening. A web dashboard on your phone shows the readings and a 24-hour chart, and lets you change the light times and the limits. If it gets too hot, too cold or too dry, ntfy sends you a message, like "Time to mist the terrarium". The settings survive a power cut. This is for plants. Animals need proper thermostats and a vet's advice.
What you'll learn
- How to plan a device that runs for months: settings, history and alerts
- How to switch a light on a daily schedule, even across midnight
- How to save settings from a web form in Preferences
- How to send an alert only once, and again only after things went back to normal
What you need
- ESP32 board, expansion board and USB cable (ESP32 IoT Starter Kit)
- DHT11 temperature and humidity sensor (ESP32 IoT Starter Kit)
- DS18B20 temperature module (45-in-1 Sensor Kit)
- Relay module (45-in-1 Sensor Kit)
- A USB-powered LED lamp, and a USB extension cable to cut open (ask an adult)
- Long jumper wires for the sensors (about 50 cm)
- A phone with the free ntfy app
- A 2.4 GHz Wi-Fi network
- The printed parts from the 3D printing box below (sensor stake and controller case), or the cardboard version described there
- Breadboard and jumper wires (ESP32 IoT Starter Kit)
Wiring

| From | To |
|---|---|
| DHT11 S (data) | ESP32 GPIO 27 |
| DHT11 + and − | 3V3 and GND |
| DS18B20 module S | ESP32 GPIO 33 |
| DS18B20 module + and − | 3V3 and GND |
| Relay module S | ESP32 GPIO 26 |
| Relay module + | ESP32 5V |
| Relay module − | GND |
| Relay COM and NO | The red (+5 V) wire of the cut USB extension, one end on each |
Sensor modules from different makers can label and order their pins differently. Always follow the labels printed on your module.
Only switch a USB lamp. Ask an adult to cut the outer sheath of a USB extension cable, cut only the red wire, and screw its two ends into the relay's COM and NO terminals. The lamp then gets power only while the relay is on. Never connect mains electricity to the relay, and keep water away from the electronics.
Build it in stages
- Sensors. Wire the DHT11 and the DS18B20 and upload the sketch. The dashboard shows both temperatures and the humidity.
- Light. Add the relay. Set the light times on the dashboard so that "now" is inside them: the relay clicks and its LED lights. Then add the USB lamp.
- Alerts. Set up ntfy and set the highest temperature 1 °C below the current one: a message arrives within a minute. Set it back.
- Install. Put the stake in the terrarium, the lamp above it and the case outside.
Set up ntfy
Install the ntfy app and subscribe to a long, random topic name such as eementor-tank-k4w9p2, as in the motion alert lesson. Topics on the free server are public, so never use a name that is easy to guess, and never send personal details through it.
The sketch
In the Arduino IDE, open Library Manager and install the DHT sensor library by Adafruit, OneWire by Paul Stoffregen and DallasTemperature by Miles Burton.
Replace YOUR_WIFI_NAME and YOUR_WIFI_PASSWORD with your own network details (2.4 GHz only).
Change TIME_ZONE to your own time zone. The example is for Central Europe; search for "POSIX time zone" and your city to find yours (for example EET-2EEST,M3.5.0/3,M10.5.0/4 for Estonia or GMT0BST,M3.5.0/1,M10.5.0 for the UK).
#include <WiFi.h>
#include <WebServer.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <Preferences.h>
#include <DHT.h>
#include <OneWire.h>
#include <DallasTemperature.h>
#include <time.h>
const char* WIFI_NAME = "YOUR_WIFI_NAME";
const char* WIFI_PASSWORD = "YOUR_WIFI_PASSWORD";
const char* TOPIC = "YOUR_SECRET_TOPIC";
const char* TIME_ZONE = "CET-1CEST,M3.5.0,M10.5.0/3"; // your time zone
const int DHT_PIN = 27, SOIL_PIN = 33, RELAY_PIN = 26;
const int RELAY_ON = HIGH; // LOW for relay modules that switch on with LOW
const int HISTORY = 48; // one point every 30 min = 24 hours
DHT dht(DHT_PIN, DHT11);
OneWire oneWire(SOIL_PIN);
DallasTemperature soil(&oneWire);
WebServer server(80);
Preferences prefs;
// settings, saved in Preferences
int lightOn, lightOff; // minutes after midnight
float minTemp, maxTemp;
int minHumidity;
int mode; // 0 = schedule, 1 = always on, 2 = always off
float airTemp = NAN, humidity = NAN, soilTemp = NAN;
bool lightIsOn = false;
bool hotSent = false, coldSent = false, drySent = false;
float histTemp[HISTORY], histHum[HISTORY];
int histCount = 0, histNext = 0;
unsigned long lastRead = 0, lastHistory = 0;
String hhmm(int minutes) {
char s[6];
sprintf(s, "%02d:%02d", minutes / 60, minutes % 60);
return s;
}
int parseTime(String s) { // "07:30" -> 450
return constrain(s.substring(0, 2).toInt() * 60 + s.substring(3, 5).toInt(), 0, 1439);
}
String show(float v, int decimals) {
return isnan(v) ? String("-") : String(v, decimals);
}
void notify(String text) {
WiFiClientSecure client;
client.setInsecure();
HTTPClient http;
http.begin(client, String("https://ntfy.sh/") + TOPIC);
http.addHeader("Title", "Terrarium");
http.addHeader("Tags", "seedling");
int status = http.POST(text);
http.end();
Serial.printf("ntfy: %s (%d)\n", text.c_str(), status);
}
void updateLight() {
bool on = false;
if (mode == 1) on = true;
else if (mode == 0 && time(nullptr) > 1700000000) {
time_t now = time(nullptr);
struct tm t;
localtime_r(&now, &t);
int m = t.tm_hour * 60 + t.tm_min;
if (lightOn <= lightOff) on = m >= lightOn && m < lightOff; // a normal day
else on = m >= lightOn || m < lightOff; // on across midnight
}
if (on != lightIsOn) {
lightIsOn = on;
digitalWrite(RELAY_PIN, on ? RELAY_ON : !RELAY_ON);
Serial.println(on ? "Light on" : "Light off");
}
}
void readSensors() {
float t = dht.readTemperature(), h = dht.readHumidity();
if (!isnan(t) && !isnan(h)) {
airTemp = t;
humidity = h;
}
soil.requestTemperatures();
float s = soil.getTempCByIndex(0);
if (s != DEVICE_DISCONNECTED_C) soilTemp = s;
if (isnan(airTemp)) return;
// each alert once, and again only after it went back to normal
if (airTemp > maxTemp && !hotSent) { notify("Too hot: " + String(airTemp, 1) + " C. Open the lid a little."); hotSent = true; }
if (airTemp < maxTemp - 1) hotSent = false;
if (airTemp < minTemp && !coldSent) { notify("Too cold: " + String(airTemp, 1) + " C."); coldSent = true; }
if (airTemp > minTemp + 1) coldSent = false;
if (humidity < minHumidity && !drySent) { notify("Only " + String(humidity, 0) + " % humidity. Time to mist the terrarium."); drySent = true; }
if (humidity > minHumidity + 5) drySent = false;
}
void addHistory() {
histTemp[histNext] = airTemp;
histHum[histNext] = humidity;
histNext = (histNext + 1) % HISTORY;
if (histCount < HISTORY) histCount++;
}
// Polyline points for the chart: temperature 0-40 C and humidity 0-100 % both fill the height
String points(float* values, float top) {
String p;
for (int i = 0; i < histCount; i++) {
float v = values[(histNext - histCount + i + HISTORY) % HISTORY];
if (isnan(v)) continue;
p += String(i * 10) + "," + String(100 - constrain(v / top * 100, 0, 100), 1) + " ";
}
return p;
}
void handleRoot() {
String sel[3] = {"", "", ""};
sel[mode] = " selected";
String html = "<!doctype html><meta name=viewport content='width=device-width'><title>Terrarium</title>"
"<style>body{font-family:sans-serif;max-width:520px;margin:20px auto;padding:0 16px}.g{display:grid;grid-template-columns:1fr 1fr;gap:8px}"
".c{background:#F1F6EE;border-radius:14px;padding:12px}.c b{display:block;font-size:26px}svg{width:100%;height:150px;background:#F6F8FC;border-radius:12px}"
"input,select{width:100%;padding:6px;box-sizing:border-box}label{display:block;margin:6px 0}</style>"
"<h1>Terrarium</h1><div class=g>"
"<div class=c>Air<b>" + show(airTemp, 1) + " °C</b></div><div class=c>Humidity<b>" + show(humidity, 0) + " %</b></div>"
"<div class=c>Soil<b>" + show(soilTemp, 1) + " °C</b></div><div class=c>Light<b>" + String(lightIsOn ? "On" : "Off") + "</b></div></div>"
"<h2>Last 24 hours</h2><svg viewBox='0 0 480 100' preserveAspectRatio=none>"
"<polyline fill=none stroke='#E63946' stroke-width=1.5 points='" + points(histTemp, 40) + "'/>"
"<polyline fill=none stroke='#2F6FDB' stroke-width=1.5 points='" + points(histHum, 100) + "'/></svg>"
"<p><span style='color:#E63946'>Temperature (0-40 °C)</span> · <span style='color:#2F6FDB'>Humidity (0-100 %)</span></p>"
"<h2>Settings</h2><form action='/set' method='post' class=g>"
"<label>Light on<input type=time name=on value='" + hhmm(lightOn) + "'></label>"
"<label>Light off<input type=time name=off value='" + hhmm(lightOff) + "'></label>"
"<label>Lowest °C<input type=number step=0.5 name=min value='" + String(minTemp, 1) + "'></label>"
"<label>Highest °C<input type=number step=0.5 name=max value='" + String(maxTemp, 1) + "'></label>"
"<label>Lowest humidity %<input type=number name=hum value='" + String(minHumidity) + "'></label>"
"<label>Light<select name=mode><option value=0" + sel[0] + ">Schedule</option><option value=1" + sel[1] + ">Always on</option><option value=2" + sel[2] + ">Always off</option></select></label>"
"<button>Save</button></form>";
server.send(200, "text/html", html);
}
void handleSet() {
lightOn = parseTime(server.arg("on"));
lightOff = parseTime(server.arg("off"));
minTemp = server.arg("min").toFloat();
maxTemp = server.arg("max").toFloat();
minHumidity = server.arg("hum").toInt();
mode = constrain(server.arg("mode").toInt(), 0, 2);
prefs.putInt("on", lightOn);
prefs.putInt("off", lightOff);
prefs.putFloat("min", minTemp);
prefs.putFloat("max", maxTemp);
prefs.putInt("hum", minHumidity);
prefs.putInt("mode", mode);
hotSent = coldSent = drySent = false; // check the new limits from scratch
updateLight();
server.sendHeader("Location", "/");
server.send(303);
}
void setup() {
Serial.begin(115200);
pinMode(RELAY_PIN, OUTPUT);
digitalWrite(RELAY_PIN, !RELAY_ON);
dht.begin();
soil.begin();
prefs.begin("terrarium");
lightOn = prefs.getInt("on", 8 * 60);
lightOff = prefs.getInt("off", 20 * 60);
minTemp = prefs.getFloat("min", 18);
maxTemp = prefs.getFloat("max", 28);
minHumidity = prefs.getInt("hum", 60);
mode = prefs.getInt("mode", 0);
WiFi.begin(WIFI_NAME, WIFI_PASSWORD);
Serial.print("Connecting to Wi-Fi");
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.print(" connected. Open http://");
Serial.println(WiFi.localIP());
configTzTime(TIME_ZONE, "pool.ntp.org");
server.on("/", handleRoot);
server.on("/set", HTTP_POST, handleSet);
server.begin();
readSensors();
addHistory();
lastRead = lastHistory = millis();
}
void loop() {
server.handleClient();
if (millis() - lastRead >= 60000) { // every minute
lastRead = millis();
readSensors();
}
if (millis() - lastHistory >= 30UL * 60 * 1000) { // every 30 minutes
lastHistory = millis();
addHistory();
}
updateLight();
}
client.setInsecure() keeps the connection encrypted but skips checking the server's certificate. That keeps the sketch short and is fine for a learning project. For a device you leave running for years, load the server's root certificate with client.setCACert() instead.
Use it
Open the Serial Monitor at 115200 baud: after connecting, the ESP32 prints its address, such as http://192.168.1.42. Open it in a browser on a phone or computer in the same Wi-Fi.
- Open the dashboard: four tiles show the air, humidity, soil and the light. The chart gets a new point every 30 minutes.
- Set the light times for your plants. Most terrarium plants like 10 to 12 hours of light, for example 08:00 to 20:00.
- Set the limits. Many tropical terrarium plants are happy between 18 and 28 °C with more than 60 % humidity. When the humidity drops, mist the plants and the alert resets itself.
How it works
The controller runs three loops at three speeds. The light is checked every time round loop(), the sensors once a minute, and the history every 30 minutes. 48 places of history make 24 hours, stored in a ring buffer that overwrites the oldest point.
The light schedule is two times stored as minutes after midnight. Usually "on" is earlier than "off", and the light is on between them. If you set it to come on at 20:00 and go off at 06:00, the rule flips: on after 20:00 or before 06:00. Without internet time, the schedule leaves the light off rather than guessing.
The form on the dashboard sends the new settings to /set with a POST. The sketch saves each one in Preferences, so they're back after a power cut, and then redirects the browser to the dashboard.
Each alert has a flag. It is sent once, then waits until the value has moved well back inside the limits. That gap, one degree or five percent, is hysteresis: it stops a reading that wobbles around a limit from sending a message every minute.
The chart is built by the ESP32 as an SVG polyline: a list of x,y points scaled to the 0–100 height, red for temperature and blue for humidity.
Try this next
- Add a small USB fan on a second relay that switches on when it gets too hot.
- Fade the light in and out in the morning and evening with a MOSFET instead of a relay.
- Send a daily summary with the lowest and highest temperature at 21:00.
Troubleshooting
- The relay clicks but the lamp stays off: the red wire must go to COM and NO, not NC. Check with the relay's LED on.
-
The light does the opposite of the schedule: your relay switches on with LOW. Set
RELAY_ONtoLOW. - Air shows "-": the DHT11 isn't answering. Check its data wire on GPIO 27. Soil shows "-" or -127: check the DS18B20 on GPIO 33.
- The humidity is always 95 % or more: that is normal in a closed terrarium. Set the lowest humidity limit lower, or air it now and then.
- It never connects to Wi-Fi: check the network name and password (both are case-sensitive) and make sure the network is 2.4 GHz.
- A printed part is too tight or too loose: printers differ by a few tenths of a millimetre. Sand or file a tight hole, and fix a loose part with a drop of hot glue or a strip of tape.