What does your cat do all day while you are at school? Most pets have a rhythm: a lazy morning, a crazy half hour in the evening, a midnight snack. In this lesson you find out. A PIR motion sensor watches your pet's favourite place: its bed, its food bowl or the sofa. The ESP32 counts the movements for every hour, knows the real time from the internet, and serves a web page with a bar chart of today next to yesterday. At a glance you see when your pet was last seen, how many active minutes it had and which hour was its busiest.
What you'll learn
- How to keep counts per hour of the day in an array
- How the ESP32 gets the real time from the internet with NTP
- How a web page can draw a bar chart from JSON data
- How to save the counts so a power cut doesn't wipe them
What you need
- ESP32 board, expansion board and USB cable (ESP32 IoT Starter Kit)
- PIR motion sensor (ESP32 IoT Starter Kit)
- 1 LED and 1 resistor, 220 Ω (ESP32 IoT Starter Kit)
- A USB phone charger to keep it running all day
- Breadboard and jumper wires (ESP32 IoT Starter Kit)
Wiring

| From | To |
|---|---|
| PIR VCC | ESP32 5V |
| PIR OUT | ESP32 GPIO 27 |
| PIR GND | GND |
| ESP32 GPIO 25 | 220 Ω resistor, then the LED's long leg |
| LED short leg | GND |
Turn the PIR's time knob to the minimum (fully anticlockwise) and point the sensor at the pet's spot from about 1 to 2 metres away. Keep it away from heaters and sunny windows, which it can mistake for a warm animal.

The sketch
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, for example EET-2EEST,M3.5.0/3,M10.5.0/4 for Estonia, GMT0BST,M3.5.0/1,M10.5.0 for the UK, <+04>-4 for the UAE and Oman, or <+03>-3 for Saudi Arabia, Qatar, Kuwait and Bahrain.
Put your pet's name in PET. WebServer, ESPmDNS and Preferences come with the ESP32 board package.
#include <WiFi.h>
#include <WebServer.h>
#include <ESPmDNS.h>
#include <Preferences.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 char* PET = "Milo";
const int PIR_PIN = 27, LED_PIN = 25;
WebServer server(80);
Preferences prefs;
uint16_t today[24], yesterday[24]; // movements per hour
uint16_t activeMinutes = 0; // minutes with at least one movement
int dayOfYear = -1, savedHour = -1;
time_t lastSeen = 0;
int lastMinute = -1;
bool lastPir = LOW;
const char PAGE[] PROGMEM = R"rawliteral(<!doctype html><html><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"><title>Pet activity</title>
<style>body{font-family:Arial,sans-serif;margin:0;padding:16px;background:#F6F8FC;color:#1B3A7B}
.card{background:#fff;border-radius:16px;padding:16px;margin:0 auto 14px;max-width:720px;box-shadow:0 2px 8px rgba(0,0,0,.08)}
.big{font-size:28px;font-weight:bold}#chart{display:flex;align-items:flex-end;gap:3px;height:180px;margin-top:10px}
.col{flex:1;position:relative;height:100%}.bar{position:absolute;bottom:0;border-radius:4px 4px 0 0}
.y{left:0;right:0;background:#C9D4EA}.t{left:20%;right:20%;background:#DE544A}
#hours{display:flex;gap:3px;font-size:10px;color:#5B6470}#hours span{flex:1;text-align:center}
.key span{display:inline-block;width:12px;height:12px;border-radius:3px;margin:0 4px 0 12px}</style></head><body>
<div class="card"><div class="big" id="title">...</div><div id="seen"></div></div>
<div class="card"><b>Movements per hour</b><span class="key"><span style="background:#DE544A"></span>today<span style="background:#C9D4EA"></span>yesterday</span>
<div id="chart"></div><div id="hours"></div></div>
<div class="card"><div id="stats"></div></div>
<script>
var hours = document.getElementById('hours');
for (var h = 0; h < 24; h++) { var s = document.createElement('span'); s.textContent = h % 3 ? '' : h; hours.appendChild(s); }
function load() {
fetch('/data').then(function (r) { return r.json(); }).then(function (d) {
document.getElementById('title').textContent = d.pet + "'s day";
var mins = d.lastSeen ? Math.round((d.now - d.lastSeen) / 60) : -1;
document.getElementById('seen').textContent = mins < 0 ? 'Not seen yet today' : mins < 1 ? 'Moving right now!' : 'Last seen ' + (mins < 60 ? mins + ' min' : Math.floor(mins / 60) + ' h ' + (mins % 60) + ' min') + ' ago';
var max = Math.max.apply(null, d.today.concat(d.yesterday).concat([1]));
var chart = document.getElementById('chart'); chart.innerHTML = '';
var busiest = 0, total = 0;
for (var h = 0; h < 24; h++) {
var col = document.createElement('div'); col.className = 'col';
col.innerHTML = '<div class="bar y" style="height:' + (d.yesterday[h] / max * 100) + '%"></div><div class="bar t" style="height:' + (d.today[h] / max * 100) + '%"></div>';
col.title = h + ':00 - today ' + d.today[h] + ', yesterday ' + d.yesterday[h];
chart.appendChild(col);
if (d.today[h] > d.today[busiest]) busiest = h; total += d.today[h];
}
document.getElementById('stats').innerHTML = 'Movements today: <b>' + total + '</b><br>Active minutes: <b>' + d.active + '</b><br>Busiest hour: <b>' + (total ? busiest + ':00-' + (busiest + 1) + ':00' : '-') + '</b>';
});
}
load(); setInterval(load, 10000);
</script></body></html>)rawliteral";
void save() {
prefs.putBytes("today", today, sizeof(today));
prefs.putBytes("yday", yesterday, sizeof(yesterday));
prefs.putUShort("active", activeMinutes);
prefs.putInt("day", dayOfYear);
}
void handleData() {
String j = "{\"pet\":\"" + String(PET) + "\",\"now\":" + String((long)time(nullptr)) + ",\"lastSeen\":" + String((long)lastSeen) + ",\"active\":" + String(activeMinutes);
j += ",\"today\":[";
for (int h = 0; h < 24; h++) j += (h ? "," : "") + String(today[h]);
j += "],\"yesterday\":[";
for (int h = 0; h < 24; h++) j += (h ? "," : "") + String(yesterday[h]);
j += "]}";
server.send(200, "application/json", j);
}
void setup() {
Serial.begin(115200);
pinMode(PIR_PIN, INPUT);
pinMode(LED_PIN, OUTPUT);
prefs.begin("pet", false);
prefs.getBytes("today", today, sizeof(today)); // stays 0 the first time
prefs.getBytes("yday", yesterday, sizeof(yesterday));
activeMinutes = prefs.getUShort("active", 0);
dayOfYear = prefs.getInt("day", -1);
WiFi.begin(WIFI_NAME, WIFI_PASSWORD);
Serial.print("Connecting");
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
configTzTime(TIME_ZONE, "pool.ntp.org", "time.google.com");
MDNS.begin("petday"); // http://petday.local
server.on("/", []() { server.send_P(200, "text/html", PAGE); });
server.on("/data", handleData);
server.begin();
Serial.print("\nOpen http://petday.local or http://");
Serial.println(WiFi.localIP());
}
void loop() {
server.handleClient();
struct tm t;
if (!getLocalTime(&t, 10)) return; // no time yet: wait for NTP
if (t.tm_yday != dayOfYear) { // a new day: today becomes yesterday
if (dayOfYear >= 0) memcpy(yesterday, today, sizeof(today));
memset(today, 0, sizeof(today));
activeMinutes = 0;
dayOfYear = t.tm_yday;
save();
}
bool pir = digitalRead(PIR_PIN);
digitalWrite(LED_PIN, pir);
if (pir == HIGH && lastPir == LOW) { // a new movement
today[t.tm_hour]++;
lastSeen = time(nullptr);
int minuteOfDay = t.tm_hour * 60 + t.tm_min;
if (minuteOfDay != lastMinute) { // count each minute only once
lastMinute = minuteOfDay;
activeMinutes++;
}
Serial.printf("Movement at %02d:%02d\n", t.tm_hour, t.tm_min);
}
lastPir = pir;
if (t.tm_hour != savedHour) { // save once an hour
savedHour = t.tm_hour;
save();
}
}
Try it
- Upload the sketch and open the Serial Monitor at 115200 baud. Wait a minute: the PIR sensor needs time to settle after power-up.
- Wave in front of the sensor: the LED lights and the Serial Monitor prints "Movement at ..." with the time.
- Open the address from the Serial Monitor on your phone or computer, on the same Wi-Fi:
http://petday.local, or the number it shows, such ashttp://192.168.1.57. - Put the sensor near your pet's favourite spot and let it run for a day (with an adult's OK). Then look at the chart: when was your pet most active?
- The next day, the red bars start again from zero and yesterday's bars appear in light blue behind them, so you can compare.
How it works
The sketch keeps two arrays of 24 numbers: one for today and one for yesterday, a box for each hour. When the PIR's output goes from LOW to HIGH, something warm moved, so the box for the current hour gets one more. "Active minutes" counts each minute only once, however often the pet moves in it, which says more about how busy the day was than the raw count.
To know the hour, the ESP32 asks a time server on the internet (NTP) and uses your time zone. When the day of the year changes, today's array is copied to yesterday and cleared. Once an hour, both arrays are saved with Preferences in the ESP32's flash memory, so a power cut loses at most one hour.
The web page asks for /data every 10 seconds and gets JSON back. It draws the chart with plain HTML: each hour is a column with two bars whose heights are percentages of the busiest hour. Tap or hover over a column to see the numbers.
Try this next
- Put a second sensor at the food bowl and chart "meals" separately.
- Send yourself a ntfy message if your pet hasn't been seen for 6 hours during the day.
- Keep a whole week: seven arrays of 24 hours, and a page to flip between days.
- Compare a cat's and a dog's day with a friend who has the other pet.
Troubleshooting
-
petday.local doesn't open: some phones don't understand
.localnames. Type the number from the Serial Monitor instead, withhttp://in front. - Every hour shows movement although the pet is away: the PIR sees a heater, a curtain or sunlight. Point it elsewhere and turn its sensitivity knob down.
- The chart stays empty: it only counts once it knows the time. Check the Wi-Fi has internet access, and that "Movement at" appears in the Serial Monitor.
-
The hours are wrong: check
TIME_ZONE. - 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.