Your room has a rhythm: it cools down at night, warms up when the sun comes round or the heating comes on, and does much the same the next day. A machine learning model can learn that rhythm from data and use it to predict the future. In this lesson the ESP32 measures the temperature every 10 minutes. Once it has a few hours of data, it fits a mathematical curve to it, a regression model, and predicts the next 24 hours. A web page shows the measurements, the model's curve, a prediction for tomorrow morning, and how far off the model usually is.
What you'll learn
- What training data, a model and a prediction are
- How regression fits a curve to measurements
- How to measure a model's error
- Why predictions get better with more data
What you need
- ESP32 board, expansion board and USB cable (ESP32 IoT Starter Kit)
- DHT11 temperature and humidity sensor (ESP32 IoT Starter Kit)
- A 2.4 GHz Wi-Fi network
- The printed parts from the 3D printing box below (forecast station), or the cardboard version described there
- Breadboard and jumper wires (ESP32 IoT Starter Kit)
Wiring

| From | To |
|---|---|
| DHT11 S (data) | ESP32 GPIO 27 |
| DHT11 + | 3V3 |
| DHT11 − | GND |
Sensor modules from different makers can label and order their pins differently. Always follow the labels printed on your module.
The sketch
In the Arduino IDE, open Library Manager and install the DHT sensor library by Adafruit.
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 <DHT.h>
#include <time.h>
const char* WIFI_NAME = "YOUR_WIFI_NAME";
const char* WIFI_PASSWORD = "YOUR_WIFI_PASSWORD";
const char* TIME_ZONE = "CET-1CEST,M3.5.0,M10.5.0/3";
const int DHT_PIN = 27;
const int MAX_SAMPLES = 432; // 3 days at one sample every 10 minutes
const unsigned long EVERY = 10UL * 60 * 1000;
DHT dht(DHT_PIN, DHT11);
WebServer server(80);
float temps[MAX_SAMPLES];
time_t times[MAX_SAMPLES];
int count = 0;
float a = 0, b = 0, c = 0; // the model: a + b·cos + c·sin of the time of day
float error = 0; // average error in °C
unsigned long lastSample = 0;
// Time of day as an angle on a 24-hour clock (0 to 2π)
float dayAngle(time_t t) {
struct tm local;
localtime_r(&t, &local);
float hours = local.tm_hour + local.tm_min / 60.0;
return hours / 24.0 * 2 * PI;
}
float predict(time_t t) {
float w = dayAngle(t);
return a + b * cos(w) + c * sin(w);
}
// Least squares: find a, b, c that make the curve as close as possible to all samples
void train() {
if (count < 18) return; // at least 3 hours of data
double s[3][4] = {{0}}; // the normal equations, as a 3×4 table
for (int i = 0; i < count; i++) {
float w = dayAngle(times[i]);
double x[3] = {1, cos(w), sin(w)};
for (int r = 0; r < 3; r++) {
for (int k = 0; k < 3; k++) s[r][k] += x[r] * x[k];
s[r][3] += x[r] * temps[i];
}
}
for (int p = 0; p < 3; p++) { // solve them with Gaussian elimination
for (int r = 0; r < 3; r++) {
if (r == p || fabs(s[p][p]) < 1e-9) continue;
double f = s[r][p] / s[p][p];
for (int k = 0; k < 4; k++) s[r][k] -= f * s[p][k];
}
}
if (fabs(s[0][0]) < 1e-9 || fabs(s[1][1]) < 1e-9 || fabs(s[2][2]) < 1e-9) return;
a = s[0][3] / s[0][0];
b = s[1][3] / s[1][1];
c = s[2][3] / s[2][2];
float sum = 0; // how good is it? the average distance
for (int i = 0; i < count; i++) sum += fabs(predict(times[i]) - temps[i]);
error = sum / count;
Serial.printf("Model: %.2f + %.2f cos + %.2f sin, error %.2f C\n", a, b, c, error);
}
void sample() {
float t = dht.readTemperature();
if (isnan(t) || time(nullptr) < 1700000000) return;
if (count == MAX_SAMPLES) { // full: forget the oldest
for (int i = 1; i < count; i++) { temps[i - 1] = temps[i]; times[i - 1] = times[i]; }
count--;
}
temps[count] = t;
times[count] = time(nullptr);
count++;
Serial.printf("Sample %d: %.1f C\n", count, t);
train();
}
void handleData() {
time_t now = time(nullptr);
String json = "{\"count\":" + String(count) + ",\"error\":" + String(error, 2) + ",\"trained\":" + (count >= 18 ? "true" : "false") + ",\"measured\":[";
for (int i = 0; i < count; i++) json += String(i ? "," : "") + "[" + String((times[i] - now) / 3600.0, 2) + "," + String(temps[i], 1) + "]";
json += "],\"model\":[";
for (int h = -72; h <= 24; h++) json += String(h > -72 ? "," : "") + "[" + String(h) + "," + String(predict(now + h * 3600L), 2) + "]";
struct tm tomorrow;
time_t t7 = now + 86400;
localtime_r(&t7, &tomorrow);
tomorrow.tm_hour = 7;
tomorrow.tm_min = 0;
json += "],\"morning\":" + String(predict(mktime(&tomorrow)), 1) + "}";
server.send(200, "application/json", json);
}
const char PAGE[] = R"html(<!doctype html><meta name=viewport content='width=device-width'><title>Forecast</title>
<style>body{font-family:sans-serif;max-width:680px;margin:20px auto;padding:0 16px}svg{width:100%;height:260px;background:#F6F8FC;border-radius:12px}
.k{display:inline-block;width:12px;height:12px;border-radius:2px;margin:0 6px 0 14px}</style>
<h1>Room forecast</h1><p id=info>Loading...</p><svg id=c viewBox='0 0 960 260'></svg>
<p><span class=k style='background:#2F6FDB'></span>measured<span class=k style='background:#F08A24'></span>model and prediction (right of the line)</p>
<script>
fetch('/data').then(r => r.json()).then(d => {
const all = d.measured.map(p => p[1]).concat(d.trained ? d.model.map(p => p[1]) : []);
const lo = Math.floor(Math.min(...all, 15)) - 1, hi = Math.ceil(Math.max(...all, 25)) + 1;
const X = h => (h + 72) / 96 * 920 + 20, Y = t => 240 - (t - lo) / (hi - lo) * 220;
let s = '<line x1=' + X(0) + ' x2=' + X(0) + ' y1=10 y2=250 stroke=#bbb stroke-dasharray=4 />';
for (let t = Math.ceil(lo); t <= hi; t += 2) s += '<text x=2 y=' + (Y(t) + 4) + ' font-size=12 fill=#888>' + t + '</text>';
if (d.trained) s += '<polyline fill=none stroke=#F08A24 stroke-width=3 points="' + d.model.map(p => X(p[0]) + ',' + Y(p[1])).join(' ') + '"/>';
s += d.measured.map(p => '<circle cx=' + X(p[0]) + ' cy=' + Y(p[1]) + ' r=2.5 fill=#2F6FDB />').join('');
document.getElementById('c').innerHTML = s;
document.getElementById('info').innerHTML = d.trained
? d.count + ' measurements. Tomorrow at 07:00: <b>' + d.morning + ' °C</b>. The model is usually within ' + d.error + ' °C.'
: d.count + ' measurements so far. The model starts after 18 (3 hours).';
});
</script>)html";
void setup() {
Serial.begin(115200);
dht.begin();
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");
while (time(nullptr) < 1700000000) delay(200);
server.on("/", []() { server.send(200, "text/html", PAGE); });
server.on("/data", handleData);
server.begin();
sample();
lastSample = millis();
}
void loop() {
server.handleClient();
if (millis() - lastSample >= EVERY) {
lastSample = millis();
sample();
}
}
Try 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.
- Leave the ESP32 running. The page shows the measurements as blue dots, one every 10 minutes.
- After 3 hours, the orange model curve appears. At first it is a rough guess.
- After a day and a night, it has seen the whole rhythm. The prediction for 07:00 tomorrow becomes meaningful, and the error shrinks.
- Check in the morning: how close was the prediction?
How it works
A day is a circle: midnight comes back every 24 hours. The model describes the temperature as a baseline a plus a wave that goes up and down once a day, made from a cosine and a sine of the time of day. The two numbers b and c set how big the wave is and when its peak comes. Together, a, b and c are the model's parameters.
Training means finding the parameters that make the curve as close as possible to every measurement. The method is called least squares: it makes the sum of the squared distances between the curve and the dots as small as possible. That turns into three equations with three unknowns, which train() solves like you would on paper, by eliminating one unknown after another.
A prediction is only useful if you know how good it is. The sketch checks the model against its own data and reports the average error. Real weather models do the same, with millions of parameters instead of three.
The model only knows the daily rhythm. A sudden cold snap or someone opening a window surprises it, which is why the measured dots never sit exactly on the curve.
Try this next
- Add a second wave with a 12-hour rhythm (cos and sin of twice the angle) and see if the error gets smaller.
- Predict the humidity too.
- Save the samples in Preferences, so a restart doesn't erase the training data.
Troubleshooting
- "Loading..." for ever: the ESP32 is waiting for internet time. Check the Serial Monitor.
- The curve is flat: the room temperature hardly changes. Try a room that gets sun, or a window sill.
- The temperature is too high: the DHT11 is too close to the ESP32. Move them apart.
- 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.