Build a cat flap tracker that knows if your cat is in or out

Two infrared sensors in the cat flap tunnel tell which way your cat went. The ESP32 keeps a diary of every trip, shows on your phone whether your cat is inside or outside and for how long, and can send...
  • Level:Advanced
  • Duration: 2 h 30 min
  • Ages 12+
  • Platform: ESP32

Tools for this lesson

Helpful for this build. Open a tool to see tips and more projects that use it.

Build a cat flap tracker that knows if your cat is in or out – EEMENTOR STEM™ lesson

"Is the cat in?" is a question many families ask every evening. In this capstone your cat flap answers it. Two infrared obstacle sensors sit one behind the other in the flap's tunnel, or on a cardboard model of it. When the cat walks through, it blocks first one sensor and then the other, and the order tells the direction: outer then inner means coming in, inner then outer means going out. The ESP32 keeps a diary of trips with the real time, shows a big "inside" or "outside" on a web page, adds up today's time outdoors, and can send a phone message through ntfy when your cat heads out after dark.

What you'll learn

  • How two sensors and the order of their signals reveal a direction
  • How a state machine ignores false triggers, like a cat that only peeks through
  • How to keep a diary in a ring buffer and remember the state after a power cut
  • How to combine a web page, real time and optional phone alerts

What you need

  • ESP32 board, expansion board and USB cable (ESP32 IoT Starter Kit)
  • 2 IR obstacle avoidance sensor modules (45-in-1 Sensor Kit)
  • 1 LED and 1 resistor, 220 Ω (ESP32 IoT Starter Kit)
  • Cardboard to build a model tunnel (or tape for a real cat flap, with an adult)
  • Optional: a phone with the free ntfy app
  • Breadboard and jumper wires (ESP32 IoT Starter Kit)

Animal welfare comes first. Nothing may narrow or block the flap, and no wires may hang where the cat can chew them. Test with a model tunnel and your hand first. On a real flap, mount the sensors outside the tunnel, looking in through the sides.

Wiring

Breadboard wiring diagram: Build a cat flap tracker that knows if your cat is in or out
Breadboard view. Check each connection against the wiring table below.
From To
Both IR sensors VCC 3V3
Both IR sensors GND GND
Outer sensor OUT ESP32 GPIO 26
Inner sensor OUT ESP32 GPIO 27
ESP32 GPIO 25 220 Ω resistor, then the LED's long leg
LED short leg GND

Mount the two sensors about 8 to 10 cm apart along the tunnel, the "outer" one on the garden side. Turn each sensor's knob so that its small LED lights when your hand is in the tunnel, but not when it is empty.

Set up ntfy (optional)

For phone messages, install the free ntfy app and subscribe to a long, random topic name, as in the ntfy lesson. Leave TOPIC empty to switch messages off.

Example screen for: Build a cat flap tracker that knows if your cat is in or out
Example screen with sample values. Yours may look different.

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 cat's name in CAT. Everything else is built into the ESP32 board package.

#include <WiFi.h>
#include <WebServer.h>
#include <ESPmDNS.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.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* CAT = "Milo";
const char* TOPIC = "";                  // your ntfy topic, or "" for no messages
const int NIGHT_FROM = 22, NIGHT_TO = 6; // alert when going out between these hours
const int OUTER_PIN = 26, INNER_PIN = 27, LED_PIN = 25;
const unsigned long PASS_TIME = 3000;    // the second sensor must follow within 3 s

WebServer server(80);
Preferences prefs;
struct Trip { time_t when; bool cameIn; };
Trip diary[20];                          // ring buffer of the last 20 trips
int diaryCount = 0, diaryNext = 0;
bool inside = true;
time_t since = 0;
long outSecondsToday = 0;
int today = -1;
int firstSensor = -1;                    // -1 = waiting, 0 = outer first, 1 = inner first
unsigned long firstAt = 0, quietUntil = 0;

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>Cat flap</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:560px;box-shadow:0 2px 8px rgba(0,0,0,.08)}
#state{font-size:34px;font-weight:bold}.in{color:#1E9E5A}.out{color:#DE544A}li{padding:6px 0;border-bottom:1px solid #eee;list-style:none}
ul{padding:0;margin:0}button{font-size:16px;padding:10px 14px;border-radius:10px;border:0;margin:4px;background:#E5E7EB}</style></head><body>
<div class="card"><div id="state">...</div><div id="since"></div></div>
<div class="card"><b>Today</b><div id="today"></div></div>
<div class="card"><b>Diary</b><ul id="diary"></ul></div>
<div class="card">Wrong? <button onclick="fix(1)">Inside now</button><button onclick="fix(0)">Outside now</button></div>
<script>
function hm(t) { var d = new Date(t * 1000); return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); }
function dur(s) { s = Math.max(0, s); var h = Math.floor(s / 3600), m = Math.floor(s % 3600 / 60); return (h ? h + ' h ' : '') + m + ' min'; }
function load() {
  fetch('/data').then(function (r) { return r.json(); }).then(function (d) {
    var st = document.getElementById('state');
    st.textContent = d.cat + ' is ' + (d.inside ? 'INSIDE' : 'OUTSIDE'); st.className = d.inside ? 'in' : 'out';
    document.getElementById('since').textContent = d.since ? 'since ' + hm(d.since) + ' (' + dur(d.now - d.since) + ')' : '';
    document.getElementById('today').textContent = 'Time outside: ' + dur(d.outToday) + ' - trips: ' + d.trips.filter(function (t) { return new Date(t[0] * 1000).toDateString() === new Date().toDateString(); }).length;
    document.getElementById('diary').innerHTML = d.trips.map(function (t) { return '<li>' + hm(t[0]) + ' - ' + (t[1] ? 'came in' : 'went out') + '</li>'; }).join('') || '<li>No trips yet</li>';
  });
}
function fix(i) { fetch('/set?inside=' + i).then(load); }
load(); setInterval(load, 5000);
</script></body></html>)rawliteral";

bool blocked(int pin) { return digitalRead(pin) == LOW; }   // most IR modules pull OUT low when they see something

void notify(String message) {
  if (strlen(TOPIC) == 0 || WiFi.status() != WL_CONNECTED) return;
  WiFiClientSecure client;
  client.setInsecure();
  HTTPClient http;
  http.begin(client, String("https://ntfy.sh/") + TOPIC);
  http.addHeader("Title", "Cat flap");
  http.addHeader("Tags", "cat2");
  int code = http.POST(message);
  http.end();
  Serial.printf("ntfy answered %d\n", code);
}

void saveState() {
  prefs.putBool("inside", inside);
  prefs.putLong("since", (long)since);
}

void trip(bool cameIn) {
  time_t now = time(nullptr);
  if (!inside && cameIn && since > 0) outSecondsToday += now - since;   // add the time that just ended
  inside = cameIn;
  since = now;
  diary[diaryNext] = {now, cameIn};
  diaryNext = (diaryNext + 1) % 20;
  if (diaryCount < 20) diaryCount++;
  saveState();
  struct tm t;
  localtime_r(&now, &t);
  Serial.printf("%02d:%02d %s %s\n", t.tm_hour, t.tm_min, CAT, cameIn ? "came in" : "went out");
  bool night = t.tm_hour >= NIGHT_FROM || t.tm_hour < NIGHT_TO;
  if (!cameIn && night) notify(String(CAT) + " went out at night");
}

void handleData() {
  time_t now = time(nullptr);
  long outToday = outSecondsToday + (!inside && since > 0 ? now - since : 0);
  String j = "{\"cat\":\"" + String(CAT) + "\",\"inside\":" + (inside ? "true" : "false") + ",\"since\":" + String((long)since) + ",\"now\":" + String((long)now) + ",\"outToday\":" + String(outToday) + ",\"trips\":[";
  for (int i = 0; i < diaryCount; i++) {   // newest first
    Trip& t = diary[(diaryNext - 1 - i + 20) % 20];
    j += (i ? "," : "") + String("[") + String((long)t.when) + "," + (t.cameIn ? "1" : "0") + "]";
  }
  j += "]}";
  server.send(200, "application/json", j);
}

void handleSet() {                       // correct the state from the page
  inside = server.arg("inside") == "1";
  since = time(nullptr);
  saveState();
  server.send(200, "text/plain", "ok");
}

void setup() {
  Serial.begin(115200);
  pinMode(OUTER_PIN, INPUT);
  pinMode(INNER_PIN, INPUT);
  pinMode(LED_PIN, OUTPUT);
  prefs.begin("catflap", false);
  inside = prefs.getBool("inside", true);
  since = prefs.getLong("since", 0);
  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("catflap");                 // http://catflap.local
  server.on("/", []() { server.send_P(200, "text/html", PAGE); });
  server.on("/data", handleData);
  server.on("/set", handleSet);
  server.begin();
  Serial.print("\nOpen http://catflap.local or http://");
  Serial.println(WiFi.localIP());
}

void loop() {
  server.handleClient();
  unsigned long now = millis();
  bool outer = blocked(OUTER_PIN), inner = blocked(INNER_PIN);
  digitalWrite(LED_PIN, outer || inner);

  struct tm t;
  if (getLocalTime(&t, 0) && t.tm_yday != today) {   // midnight: start a new day
    today = t.tm_yday;
    outSecondsToday = 0;
    if (!inside) since = time(nullptr);
  }

  if (now < quietUntil) return;          // just after a trip: ignore the tail of the cat
  if (firstSensor < 0) {                 // waiting for a cat
    if (outer) { firstSensor = 0; firstAt = now; }
    else if (inner) { firstSensor = 1; firstAt = now; }
  } else {
    bool second = firstSensor == 0 ? inner : outer;
    if (second) {                        // the other sensor followed: a real trip
      trip(firstSensor == 0);            // outer first = came in
      firstSensor = -1;
      quietUntil = now + 3000;
    } else if (now - firstAt > PASS_TIME && !outer && !inner) {
      firstSensor = -1;                  // only peeked, then turned back
    }
  }
}

Test it

  1. Build a cardboard tunnel about 15 cm wide and 20 cm long, and fix the two sensors in its sides, about 8 cm apart.
  2. Upload the sketch and open the Serial Monitor at 115200 baud.
  3. Move your hand (or a toy cat) through the tunnel from the outer to the inner sensor: the Serial Monitor says "came in". Go the other way: "went out".
  4. Put your hand in front of one sensor only and pull it back: nothing happens, because a peek is not a trip.
  5. Open the address from the Serial Monitor on your phone or computer, on the same Wi-Fi: http://catflap.local, or the number it shows, such as http://192.168.1.57.
  6. If the state is ever wrong, for example because the cat squeezed through very fast, tap "Inside now" or "Outside now" on the page.

How it works

One sensor can tell that something passed, but not which way. Two sensors in a row can, because of the order: a cat coming in blocks the outer sensor first. The sketch is a small state machine. It waits for the first sensor, remembers which one it was and when, and waits up to 3 seconds for the other one. If the other one follows, that's a trip in the matching direction. If the first sensor clears without the second one ever triggering, the cat only peeked and turned back, so nothing is recorded. After a trip, the sensors are ignored for 3 seconds, so the cat's tail can't count as a second trip.

The diary is a ring buffer of 20 trips: new trips overwrite the oldest ones, so the memory never fills up. The current state (inside or outside, and since when) is saved with Preferences after every trip, so a power cut doesn't make the tracker forget that the cat is out. Time outside today adds up every outdoor stretch, including the one still going on.

Try this next

  • Count trips per day and warn if your cat hasn't come home for 12 hours.
  • Track two cats: add an RFID tag reader at the flap (the RC522 from the kit, in a waterproof box) to tell them apart.
  • Add a "curfew" mode: an LED lights when the cat is out after dark, as a reminder to call it in.
  • Show a chart of how long your cat spends outside each day of the week.

Troubleshooting

  • catflap.local doesn't open: some phones don't understand .local names. Type the number from the Serial Monitor instead, with http:// in front.
  • Every trip counts the wrong way round: the outer and inner sensors are swapped. Swap the wires on GPIO 26 and 27.
  • Nothing triggers, or the LED is always on: turn each sensor's knob until its LED is off with an empty tunnel and on with your hand in it. Bright sunlight can blind IR sensors; shade them.
  • No ntfy messages: they are only sent for trips out at night (22:00 to 6:00), and only if TOPIC is set.
  • 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.

Build it yourself

Everything used in this lesson comes in one kit, with parts tested by our engineers.

ESP32 IoT Starter Kit | Arduino IDE Compatible | Free Tutorials $57.00

Shop the kit