diff --git a/.envrc b/.envrc new file mode 100644 index 0000000000000000000000000000000000000000..fa370586604656b1b4b386d4af41bf5f1067412e --- /dev/null +++ b/.envrc @@ -0,0 +1,5 @@ +# shellcheck shell=bash + +strict_env + +PATH_add scripts diff --git a/.gitignore b/.gitignore index 8a515e36db912a9aa732e087adce88365a4cf433..03910c478c78239a6a357f2af0567dc56349f73f 100644 --- a/.gitignore +++ b/.gitignore @@ -4,8 +4,5 @@ # KiCad /schematic/*-backups/ -# Dependencies -/src/lib/ - # User-specific configuration -/src/config.json +/src/settings.toml diff --git a/.vscode/settings.json b/.vscode/settings.json index 6491b5829468afcb5e129b44acfb746ee2375273..000811b4d0f9f2ff7dbaa56f142c5f4686c1de05 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,9 +1,8 @@ { "python.analysis.diagnosticSeverityOverrides": { - "reportMissingModuleSource": "none" + "reportMissingImports": "none", + "reportMissingModuleSource": "none", + "reportShadowedImports": "none" }, - "python.formatting.provider": "black", - "python.analysis.extraPaths": [ - "src/lib" - ] + "python.formatting.provider": "black" } diff --git a/LICENSE b/LICENSE index c18da5b3d9505d241b1dd6152b23a0a92307f58c..6b987ead96f93bee793c2e54a28f66aaac61ffc5 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2022 James Ide +Copyright (c) 2023 James Ide Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index c08e7e6a2d5cb1cc2acc5f814f18adb818a5de1a..252467d568f47d8bbfcb002e26f5531d110ef3a2 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Pico W Garage Door Sensor -Get notified when your garage door's been left open. This sensor uses the Raspberry Pi Pico W and MicroPython. +Get notified when your garage door's been left open. This sensor uses the Raspberry Pi Pico W and CircuitPython and requires Home Assistant with MQTT set up. ## Hardware @@ -28,7 +28,18 @@ You could add a small capacitor to debounce the reed switch in hardware but this | | Stranded copper wire, 22-24 AWG (long enough to reach your garage door from the Pico and back) | | ## Software -Install MicroPython on your Pico W using [a .uf2 file](https://micropython.org/download/rp2-pico-w/) from the MicroPython website. +Install CircuitPython 8 on your Pico W using [a .uf2 file](https://circuitpython.org/board/raspberry_pi_pico_w/) from the CircuitPython website. +Create settings.toml under src or directly in your CIRCUITPY drive and define the following environment variables: +```toml +CIRCUITPY_WIFI_SSID = "your Wi-Fi access point's name" +CIRCUITPY_WIFI_PASSWORD = "your Wi-Fi password" +MQTT_HOSTNAME = "your MQTT broker's hostname" +MQTT_USERNAME = "the username to use with your MQTT broker" +MQTT_PASSWORD = "the password to use with your MQTT broker" +WIFI_HOSTNAME = "garagesensor" +``` + +There are convenient scripts under the `scripts` directory for deploying software to your Pico W and connecting to its REPL. Only macOS is supported. diff --git a/deploy.sh b/deploy.sh deleted file mode 100755 index 873fc3a5a3c0b6609b5c35b1f61e7107509b725b..0000000000000000000000000000000000000000 --- a/deploy.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/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 deleted file mode 100755 index 81ff47450670713744bc2c9f4235c79eec978f1d..0000000000000000000000000000000000000000 --- a/install.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/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/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..3b1c8ac02782d1d3d938d7e6245906cc4dbb0bb9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +adafruit_debouncer==2.0.5 +adafruit_ticks==1.0.9 +adafruit_logging==5.2.1 +adafruit_minimqtt==7.2.2 diff --git a/scripts/deploy b/scripts/deploy new file mode 100755 index 0000000000000000000000000000000000000000..da2b9570fe2468dcc3d62842de009eedc77492bf --- /dev/null +++ b/scripts/deploy @@ -0,0 +1,51 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_directory="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +project_directory="$script_directory/.." + +install_dependencies=false +while getopts ":d" opt; do + case $opt in + d) + install_dependencies=true + ;; + *) + ;; + esac +done + +if ! command -v rsync &> /dev/null; then + echo 'rsync is not installed on this computer. rsync is used to copy the application code to your device. Install it using the package manager you use with your OS.' + exit 1 +fi + +if ! command -v circup &> /dev/null; then + echo 'circup is not installed on this computer. circup is used to install CircuitPython dependencies on your device. Install it by following the instructions in the rshell repository at: https://github.com/adafruit/circup' + exit 1 +fi + +echo 'Detecting your CircuitPython device...' +device_path=$(python3 -c 'import circup; print(circup.find_device())') +if [ "$device_path" == 'None' ]; then + echo 'circup could not detect a connected CircuitPython device. Make sure your device is flashed with CircuitPython and connected to your computer with a USB data cable.' + exit 1 +fi +echo "Found device at $device_path" + +if [ $install_dependencies = true ] || \ + [ ! -d "$device_path/lib" ] || \ + [ -z "$(ls -A "$device_path/lib")" ]; then + echo 'Installing dependencies...' + circup install --requirement "$project_directory/requirements.txt" + echo 'Finished installing dependencies' +fi + +echo 'Copying application code...' +rsync --verbose --recursive --delete --checksum \ + --include='/._*' --exclude "/.*" \ + --exclude "/boot_out.txt" --exclude "/lib/" \ + --exclude "/log.txt" \ + "$project_directory/src/" "$device_path" +echo 'Finished copying application code' diff --git a/scripts/repl b/scripts/repl new file mode 100755 index 0000000000000000000000000000000000000000..54a68faea363825fa49e50783ee46f7c0cbb1c10 --- /dev/null +++ b/scripts/repl @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +set -euo pipefail + +device=$(ls -1 -t /dev/tty.usbmodem*) + +# Reattach to an existing session if one exists; otherwise create a new one +screen -D -R -S circuitpython "$device" 115200 diff --git a/src/boot.py b/src/boot.py new file mode 100644 index 0000000000000000000000000000000000000000..ca15b5a206919979329a4004872f48cf08c3e3de --- /dev/null +++ b/src/boot.py @@ -0,0 +1,8 @@ +import os + +import storage + +if os.getenv("LOG_FILE"): + # Allow CircuitPython to write to its flash storage + # https://learn.adafruit.com/circuitpython-essentials/circuitpython-storage + storage.remount("/", readonly=False, disable_concurrent_write_protection=True) diff --git a/src/code.py b/src/code.py new file mode 100644 index 0000000000000000000000000000000000000000..7eeb815720ac730129effed3f0c32d447ca87017 --- /dev/null +++ b/src/code.py @@ -0,0 +1,90 @@ +import binascii +import os +import time +import traceback + +import board +import digitalio +import microcontroller +import socketpool +import supervisor +import wifi + +import adafruit_debouncer +import adafruit_logging +import adafruit_minimqtt.adafruit_minimqtt as adafruit_minimqtt + +from logging import create_logger +from mqtt import publish_homeassistant_discovery_message, publish_sensor_state_message + + +def main(logger: adafruit_logging.Logger) -> None: + led = digitalio.DigitalInOut(board.LED) + led.switch_to_output() + + switch_io = digitalio.DigitalInOut(board.GP22) + # GPIO 22 has a pull-up resistor so we don't pull it up in code + switch_io.switch_to_input() + switch = adafruit_debouncer.Debouncer(switch_io) + + # Connect to the Wi-Fi network + logger.info("Connecting to the local Wi-Fi network...") + wifi.radio.hostname = os.getenv("WIFI_HOSTNAME") + wifi.radio.connect( + os.getenv("CIRCUITPY_WIFI_SSID"), os.getenv("CIRCUITPY_WIFI_PASSWORD") + ) + logger.info( + "Connected to the local network: IP address = %s, router = %s, DNS server %s", + wifi.radio.ipv4_address, + wifi.radio.ipv4_gateway, + wifi.radio.ipv4_dns, + ) + pool = socketpool.SocketPool(wifi.radio) + + # Connect to the MQTT broker + logger.info("Connecting to the MQTT broker...") + mqtt = adafruit_minimqtt.MQTT( + broker=os.getenv("MQTT_HOSTNAME"), + username=os.getenv("MQTT_USERNAME"), + password=os.getenv("MQTT_PASSWORD"), + is_ssl=False, + socket_pool=pool, + ) + mqtt.logger = logger + mqtt.connect() + logger.info("Connected to the MQTT broker") + + mqtt_device_id = binascii.hexlify(microcontroller.cpu.uid).decode("utf-8") + mqtt_state_topic = f"door/{mqtt_device_id}/state" + publish_homeassistant_discovery_message(mqtt, mqtt_device_id, mqtt_state_topic) + + # The switch is open when the door is closed + led.value = not switch.value + publish_sensor_state_message(mqtt, mqtt_state_topic, not switch.value) + logger.info("Advertised Home Assistant discovery message and current door state") + + logger.info("Monitoring door sensor...") + while True: + switch.update() + mqtt.loop() + + if switch.rose or switch.fell: + is_door_open = not switch.value + logger.info( + "Detected the door open" if is_door_open else "Detected the door closed" + ) + led.value = is_door_open + publish_sensor_state_message(mqtt, mqtt_state_topic, is_door_open) + + +logger = create_logger() + +try: + main(logger) +except Exception as exception: + logger.critical("%s", "".join(traceback.format_exception(exception, limit=8))) + time.sleep(10) + if supervisor.runtime.usb_connected: + supervisor.reload() + else: + microcontroller.reset() diff --git a/src/debounce.py b/src/debounce.py deleted file mode 100644 index f505b0ca50914f16f533c7e788b17f1675c59b3b..0000000000000000000000000000000000000000 --- a/src/debounce.py +++ /dev/null @@ -1,72 +0,0 @@ -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 deleted file mode 100644 index ba8ecd4264e2ae78fa948b8a4ce1578d3e4748cc..0000000000000000000000000000000000000000 --- a/src/html/hello.html +++ /dev/null @@ -1,9 +0,0 @@ -<!DOCTYPE html> -<html> - <head> - <title>Pico W Garage Door Sensor</title> - </head> - <body> - <p>Hello world</p> - </body> -</html> diff --git a/src/logging.py b/src/logging.py new file mode 100644 index 0000000000000000000000000000000000000000..3a9c241f3b185328c64db54df356d8dc2673034d --- /dev/null +++ b/src/logging.py @@ -0,0 +1,56 @@ +import errno +import os + +import storage +import supervisor + +import adafruit_logging + + +_LOG_SIZE_THRESHOLD_BYTES = 100 * 1024**2 + + +def create_logger() -> adafruit_logging.Logger: + """Create the root logger and configure it to print all info messages and higher to stderr. + Error messages and higher are written to a log file when the file system is writable. + """ + logger = adafruit_logging.getLogger() + logger.setLevel(adafruit_logging.INFO) + + if supervisor.runtime.usb_connected: + logger.addHandler(adafruit_logging.StreamHandler()) + else: + # The logging package prints messages with its default stream handler when the message is + # too low in severity for any of the registered handlers. This null handler accepts messages + # of all severity levels and suppresses the logger from writing high numbers of debug + # messages through the default stream handler to stderr. + logger.addHandler(adafruit_logging.NullHandler()) + + log_filename = os.getenv("LOG_FILE") + if log_filename and not storage.getmount("/").readonly: + handler = _file_handler(logger, log_filename) + handler.setLevel(adafruit_logging.ERROR) + logger.addHandler(handler) + + return logger + + +def _file_handler( + logger: adafruit_logging.Logger, log_filename: str +) -> adafruit_logging.FileHandler: + # Prevent the log file from filling the flash memory + log_file_mode = "a" + try: + size = os.stat(log_filename)[6] # st_size is at index 6 + if size >= _LOG_SIZE_THRESHOLD_BYTES: + log_file_mode = "w" + logger.info("Log file is %d bytes, creating a new log file", size) + else: + logger.info("Log file is %d bytes, appending to existing log file", size) + except OSError as error: + if error.errno != errno.ENOENT: + raise error + log_file_mode = "w" + logger.info("Log file does not exist, creating a new log file") + + return adafruit_logging.FileHandler(log_filename, mode=log_file_mode) diff --git a/src/main.py b/src/main.py deleted file mode 100644 index 9ce23700f3486b3f4c56e208b9582b3ab9f541e3..0000000000000000000000000000000000000000 --- a/src/main.py +++ /dev/null @@ -1,85 +0,0 @@ -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 index df831d205d806229241b09f100fadf6f690ada4e..b2ab815ec027e7a712d3261ddf168b8987b433de 100644 --- a/src/mqtt.py +++ b/src/mqtt.py @@ -1,27 +1,11 @@ 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 +def publish_homeassistant_discovery_message( + mqtt, device_id: str, state_topic: str ) -> None: - await mqtt.publish( + mqtt.publish( f"homeassistant/binary_sensor/{device_id}/config", - mqtt_json_message( + json.dumps( { "unique_id": device_id, "name": "Garage Door", @@ -41,9 +25,10 @@ async def publish_homeassistant_discovery_message( ) -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") +def publish_sensor_state_message(mqtt, state_topic: str, sensor_state: bool) -> None: + mqtt.publish( + state_topic, + "ON" if sensor_state else "OFF", + retain=True, + qos=1, + ) diff --git a/src/sensor.py b/src/sensor.py deleted file mode 100644 index 249f829bf8dc67fc05a162a06c7203ff367c40eb..0000000000000000000000000000000000000000 --- a/src/sensor.py +++ /dev/null @@ -1,38 +0,0 @@ -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 deleted file mode 100644 index 78499af666136bc87c3a59c38e90e398df206091..0000000000000000000000000000000000000000 --- a/src/web.py +++ /dev/null @@ -1,21 +0,0 @@ -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