diff --git a/.gitignore b/.gitignore
index f3d22d00db451ec5118fd8f52ef9482cb8976d87..8a515e36db912a9aa732e087adce88365a4cf433 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,9 @@
 
 # KiCad
 /schematic/*-backups/
+
+# Dependencies
+/src/lib/
+
+# User-specific configuration
+/src/config.json
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000000000000000000000000000000000000..6491b5829468afcb5e129b44acfb746ee2375273
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,9 @@
+{
+  "python.analysis.diagnosticSeverityOverrides": {
+    "reportMissingModuleSource": "none"
+  },
+  "python.formatting.provider": "black",
+  "python.analysis.extraPaths": [
+    "src/lib"
+  ]
+}
diff --git a/deploy.sh b/deploy.sh
new file mode 100755
index 0000000000000000000000000000000000000000..873fc3a5a3c0b6609b5c35b1f61e7107509b725b
--- /dev/null
+++ b/deploy.sh
@@ -0,0 +1,14 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+pico_name="${PICO_BOARD:-pyboard}"
+script_directory="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+
+if ! command -v rshell &> /dev/null; then
+  echo 'rshell is not installed on this computer. Install it by following the instructions in the rshell repository at: https://github.com/dhylands/rshell'
+  exit 1
+fi
+
+rshell rsync --mirror "$script_directory/src/" "/$pico_name"
+rshell repl '~ import machine~ machine.soft_reset() ~'
diff --git a/install.sh b/install.sh
new file mode 100755
index 0000000000000000000000000000000000000000..81ff47450670713744bc2c9f4235c79eec978f1d
--- /dev/null
+++ b/install.sh
@@ -0,0 +1,21 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+script_directory="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+lib_directory="$script_directory/src/lib"
+
+mkdir -p "$lib_directory"
+
+# microdot 1.2.0
+echo "Downloading microdot..."
+curl --silent --output-dir "$lib_directory" \
+  --remote-name https://raw.githubusercontent.com/miguelgrinberg/microdot/v1.2.0/src/microdot.py \
+  --remote-name https://raw.githubusercontent.com/miguelgrinberg/microdot/v1.2.0/src/microdot_asyncio.py #\
+  # --remote-name https://raw.githubusercontent.com/miguelgrinberg/microdot/v1.2.0/src/microdot_asyncio_websocket.py \
+  # --remote-name https://raw.githubusercontent.com/miguelgrinberg/microdot/v1.2.0/src/microdot_websocket.py
+
+# mqtt_as 0.7.0
+echo "Downloading mqtt_as..."
+curl --silent --output-dir "$lib_directory" \
+  --remote-name https://raw.githubusercontent.com/peterhinch/micropython-mqtt/000b67778bf0a9308ac99c7ce2b54c8a1c4ce23c/mqtt_as/mqtt_as.py
diff --git a/src/debounce.py b/src/debounce.py
new file mode 100644
index 0000000000000000000000000000000000000000..f505b0ca50914f16f533c7e788b17f1675c59b3b
--- /dev/null
+++ b/src/debounce.py
@@ -0,0 +1,72 @@
+from machine import Pin, Timer
+from micropython import const, schedule
+from uasyncio import ThreadSafeFlag
+
+
+class PinDebouncer:
+    DEFAULT_DEBOUNCE_PERIOD_MS: int = const(10)
+
+    def __init__(
+        self,
+        initial_value: int,
+        debounce_period_ms: int = DEFAULT_DEBOUNCE_PERIOD_MS,
+        callback=None,
+    ) -> None:
+        self.value = initial_value
+        self._value_change_flag = ThreadSafeFlag()
+        self._value_change_callback = callback
+        self._debounce_timer = Timer()
+        self._debounce_period_ms = debounce_period_ms
+
+        # The pin's value at the time of the last edge trigger, only to be accessed during an IRQ
+        self._irq_pin_value = initial_value
+
+        # Eagerly bind instance methods so memory is not allocated during an IRQ
+        self._debounce_timer_isr = self._handle_debounce_timer_irq
+        self._set_value = self.set_value
+
+    def handle_edge_trigger_irq(self, pin: Pin) -> None:
+        """
+        Handles a change in the switch pin's state, triggered by either a rising or falling edge.
+        This function runs inside an IRQ. The switch is debounced using a timer mainly to filter
+        noise from the reed switch.
+        """
+        self._irq_pin_value = pin.value()
+
+        # If there is a pending timer due to an earlier edge trigger, setting the timer here cancels
+        # the previous timer and schedules a new one
+        self._debounce_timer.init(
+            mode=Timer.ONE_SHOT,
+            period=self._debounce_period_ms,
+            callback=self._debounce_timer_isr,
+        )
+
+    def _handle_debounce_timer_irq(self, timer: Timer) -> None:
+        """
+        Handles the debounce timer when enough time has elapsed since the last edge trigger IRQ.
+        This function itself runs inside the timer's IRQ.
+        """
+        schedule(self._set_value, self._irq_pin_value)
+
+    def set_value(self, value: int) -> None:
+        """
+        Sets the switch state to the given pin's current value. This function runs outside of an
+        IRQ.
+        """
+        if value == self.value:
+            return
+
+        self.value = value
+        self._value_change_flag.set()
+
+        if self._value_change_callback is not None:
+            self._value_change_callback(value)
+
+    async def wait_for_toggle(self) -> int:
+        """
+        A coroutine that waits for the pin to change value and returns the new, debounced value of
+        the pin. Only one task at a time may await this coroutine.
+        """
+        self._value_change_flag.clear()
+        await self._value_change_flag.wait()
+        return self.value
diff --git a/src/html/hello.html b/src/html/hello.html
new file mode 100644
index 0000000000000000000000000000000000000000..ba8ecd4264e2ae78fa948b8a4ce1578d3e4748cc
--- /dev/null
+++ b/src/html/hello.html
@@ -0,0 +1,9 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <title>Pico W Garage Door Sensor</title>
+  </head>
+  <body>
+    <p>Hello world</p>
+  </body>
+</html>
diff --git a/src/main.py b/src/main.py
new file mode 100644
index 0000000000000000000000000000000000000000..9ce23700f3486b3f4c56e208b9582b3ab9f541e3
--- /dev/null
+++ b/src/main.py
@@ -0,0 +1,85 @@
+import gc
+import json
+from machine import reset, unique_id
+from micropython import alloc_emergency_exception_buf, const
+import rp2
+from sys import print_exception
+import uasyncio as asyncio
+
+from mqtt_as import MQTTClient
+
+from mqtt import (
+    mqtt_client_config,
+    mqtt_json_message,
+    mqtt_string_message,
+    publish_homeassistant_discovery_message,
+)
+from sensor import enable_sensor
+
+DOOR_SWITCH_GPIO: int = const(22)
+
+
+async def main() -> None:
+    gc.collect()
+
+    # Allocate a small amount of memory to capture stack traces within interrupt handlers
+    alloc_emergency_exception_buf(100)
+
+    # Read in the global configuration from the filesystem
+    config = load_config()
+
+    # Use the Wi-Fi channels of the configured country or default to a worldwide subset
+    wifi_config = config.get("wifi", {})
+    country = wifi_config.get("country")
+    if country is not None:
+        rp2.country(country)
+
+    # The MQTT client also handles connecting to the configured Wi-Fi network
+    mqtt = MQTTClient(mqtt_client_config(config))
+    try:
+        await mqtt.connect()
+    except OSError as e:
+        print("Failed to connect to the MQTT broker")
+        print_exception(e)
+        reset()
+
+    # Announce this device to Home Assistant
+    # https://www.home-assistant.io/integrations/mqtt/#mqtt-discovery
+    mqtt_device_id = unique_id().hex()
+    mqtt_state_topic = f"door/{mqtt_device_id}/state"
+    await publish_homeassistant_discovery_message(
+        mqtt, mqtt_device_id, mqtt_state_topic
+    )
+
+    # Start listening to the garage door sensor
+    loop = asyncio.get_event_loop()
+
+    def publish_sensor_value(value: int) -> None:
+        print(
+            f"The door is open (sensor value = {value})"
+            if value is 0
+            else f"The door is closed (sensor value = {value})"
+        )
+        loop.run_until_complete(
+            mqtt.publish(
+                mqtt_state_topic,
+                mqtt_string_message("ON" if value is 0 else "OFF"),
+                retain=True,
+                qos=1,
+            )
+        )
+
+    sensor_pin = enable_sensor(
+        config.get("sensor_pin", DOOR_SWITCH_GPIO), publish_sensor_value
+    )
+    publish_sensor_value(sensor_pin.value())
+
+    gc.collect()
+
+
+def load_config() -> dict:
+    with open("config.json") as config_file:
+        return json.load(config_file)
+
+
+asyncio.run(main())
diff --git a/src/mqtt.py b/src/mqtt.py
new file mode 100644
index 0000000000000000000000000000000000000000..df831d205d806229241b09f100fadf6f690ada4e
--- /dev/null
+++ b/src/mqtt.py
@@ -0,0 +1,49 @@
+import json
+
+from mqtt_as import MQTTClient, config as mqtt_default_client_config
+
+
+def mqtt_client_config(config: dict) -> dict:
+    mqtt_config = config.get("mqtt", {})
+    wifi_config = config.get("wifi", {})
+    return dict(
+        mqtt_default_client_config,
+        ssid=wifi_config.get("ssid"),
+        wifi_pw=wifi_config.get("password"),
+        server=mqtt_config.get("hostname"),
+        user=mqtt_config.get("username"),
+        password=mqtt_config.get("password"),
+    )
+
+
+async def publish_homeassistant_discovery_message(
+    mqtt: MQTTClient, device_id: str, state_topic: str
+) -> None:
+    await mqtt.publish(
+        f"homeassistant/binary_sensor/{device_id}/config",
+        mqtt_json_message(
+            {
+                "unique_id": device_id,
+                "name": "Garage Door",
+                "object_id": "garage_door",
+                "device": {
+                    "name": "Door Sensor",
+                    "identifiers": [device_id],
+                    "model": "Raspberry Pi Pico W Door Sensor",
+                    "manufacturer": "Raspberry Pi",
+                    "suggested_area": "garage",
+                },
+                "device_class": "garage_door",
+                "state_topic": state_topic,
+            }
+        ),
+        retain=True,
+    )
+
+
+def mqtt_string_message(string: str) -> bytes:
+    return bytes(string, "utf-8")
+
+
+def mqtt_json_message(object: dict) -> bytes:
+    return bytes(json.dumps(object), "utf-8")
diff --git a/src/sensor.py b/src/sensor.py
new file mode 100644
index 0000000000000000000000000000000000000000..249f829bf8dc67fc05a162a06c7203ff367c40eb
--- /dev/null
+++ b/src/sensor.py
@@ -0,0 +1,38 @@
+from machine import Pin
+from micropython import const
+
+from debounce import PinDebouncer
+
+
+"""
+Any sub-second debounce period feels responsive enough for detecting a garage door so we choose a
+reliable debounce period even though the datasheet says the operate and release times are 3.0 ms
+"""
+DEBOUNCE_PERIOD_MS: int = const(30)
+
+
+def enable_sensor(sensor_pin_id: int, callback=None) -> Pin:
+    # The GPIO pin must be pulled up externally
+    switch = Pin(sensor_pin_id, Pin.IN, pull=None)
+    _toggle_led(switch.value())
+
+    def handle_switch_value_change(value: int) -> None:
+        _toggle_led(value)
+        if callback:
+            callback(value)
+
+    switch_debouncer = PinDebouncer(
+        switch.value(),
+        debounce_period_ms=DEBOUNCE_PERIOD_MS,
+        callback=handle_switch_value_change,
+    )
+    switch.irq(
+        switch_debouncer.handle_edge_trigger_irq,
+        trigger=Pin.IRQ_FALLING | Pin.IRQ_RISING,
+    )
+
+    return switch
+
+
+def _toggle_led(value: int) -> None:
+    Pin("LED", Pin.OUT, value=value)
diff --git a/src/web.py b/src/web.py
new file mode 100644
index 0000000000000000000000000000000000000000..78499af666136bc87c3a59c38e90e398df206091
--- /dev/null
+++ b/src/web.py
@@ -0,0 +1,21 @@
+from time import localtime
+
+from microdot import send_file
+from microdot_asyncio import Microdot
+
+
+def web_server() -> Microdot:
+    app = Microdot()
+
+    @app.route("/")
+    async def index(request):
+        now = localtime()
+        return f"Hello World! It is now {now[3]}:{now[4]}:{now[5]}"
+
+    @app.route("/<path:path>")
+    async def html(request, path):
+        if ".." in path:
+            return "Not found", 404
+        return send_file("html/" + path)
+
+    return app