Catio (background for the project)
TLDR: we have a gate that needs opening and closing daily, this project uses home assistant to monitor its state.
We built a catio for our cat. Catching covid a week after adopting her and having a recommended 6 weeks of indoor-only to mitigate risk of her running away meant a lot of litter tray emptying, so with covid-limited bodies & minds, we constructed a simple temporary outdoor enclosure using cat netting. This let our cat toilet outside in a safely confined area around the door with the cat flap.
With other neighbourhood cats fighting with our cat at night, we found it advantageous to construct a permanent catio with a gate that we open in the morning and close at dusk to keep our cat safe. Unfortunately, it’s very easy to forget to open/close the gate. In fact it’s more of a problem than the garage door.
Sensing gate position
I used a magnetic reed switch on the gate post and a magnet on the gate to sense when it is closed. The gate has a bit of play and a risk of sagging so keeping enough clearance between the magnet and the sensor was important. To help with this, I used a particularly strong magnet to increase the working distance. The magnet was extracted from a dead hard drive. The range also allowed the reed switch to be housed inside the IP67 weatherproof box along with batteries and ESP32 dev kit. The box was mounted to the gate post with 2-part epoxy and the magnet was mounted on a block of wood (painted for weather treatment).

Electronics design
Just like with my letterbox project, I made use of the ESP32’s wake-on-GPIO feature (not found on ESP8266!) to allow the micro to deep sleep until the reed switch changes the state of a GPIO. At this point the micro wakes and reports the current state via MQTT.
Since the reed switch will be open during the day and closed at night, it is fairly arbitrary whether it is used to pull up or pull down the GPIO, though your choice may require inverting the logic. I used a 2.2MΩ pull-up / pull-down resistor which draws 1.5uA from 3V3. Although this is a similar magnitude to the deep sleep consumption of the micro, it would take over a million hours (152 years!) to drain a set of 2000mAH AA batteries so is not a concern. I also used a pair of 4.7MΩ resistors to divide down the battery voltage into an ADC (with a 100nF capacitor to provide stable ADC conversions) which should be even more negligible in its quiescent current contribution.
Speaking of quiescent current, I used a Lolin32 ESP32 development kit with a low leakage LDO on it for its 3V3 power supply. I’ve found that some LDOs have horrible quiescent current, some waste over 1mA when idle which will drain those batteries in a couple of months. You may need to replace the LDO if your board came with a wasteful device. I got lucky with my Lolin32 which used a ME6211 LDO. These dev kits are getting a bit rarer these days so you may need to experiment or change the LDO on your board.
It’s also important to check that your board’s USB to UART chip for programming is powered by the USB port, not the 3V3, otherwise the chip may need to be removed, in which case you won’t have a way to recover the board if you lose access to over the air updates.
I added two LEDs, one blue, one red for easy diagnostics. They only flash briefly and have resistors chosen to minimise current draw when lit. This required using modern, high efficiency ultra bright LEDs. Here’s a photo of the electronics in situ after 503 days of operation. You can see the track cut next to the flash memory as described in Adventures powering ESP32 from batteries.

Software architecture
- Report the open/closed state of the gate via MQTT, then go into a deep sleep.
- Deep sleep has a 6 hour wakeup timer. This heartbeat allows us to detect when the batteries go flat, even if the gate is left open/closed longer than 6 hours.
- Wake from deep sleep when the GPIO state changes due to gate opening or closing.
- A helper switch in Home Assistant is read. If this switch is on, the micro will stay awake for 15 seconds to allow a software update to be sent through. This is essential as we’ve optimised the normal wake time to be very very short, making it almost impossible to get over the air updates through without this feature.
- The battery voltage is set to expire 10 mins after the heartbeat interval, so an unavailable battery voltage reading can be used by an automation to send a push alert about flat batteries.
ESPHome config
#In configuration.yaml for HomeAssistant, we need the following switch:
#mqtt:
# switch:
# - command_topic: "catio-gate/ota"
# state_topic: "catio-gate/ota"
# unique_id: catio-gate_ota
# name: "Catio-Gate OTA Update Mode"
# icon: "mdi:sleep-off"
# retain: true
esphome:
name: catio-gate
friendly_name: catio-gate
on_boot:
- priority: -300
then:
- script.execute: check_stuff
- priority: 500
then:
- delay: 15s
- if:
condition:
lambda: 'return id(ota_mode) == 0;'
then:
- lambda: 'ESP_LOGI("main", "**MQTT FAILED FOR 15s - Going to Deep Sleep**");'
- repeat:
count: 4
then:
- output.turn_on: red_led
- delay: .01s
- output.turn_off: red_led
- delay: .1s
- output.turn_on: red_led
- delay: .01s
- output.turn_off: red_led
- delay: .7s
- deep_sleep.enter:
id: sleepy
sleep_duration: 3600s
esp32:
board: lolin32_lite
framework:
type: arduino
substitutions:
devicename: catio-gate
wifi:
networks:
- ssid: !secret wifi_ssid
channel: 11
bssid: !secret wifi_mac
password: !secret wifi_password
domain: !secret wifi_domain
fast_connect: True
power_save_mode: LIGHT
logger:
api:
encryption:
key: <put-your-own-key>
ota:
platform: esphome
password: <put-your-own-key>
on_end:
then:
- lambda: |-
id(ota_mode) = 0;
- mqtt.publish:
topic: "$devicename/ota"
payload: "OFF"
retain: true
mqtt:
id: mqtt_cli
broker: <put-your-HA-IP-here>
username: "mqtt"
password: !secret mqtt_password
discovery: true
birth_message:
will_message:
on_message:
- topic: "$devicename/ota"
payload: 'ON'
then:
- lambda: |-
id(ota_mode) = 1;
#####################
# Allow the reed switch to be an interrupt that wakes on any change of state
deep_sleep:
id: sleepy
wakeup_pin:
number: GPIO13
allow_other_uses: true
wakeup_pin_mode: INVERT_WAKEUP # Either direction wakes
globals:
- id: updates
type: int
restore_value: no
initial_value: '0'
- id: ota_mode
type: int
restore_value: no
initial_value: '0'
- id: naptime
type: int
restore_value: no
initial_value: '10'
- id: mqtt_checked
type: int
restore_value: no
initial_value: '0'
- id: wake_up_reason
type: int
restore_value: no
initial_value: '0'
script:
- id: check_stuff
then:
- output.turn_on: red_led
- lambda: |-
ESP_LOGI("main", "Start of check_stuff lambda");
id(wake_up_reason) = esp_sleep_get_wakeup_cause();
ESP_LOGI("main", "Wakeup reason %i", esp_sleep_get_wakeup_cause());
id(mqtt_cli).subscribe("$devicename/ota", [=](const std::string &topic, const std::string &payload) {
id(ota_mode) = (payload.compare("ON") == 0);
id(mqtt_checked) = 1;
});
- lambda: |-
id(updates) = 0;
id(wakereason_sens).update();
id(bat).update();
- wait_until:
lambda: |-
return (id(updates) >= 1);
- wait_until:
condition:
lambda: |-
return (id(mqtt_checked) > 0);
timeout: 15s
- if:
condition:
lambda: 'return id(ota_mode) != 0;'
then: #Buy some time for a FW upgrade
- repeat:
count: 30
then:
- output.turn_on: red_led
- delay: .01s
- output.turn_off: red_led
- delay: .99s
- output.turn_off: red_led
- output.turn_on: blue_led
- delay: 2s
- output.turn_off: blue_led
#- lambda: |- Removed Dec 2024
# adc_power_release();
- deep_sleep.enter:
id: sleepy
sleep_duration:
seconds: 0
hours: 18
sensor:
- platform: wifi_signal
name: "$devicename WiFi Signal"
update_interval: 120s
- platform: adc
id: bat
pin: 33
attenuation: auto
name: "$devicename Battery Voltage"
filters:
- multiply: 2.017
on_value:
lambda: |-
id(updates)++;
expire_after: 65400s # 10min after the 18hour heartbeat update was meant to be done
- platform: template
name: "$devicename Wake Reason"
id: wakereason_sens
accuracy_decimals: 0
lambda: |-
return (id(wake_up_reason));
binary_sensor:
- platform: gpio
id: catio_gate_switch
pin:
number: GPIO13
allow_other_uses: true
name: "$devicename Position"
device_class: door
output:
- platform: gpio
id: red_led
pin: 32
- platform: gpio
id: blue_led
pin: 27
Performance
The catio gate sensor lasted 503 days on a single set of 4x AA alkaline batteries. This was before an optimisation was made that could extend the battery life by around 5x.
