cartland / SmartGarageDoor

DIY smart garage door (Arduino sensors, Arduino button pusher, Firebase server, Android app)

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Smart Garage Door

Author: Christopher Cartland

DIY smart garage door system built with Arduino, Firebase, and Android.

Table of Contents

  • Microcontroller: Adafruit HUZZAH32 - ESP32 Feather
  • Sensor: Adafruit Magnetic contact switch (door sensor)
  • Button: Adafruit Non-Latching Mini Relay FeatherWing
  • Android: Android app
  • Server: Firebase Functions

Microcontroller

I used 2 WiFi capable boards for this project:

I also did some tests with Adafruit Metro M4 Express AirLift (WiFi) - Lite, but I mostly used the Feather because it is smaller.

To build for the Metro M4, two flags need to change. The Feather is selected by default.

#define USE_ADAFRUIT_HUZZAH32_ESP32_FEATHER true
#define USE_ADAFRUIT_METRO_M4_EXPRESS_AIRLIFT false

// Exactly one of the following must be true!!!
// * USE_ADAFRUIT_HUZZAH32_ESP32_FEATHER
// Adafruit HUZZAH32 - ESP32 Feather
#define USE_ADAFRUIT_HUZZAH32_ESP32_FEATHER true
// * USE_ADAFRUIT_METRO_M4_EXPRESS_AIRLIFT
// Adafruit Metro M4 Express AirLift
#define USE_ADAFRUIT_METRO_M4_EXPRESS_AIRLIFT false

The main difference between the boards is that different WiFi libraries are used.

#if USE_WIFI_NINA
  return WiFiNINASetup(wifiSSID, wifiPassword);
#endif
#if USE_MULTI_WIFI
  return wifiMultiSetup(wifiSSID, wifiPassword);
#endif

bool wifiSetup(String wifiSSID, String wifiPassword) {
#if USE_WIFI_NINA
return WiFiNINASetup(wifiSSID, wifiPassword);
#endif
#if USE_MULTI_WIFI
return wifiMultiSetup(wifiSSID, wifiPassword);
#endif
}

Sensor

I use 2 door sensors with 1 microcontroller. I assemble the physical system so one circuit is "closed" when the garage door is "closed. The other circuit is "closed" when the garage door is "open". Having 2 sensors allows the server to detect when the door is "open", "closed", or something unexpected.

Sensor A and Sensor B: The microcontroller reads the signals and sends the status to the server as SENSOR_A and SENSOR_B. The client does not make a distinction about the meaning of the sensors. The meaning of each sensor is defined by the server.

Debounce: Raw sensor data is noisy. When the sensor value changes, it often flickers before settling on the new value. I wrote a debouncer that makes sure the signal is stable for DEBOUNCE_MILLIS (50 milliseconds). Based on my experiments, 50 milliseconds removed almost all errors and still felt fast. debounceUpdate() reads the current input value and checks to see if it has changed. When there is a change, the time is reset to the current time. If the value has not changed for more than the required duration, the state is set to the value. If the state changes, the function returns true. If the state has not changed, the function returns false.

bool Debouncer::debounceUpdate(int pin, unsigned long currentTime) {
bool changed = false;
int newRead = digitalRead(pin);
if (newRead != lastRead[pin]) {
debounceTime[pin] = currentTime;
}
if (currentTime - debounceTime[pin] > debounceDuration) {
if (state[pin] != newRead) {
Serial->print(currentTime);
Serial->print(": ");
Serial->print("Debounced pin: ");
Serial->print(pin);
Serial->print(", value: ");
Serial->println(newRead);
changed = true;
}
state[pin] = newRead;
}
lastRead[pin] = newRead;
return changed;
}

When the state changes, the client sends the updated server information to the server.

bool updateServerSensorData(ClientParams params) {
digitalWrite(LED_BUILTIN, HIGH); // Blink a little while contacting the server.
String url = serverApi.buildUrl(params);
Serial.print("Request URL: ");
Serial.println(url);
const uint16_t port = WIFI_PORT;
char buf[4000];
wget(url, port, buf);

If the client has not reported the value to the server for HEARTBEAT_INTERVAL (10 minutes), then the client sends a heartbeat update to the server with both sensor values.

if (currentTime - lastNetworkRequestTime > HEARTBEAT_INTERVAL || lastNetworkRequestTime == 0) {
Serial.print("Heartbeat - Battery voltage: ");
Serial.println(batteryVoltage);
digitalWrite(LED_BUILTIN, HIGH);
ClientParams params;
params.session = session;
params.batteryVoltage = String(batteryVoltage);
if (debouncedA != DEBOUNCER_INVALID) {
params.sensorA = debouncedAString;
}
if (debouncedB != DEBOUNCER_INVALID) {
params.sensorB = debouncedBString;
}
networkSuccess = updateServerSensorData(params);
if (!networkSuccess) {
fail("Server update failed", 60);
}
lastNetworkRequestTime = currentTime;
digitalWrite(LED_BUILTIN, LOW);
Serial.println();
}

Reset Pin - Reboot After Error: If there is any error (usually caused by a glitch in the WiFi connection), the client will reboot itself. This is implemented with a physical wire that drives a reset pin LOW to trigger a device reset. To avoid accidentally turning off the system during client boot, we set the pin HIGH before doing any other setup functions.

void setup() {
// https://www.instructables.com/two-ways-to-reset-arduino-in-software/
// Write PIN high immediately in order to avoid resetting the device.
digitalWrite(RST_PIN, HIGH);
delay(200);
pinMode(RST_PIN, OUTPUT);
// https://www.instructables.com/two-ways-to-reset-arduino-in-software/
void resetDevice() {
pinMode(RST_PIN, OUTPUT);
digitalWrite(RST_PIN, LOW);
}

Sensor Reliability: The sensors have been running for 3 years without code changes. The sensors are physically installed on the ceiling, and I haven't needed to use the ladder since installation. The reset pin has eventually been able to recover from all issues!

Button

I use 1 relay with 1 microcontroller to activate a garage remote button. The primary job of this device is to listen to the "push button" command from the server, and then "push the button."

Physical Configuration: I bought a regular garage remote that works with my garage. I dismantled the case to find the electrical button. Using an ohmmeter, I figured out which pins are connected when the button is pressed. I soldered wires to the two contact points and connected them to the relay. Whenever the button needs to be pressed, it flips the relay for PUSH_REMOTE_BUTTON_DURATION_MILLIS (500 milliseconds).

unsigned long PUSH_REMOTE_BUTTON_DURATION_MILLIS = 500;
void pushRemoteButton(unsigned long durationMillis) {
digitalWrite(REMOTE_BUTTON_PIN, HIGH);
delay(durationMillis);
digitalWrite(REMOTE_BUTTON_PIN, LOW);
}

Update Frequency: The client polls the server every 5 seconds. This means the garage remote has a latency of 0-5 seconds + network latency, and the client polls the server about 17K times per day: (86400 seconds / day) / (5 seconds / poll) = 17280 polls. Higher frequency increases the costs. Lower frequency makes the button feel unresponsive.

Cost Update: The system has been running 24/7 for 3 years (April 2021 to May 2024 as of this update). The monthly Firebase server cost is approximately $1.50 USD. This cost is low enough for a personal project (1 garage) that I haven't spent time on further optimizations.

Button Ack Token Protocol: One of the most important requirements of pushing the garage door button is that the button must not be pressed more than once in response to a server command. The client and server need a protocol to ensure that the client only pushes the button at most once. The client and server solve this problem with a "Button Ack Token" (button acknowledgment token).

  • First request: Client sends the first request with any buttonAckToken.
  • Server remembers: Server reads the buttonAckToken to determine if the client needs to push the button.
    • If the server wants the client to push the button, respond with a new token.
    • If the server does not want the client to push the button, respond with the same token.
  • Client remembers: Client reads the token and remembers the buttonAckToken for the next request.
    • If the token is different, push the button.
    • If the token is the same, do not push the button.
  • Client ack: Client pings the server every 500 milliseconds with the most recent buttonAckToken (sending the recent value is the "acknowledgment" of the latest button press).

params.buttonAckToken = buttonAckToken;
pingServer(params);
if (newButtonAckToken.length() > 0 && newButtonAckToken != buttonAckToken) {
Serial.println("PUSHING BUTTON");
pushRemoteButton(PUSH_REMOTE_BUTTON_DURATION_MILLIS);
}

Button Ack Token During Network Disruptions: The buttonAckToken protocol is resilient to some network disruptions and client reboots.

  • Client request unable to reach server: The client will repeat the request without pushing the button.
  • Server response unable to reach client: The client will repeat the request without pushing the button. The server sees multiple outdated requests from the client, which means the client is failing to acknowledge the button. The server must keep responding with the same buttonAckToken.
    • Important: If the server sends a new token every time the client sends an outdated token, there is a risk of the client interpreting these as multiple button press requests. When the server wants the client to push the button, the server should pick a new token and then the server should consistently send the same token until it is acknowledged.

Android

  • Platform: Android

Door State: The Android app displays the current status of the door, as interpreted by the server.

enum class DoorState {
UNKNOWN,
CLOSED,
OPENING,
OPENING_TOO_LONG,
OPEN,
OPEN_MISALIGNED,
CLOSING,
CLOSING_TOO_LONG,
ERROR_SENSOR_CONFLICT
}

Pushing the garage button: The user can "press the garage door button" in the app. After a confirmation dialog, the app will send a command to the server. To minimize the chance of double-pressing the button, the Android app will disable the button for 30 seconds.

private fun disableButtonTemporarily() {
Log.d(TAG, "disableButtonTemporarily")
doorViewModel.showProgressBar()
doorViewModel.disableRemoteButton()
buttonRunnable?.let {
h.removeCallbacks(it)
}
buttonRunnable = object : Runnable {
override fun run() {
doorViewModel.enableRemoteButton()
doorViewModel.hideProgressBar()
}
}
buttonRunnable?.let {
val buttonDelay = 30 * 1000L // 30 seconds.
h.postDelayed(it, buttonDelay)
}
}

Authentication: Pushing the button requires Google Sign-In. The server maintains an allow-list.

doorViewModel.oneTapSignInClient = Identity.getSignInClient(this)
doorViewModel.oneTapSignInRequest = BeginSignInRequest.builder()
.setGoogleIdTokenRequestOptions(
BeginSignInRequest.GoogleIdTokenRequestOptions.builder()
.setSupported(true)
.setServerClientId(getString(R.string.web_client_id))
.setFilterByAuthorizedAccounts(false)
.build())
.setAutoSelectEnabled(true)
.build()
signIn(clicked = false)

Server

  • Platform: Firebase Functions

All critical logic is handled by the server. To minimize client updates, the clients have very little business logic encoded in them.

Non-server responsibilities:

  • Sensors: Simply report sensor values to the server when the value change
  • Button: Simply push the button based on a server command
  • Android: View the current door state based on the server, and send a command to push the button based on user request.

As long as the primitive requirements are supported by the clients, the server can add new features.

  • Server
    • Stores all requests sent by clients.
      exports.echo = echo;
    • Interprets sensor data and converts signal input to door events.
      exports.updateEvents = functions.firestore
      .document('updateAll/{docId}')
      .onWrite(async (change, context) => {
      const data = change.after.data();
      const scheduledJob = false;
      await updateEvent(data, scheduledJob);
      return null;
      });
    • Responds to client requests for the current event.
      exports.currentEventData = currentEventData;
    • Implements the Button Ack Token Protocol to push the garage remote button.
      exports.remoteButton = remoteButton;
    • Listens to Android app requests to push the button.
      exports.addRemoteButtonCommand = addRemoteButtonCommand;
    • Checks for garage door errors every minute (example: door halfway closed).
      exports.checkForDoorErrors = functions.pubsub.schedule('every 1 minutes').onRun(async (context) => {
      const BUILD_TIMESTAMP_PARAM_KEY = "buildTimestamp";
      const buildTimestampString = 'Sat Mar 13 14:45:00 2021'; // TODO: Use config.
      const scheduledJob = true;
      const data = {};
      data[BUILD_TIMESTAMP_PARAM_KEY] = buildTimestampString;
      await updateEvent(data, scheduledJob);
      return null;
      });
    • Checks for open garage door every 5 minutes, and sends a mobile notification if the door is open more than 15 minutes.
      exports.checkForOpenDoorsJob = functions.pubsub.schedule('every 5 minutes').onRun(async (context) => {
      const buildTimestamp = 'Sat Mar 13 14:45:00 2021'; // TODO: Use config.
      const eventData = await EVENT_DATABASE.getCurrent(buildTimestamp);
      await sendFCMForOldData(buildTimestamp, eventData);
      return null;
      });
      exports.checkForOpenDoors = functions.https.onRequest(async (request, response) => {
      const buildTimestamp = 'Sat Mar 13 14:45:00 2021'; // TODO: Use config.
      const eventData = await EVENT_DATABASE.getCurrent(buildTimestamp);
      const result = await sendFCMForOldData(buildTimestamp, eventData);
      response.status(200).send(result);
      });
    • Implements a data retention policy to delete old data.
      exports.dataRetentionPolicy = dataRetentionPolicy;

Limitations

  • Hard-coded WiFi: If my WiFi password changes, I will need to reprogram the Arduino boards.
  • Polling: Polling is more expensive and has high latency.
    • I could improve latency with a server-only change. The idea is to wait during the polling period to see if a button press should happen, and respond immediately to the client. This requires testing to make sure the client doesn't timeout, and to implement this without skyrocketing CPU costs.
  • Button press race condition: If the device crashes in the 500 ms after successfully pressing the button but before sending the buttonAckToken to the server, the client might push the button more than once.

License

This project is licensed under the Apache 2.0 License - see the LICENSE file for details.


Acknowledgments

Below are some of the main resources that helped me create this project. Adafruit has amazing products and educational materials.

Feather

Arduino IDE

AirLift

ESP32

Reset Arduino in Software

About

DIY smart garage door (Arduino sensors, Arduino button pusher, Firebase server, Android app)

License:Apache License 2.0


Languages

Language:TypeScript 36.0%Language:Kotlin 27.6%Language:C++ 17.9%Language:JavaScript 7.7%Language:HTML 6.4%Language:C 4.4%