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