Battery Powered ESP32 Gate / Door sensor

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.