I'm in a good position that I can rent a house with a garage. The garage even has an electric door with remote control. Every now and then, after I've left the house, I can't quite remember if I've closed the garage door. I guess it's one of those "did I lock the door?" or "did I turn the gas off?" situations. I probably did, I just can't quite remember.

Because I'm in a rental house, I'm not allowed to change anything to it without the owner's permission. So hacking the garage door opener electronics is out of the question, unfortunately.

Since I usually think about the garage door when I'm not even that far from home (but far enough to not want to turn around if unnecessary), I decided that all I need is to know whether it's closed or not. And that part is easy without having too much impact on the structural integrity of the house (and so can be achieved without permission).

I used a SparkFun "The Thing" ESP32 device with a few external components, mainly a 2400mAh Li-Po battery and a reed switch. The idea is to put the reed switch in a position that it closes when the door is closed. It's hooked up between a GPIO pin with an internal pull-up resistor and ground. This means that the GPIO input is LOW when the door is closed, and HIGH when the door is open.

"The Thing" has an integrated battery charger for single cell Li-Po batteries, so I don't have to charge it separately. Also, it has on-board WiFi, and you can program it using the standard Arduino IDE. Quite convenient, really.

I went through a couple of iterations before I landed on my final implementation. Ultimately, it was the power consumption that made me decide to use Deep Sleep mode of the ESP32. Using a web server is easy, and very nice, but based on some very crude measurements, the ESP32 draws on average about 40mA, and this increases to over 150mA when the radio is used. It would drain the battery too quickly, and so I had to look into using an adapter (meaning I would also need to add additional cables, because the power point is nowhere near the door). It was quickly getting out of hand, hence my decision to use Deep Sleep mode and run it off a battery only.

My implementation is kind of a hybrid solution, consisting of the ESP32 and a Raspberry Pi (which was already hooked up to my local network, so no additional hassle there). I just had a couple of requirements:

  1. I need to be able to see the door status remotely.
  2. I want the door status to be updated immediately after a first time boot.
  3. I want the ESP32 to run from a battery (because reasons, see above).
  4. I want to be able to see how old the last door update is.
  5. I want to get periodic updates.

Here is the overall project directory structure:

garage-door-checker/
├── esp32
│   └── main
│       └── main.ino
├── README.md
└── web
    ├── app
    │   ├── app.py
    │   ├── requirements.txt
    │   └── templates
    │       ├── index.html
    │       └── status.html
    ├── deployments
    │   └── app
    │       └── Dockerfile
    └── docker-compose.yml

It contains a folder esp32/ for the ESP32 source code, and web/ for the back end, which I'll get to in the next article. In this first article, I'll focus on the ESP32.

Programming the ESP32

Luckily, there are loads of libraries and accompanying examples available for Arduino in general, and ESP32 in particular. I used two 'Deep Sleep' examples just to see if I could get it work: waking from a state change on a GPIO pin (for the door sensor), and waking from a timer (for periodic updates). It probably took me only 10-15 minutes to get that to work on my actual hardware, so I was totally confident I could use this functionality without any issues at all.

We need to include the following files in main.ino to handle the GPIO correctly:

#include <driver/gpio.h>
#include <driver/rtc_io.h>

The setup() function looks like this:

RTC_DATA_ATTR bool first_run = false;

void setup() {
  Serial.begin(115200);

  /* Configure sensor pin as GPIO again. */
  rtc_gpio_deinit(GPIO_NUM_15);

  /* Get sensor input value. */
  const int pin_state = digitalRead(GPIO_NUM_15);

  switch(esp_sleep_get_wakeup_cause()) {
  case ESP_SLEEP_WAKEUP_EXT0:
    Serial.println("Wakeup caused by ext0");
    update(pin_state);
    break;

  case ESP_SLEEP_WAKEUP_TIMER:
    Serial.println("Wakeup caused by timer");
    update(pin_state);
    break;

  default:
    Serial.println("Wakeup caused by neither ext0 nor timer");
    if (!first_run) {
      first_run = true;
      update(pin_state);
    }
    break;
  }

  /* Make sure to pull up the sensor input. */
  rtc_gpio_pullup_en(GPIO_NUM_15);

  /* Set up the conditions for waking up. */
  esp_sleep_enable_ext0_wakeup(GPIO_NUM_15, pin_state ^ 1);
  esp_sleep_enable_timer_wakeup(SLEEP_FOR * uS_IN_S);

  Serial.println("ZZZzzz");
  esp_deep_sleep_start();
}

At the top, we initialise the serial port. We then explicitly "de-initialise" the GPIO pin so we're sure we can use it as a digital input. Then we read its state and store it in the variable pin_state.

The switch statement is used to determine the reason for waking up. We could get here after an initial boot, which we handle in the default case. The variable first_run is stored in non-volatile RTC memory. Since we're using Deep Sleep, all other memory is turned off. We use this variable to send an update after initial boot only once.

We can also get here when the input pin changes state, or when the timer fires. In either case, we want to send an update, for which we use the function update(), passing in the current pin state. More about that function later.

After sending the update, we're ready to go (back) to sleep. We explicitly enable the pull-up for the GPIO pin (this is necessary when using pull-ups in Deep Sleep). We then set the two wake-up triggers. The timer is pretty much self-explanatory (it's good to know that it assumes the delay is in microseconds).

The trigger for the GPIO pin is set to pin_state ^ 1. ^ is XOR, meaning 0 ^ 1 → 1 and 1 ^ 1 → 0. Since a pin state is either HIGH (=1) or LOW (=0), this effectively inverts its value. We're interested in a change on the GPIO input, so we need to set the trigger value quite literally to not the current value.

Finally, snoozing starts just after we've called esp_deep_sleep_start().

That's the Deep Sleep functionality of the system sorted. But we haven't talked about how the updates will be handled, what they will look like, where they'll go, etc., so let's do that now.

I decided on using the ESP32's WiFi functionality. Again, there are plenty of example to work from.

We will create a WiFi client (or station) and connect the ESP32 to my home WiFi Access Point. The actual updates (which consist of the door state and a timestamp) will then be sent to a Redis server. This is probably overkill, but it was incredibly easy to set up. Furthermore, it's supported by ESP32 and by Python.

The timestamp will be created using an NTP client (NTP = Network Time Protocol). It's a simple software solution to get around the problem of not having a Real-Time Clock (RTC).

In order to get this to work, we need to install two libraries: 'Redis for Arduino' (v2.1.3 is what I used), and 'NTPClient' (I use v3.2.0). You can install both libraries from the Library Manager in the Arduino IDE.

We also need to include some more files:

#include <NTPClient.h>
#include <Redis.h>
#include <WiFi.h>
#include <WiFiClient.h>
#include <WiFiUdp.h>

And a few macros:

#define WIFI_SSID "<Your WiFi SSID>"
#define WIFI_PASS "<Your WiFi password>"

#define REDIS_ADDR "<Your Redis server's IP address>"
#define REDIS_PORT 6379             /* This is the default port */
#define REDIS_PASS "<Your Redis password (may be empty)>"
#define REDIS_HASH "my-garage-door" /* some hash name you like */

#define uS_IN_S   1000000ULL  /* Conversion factor for usecs to secs */
#define SLEEP_FOR (10 * 60)   /* Time ESP32 will go to sleep (in secs) */

#define TZ_OFFSET (8 * 60 * 60)     /* set to your own timezone (secs) */
#define NTP_POOL  "au.pool.ntp.org" /* Use an NTP pool near you */

The update() function then looks like this:

void update(const int pin_state) {
  /* Set up WiFi client in station mode */
  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  Serial.print("Connecting to WiFi");
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }

  Serial.println();
  Serial.print("IP Address: ");
  Serial.println(WiFi.localIP());

  /* Start the NTP client. */
  WiFiUDP ntpUDP;
  NTPClient ntpClient(ntpUDP, NTP_POOL, TZ_OFFSET, 1800);
  ntpClient.begin();

  /* Set up Redis client, connect and authenticate. */
  WiFiClient redisConn;
  if (!redisConn.connect(REDIS_ADDR, REDIS_PORT)) {
    Serial.println("Failed to connect to the Redis server!");
    return;
  }

  Redis redisClient(redisConn);
  auto conn = redisClient.authenticate(REDIS_PASS);
  if (conn == RedisSuccess) {
    Serial.printf("Connected to the Redis server at %s!\n", REDIS_ADDR);
  } else {
    Serial.printf("Failed to authenticate to the Redis server! Errno: %d\n", (int)conn);
    return;
  }

  /* Perform the actual update. */
  ntpClient.update();
  const char *strState = pin_state == HIGH ? "open" : "closed";
  const String timestamp = ntpClient.getFormattedTime();

  redisClient.hset(REDIS_HASH, "status", strState);
  redisClient.hset(REDIS_HASH, "updated_at", timestamp.c_str());

  Serial.print(timestamp);
  Serial.print(": Garage door is ");
  Serial.println(strState);

  /* Close the connection to the Redis server. */
  redisConn.stop();
}

At the top of the function, we create the WiFi client, and connect to the configured Access Point by way of the macros WIFI_SSID and WIFI_PASS.

Once the connection is established, we create the NTP client and the Redis client, which will connect to the Redis server. Finally, we update the time using the NTP client.

We use the argument pin_state to set a variable to "open" or "closed", the human readable equivalent of HIGH and LOW, which better reflects the actual state of the garage door. We get the timestamp, and we do some more logging. Finally, we send the update to the Redis server in the form of a hash map, close the connection, and return.

For those of you who know Arduino and are wondering where the function loop() is:

void loop() {}

It will never be called, as the ESP32 will go into deep sleep from within the setup() function.

From some, again very crude, measurements, it seems the whole process from waking up, sending updates to the Redis server, and going back to sleep, takes between 1.5 - 3 seconds. I estimate that this device should be able to run at least a month on the 2400mAh battery I use. Time will tell if I'm right.

This is all you need to get the ESP32 up and running. Load it into the Arduino IDE, and program your ESP32. In the next article, I'll explain how to set up the back end for this project.

For completeness, here is the full version of main.ino:

#include <NTPClient.h>
#include <Redis.h>
#include <WiFi.h>
#include <WiFiClient.h>
#include <WiFiUdp.h>
#include <driver/gpio.h>
#include <driver/rtc_io.h>

#define WIFI_SSID "<Your WiFi SSID>"
#define WIFI_PASS "<Your WiFi password>"

#define REDIS_ADDR "<Your Redis server's IP address>"
#define REDIS_PORT 6379             /* This is the default port */
#define REDIS_PASS "<Your Redis password (may be empty)>"
#define REDIS_HASH "my-garage-door" /* some hash name you like */

#define uS_IN_S   1000000ULL  /* Conversion factor for usecs to secs */
#define SLEEP_FOR (10 * 60)   /* Time ESP32 will go to sleep (in secs) */

#define TZ_OFFSET (8 * 60 * 60)     /* set to your own timezone (secs) */
#define NTP_POOL  "au.pool.ntp.org" /* Use an NTP pool near you */

RTC_DATA_ATTR bool first_run = false;

void setup() {
  Serial.begin(115200);

  /* Configure sensor pin as GPIO again. */
  rtc_gpio_deinit(GPIO_NUM_15);

  /* Get sensor input value. */
  const int pin_state = digitalRead(GPIO_NUM_15);

  switch(esp_sleep_get_wakeup_cause()) {
  case ESP_SLEEP_WAKEUP_EXT0:
    Serial.println("Wakeup caused by ext0");
    update(pin_state);
    break;

  case ESP_SLEEP_WAKEUP_TIMER:
    Serial.println("Wakeup caused by timer");
    update(pin_state);
    break;

  default:
    Serial.println("Wakeup caused by neither ext0 nor timer");
    if (!first_run) {
      first_run = true;
      update(pin_state);
    }
    break;
  }

  /* Make sure to pull up the sensor input. */
  rtc_gpio_pullup_en(GPIO_NUM_15);

  /* Set up the conditions for waking up. */
  esp_sleep_enable_ext0_wakeup(GPIO_NUM_15, pin_state ^ 1);
  esp_sleep_enable_timer_wakeup(SLEEP_FOR * uS_IN_S);

  Serial.println("ZZZzzz");
  esp_deep_sleep_start();
}

void update(const int pin_state) {
  /* Set up WiFi client in station mode */
  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  Serial.print("Connecting to WiFi");
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }

  Serial.println();
  Serial.print("IP Address: ");
  Serial.println(WiFi.localIP());

  /* Start the NTP client. */
  WiFiUDP ntpUDP;
  NTPClient ntpClient(ntpUDP, NTP_POOL, TZ_OFFSET, 1800);
  ntpClient.begin();

  /* Set up Redis client, connect and authenticate. */
  WiFiClient redisConn;
  if (!redisConn.connect(REDIS_ADDR, REDIS_PORT)) {
    Serial.println("Failed to connect to the Redis server!");
    return;
  }

  Redis redisClient(redisConn);
  auto conn = redisClient.authenticate(REDIS_PASS);
  if (conn == RedisSuccess) {
    Serial.printf("Connected to the Redis server at %s!\n", REDIS_ADDR);
  } else {
    Serial.printf("Failed to authenticate to the Redis server! Errno: %d\n", (int)conn);
    return;
  }

  /* Perform the actual update. */
  ntpClient.update();
  const char *strState = pin_state == HIGH ? "open" : "closed";
  const String timestamp = ntpClient.getFormattedTime();

  redisClient.hset(REDIS_HASH, "status", strState);
  redisClient.hset(REDIS_HASH, "updated_at", timestamp.c_str());

  Serial.print(timestamp);
  Serial.print(": Garage door is ");
  Serial.println(strState);

  /* Close the connection to the Redis server. */
  redisConn.stop();
}

void loop() {}