diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000000000000000000000000000000000000..ee33faf47a114875e3b6fd893597df992c42052b --- /dev/null +++ b/.prettierrc @@ -0,0 +1,6 @@ +{ + "printWidth": 100, + "tabWidth": 2, + "singleQuote": true, + "bracketSameLine": true +} diff --git a/assets/icon-1024.avif b/assets/icon-1024.avif new file mode 100644 index 0000000000000000000000000000000000000000..31a20b087a5e66cf844b99736cc4ef986d7e85a9 Binary files /dev/null and b/assets/icon-1024.avif differ diff --git a/requirements.txt b/requirements.txt index 3b1c8ac02782d1d3d938d7e6245906cc4dbb0bb9..1c3ed3898ed9fb6955fd8d9309854fa5facbe551 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ adafruit_debouncer==2.0.5 -adafruit_ticks==1.0.9 adafruit_logging==5.2.1 +adafruit_ticks==1.0.9 +adafruit_httpserver==2.3.0 adafruit_minimqtt==7.2.2 diff --git a/scripts/certificates b/scripts/certificates new file mode 100755 index 0000000000000000000000000000000000000000..467785dd2d182fe75282a4b684270cccdeca3201 --- /dev/null +++ b/scripts/certificates @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_directory="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Derived from https://www.linode.com/docs/guides/create-a-self-signed-tls-certificate/ +openssl req -new -newkey rsa:1024 -x509 -sha256 -days 36525 -nodes \ + -subj '/CN=garagesensor.local' \ + -out "$script_directory/../src/certificates/certificate-chain.pem" \ + -keyout "$script_directory/../src/certificates/key.pem" diff --git a/src/boot.py b/src/boot.py index ca15b5a206919979329a4004872f48cf08c3e3de..0287c1203cacdad2af47f8e1ea5f1b21011d7bc6 100644 --- a/src/boot.py +++ b/src/boot.py @@ -1,8 +1,5 @@ -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) +# 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/certificates/certificate-chain.pem b/src/certificates/certificate-chain.pem new file mode 100644 index 0000000000000000000000000000000000000000..c8f077cc379aa11afcd110f24eccf071be8e2094 --- /dev/null +++ b/src/certificates/certificate-chain.pem @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE----- +MIIBszCCARwCCQCj+re8WZ4+aDANBgkqhkiG9w0BAQsFADAdMRswGQYDVQQDDBJn +YXJhZ2VzZW5zb3IubG9jYWwwIBcNMjMwMzA1MTk0ODIxWhgPMjEyMzAzMDYxOTQ4 +MjFaMB0xGzAZBgNVBAMMEmdhcmFnZXNlbnNvci5sb2NhbDCBnzANBgkqhkiG9w0B +AQEFAAOBjQAwgYkCgYEAxSb4Ao/A6wxE/a4w9YOkHY+mzmnO9naqE/DDasJYQO5+ +MgLnpDgOcStQojfTJHHu8V2o0TqSadYZuY75c6A1oji0kG5CcpYOyhtpKdKjvEQ1 +3WPt+vBOCU3mmL1W7pS1RiNy3f/g3xC7qPEvW6nZZonafCO5f9edupNsBfbgSDUC +AwEAATANBgkqhkiG9w0BAQsFAAOBgQAP51g4jkm2YQ3ADmNesKewHK2vSw248PS3 +SNAaxaj0n+6PEUZO/COOD5Of6yHZVDa4DAxKdGUdL/YIBkf8mceLfzNFbW8Le+t5 +OQ/YNFMXLobDf7HRkVzIfVXAwtoJqUY/DSEI0cMfpHIcNC4ThYgdreiANn2f1WDK +YDe8O9X5UA== +-----END CERTIFICATE----- diff --git a/src/certificates/key.pem b/src/certificates/key.pem new file mode 100644 index 0000000000000000000000000000000000000000..e88c97207f424f460b36f3b99cfa0ff534feefd1 --- /dev/null +++ b/src/certificates/key.pem @@ -0,0 +1,16 @@ +-----BEGIN PRIVATE KEY----- +MIICdQIBADANBgkqhkiG9w0BAQEFAASCAl8wggJbAgEAAoGBAMUm+AKPwOsMRP2u +MPWDpB2Pps5pzvZ2qhPww2rCWEDufjIC56Q4DnErUKI30yRx7vFdqNE6kmnWGbmO ++XOgNaI4tJBuQnKWDsobaSnSo7xENd1j7frwTglN5pi9Vu6UtUYjct3/4N8Qu6jx +L1up2WaJ2nwjuX/XnbqTbAX24Eg1AgMBAAECgYAiN0cnuqcyo+h9VnPsyDH9Z2b9 +v+NJZwLRfyGLL7t9WWbRayuklo37Ghdeb+3XD2b2wNiBp3ato5jHWYb1iEKGXPGg +biF4gYAasjB3HiauOeG3UNLOM27+yI04K6XQZSV3s6CrM+C1ILWGHU0S7LQxjeBA +hsMdH18jVjUP1RS6QQJBAOI8pJ4x5fCp7KbnBY6lwBMn5Q2KxAqQHqCL6brrgeqh +Bzp1fR/OnzsXcn6Cb5BMs4cMWxeV3DyfBTFCwO51wu0CQQDfFssAl84TcRrNyriq +fuZBth5z9auGmS38FdbrDqXPzgDa2MM4e4/LUYBvF7FQtneiekHXKis5fJlxoytT +fQlpAkAwSKcNiDK9+VYjjNy3xBJJRFNzX3FVm8qdkx7QIOE6VSG4zUhmGHANaYSr +EWWEE4qhQPbUAszdN0cha1DH0+RFAkAKz6f212R9PLX30yMv4AZ4mMLRC87MLxAz +bzuDGKqgb3NLJ8YOLq7BQ6nduGA3cSBLF3GpY7nEh21IPIgU+7JBAkBjyMupNQHW +HHll7Fa4YtBfEXK2sIB/wvdtroT05s2YM1F2XUwLfLyepSSniyLMVnd+gOZwr4c/ +KRbIBmqaBrh9 +-----END PRIVATE KEY----- diff --git a/src/code.py b/src/code.py index 7eeb815720ac730129effed3f0c32d447ca87017..d0e125a1eb5b236e86a7bed90059e2a442d5998b 100644 --- a/src/code.py +++ b/src/code.py @@ -1,5 +1,6 @@ import binascii import os +import ssl import time import traceback @@ -13,9 +14,15 @@ import wifi import adafruit_debouncer import adafruit_logging import adafruit_minimqtt.adafruit_minimqtt as adafruit_minimqtt +from adafruit_httpserver.methods import HTTPMethod +from adafruit_httpserver.mime_type import MIMEType +from adafruit_httpserver.server import HTTPServer +from adafruit_httpserver.request import HTTPRequest +from adafruit_httpserver.response import HTTPResponse from logging import create_logger from mqtt import publish_homeassistant_discovery_message, publish_sensor_state_message +from tls import SSLServerSocketPool def main(logger: adafruit_logging.Logger) -> None: @@ -27,6 +34,9 @@ def main(logger: adafruit_logging.Logger) -> None: switch_io.switch_to_input() switch = adafruit_debouncer.Debouncer(switch_io) + # The switch is open when the door is closed + led.value = not switch.value + # Connect to the Wi-Fi network logger.info("Connecting to the local Wi-Fi network...") wifi.radio.hostname = os.getenv("WIFI_HOSTNAME") @@ -42,31 +52,59 @@ def main(logger: adafruit_logging.Logger) -> None: 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") + if os.getenv("MQTT_ENABLED"): + 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) + 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) + publish_sensor_state_message(mqtt, mqtt_state_topic, not switch.value) + logger.info( + "Advertised Home Assistant discovery message and current door state" + ) + else: + mqtt = None - # 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") + # Listen to HTTP requests and serve static files + ssl_context = ssl.create_default_context() + # The Pico is the server and does not require a certificate from the client, so disable + # certificate validation by explicitly specifying no verification CAs + ssl_context.load_verify_locations(cadata="") + ssl_context.load_cert_chain( + "certificates/certificate-chain.pem", "certificates/key.pem" + ) + ssl_pool = SSLServerSocketPool(pool, ssl_context) + + server = HTTPServer(ssl_pool) + host = str(wifi.radio.ipv4_address) + server.start(host, port=443, root_path="public_html") + logger.info(f"Listening to HTTP requests at https://{host}") + logger.info(f"Serving the website from https://{wifi.radio.hostname}.local") logger.info("Monitoring door sensor...") while True: switch.update() - mqtt.loop() + + if mqtt is not None: + mqtt.loop() + + try: + server.poll() + except OSError as error: + if error.strerror.startswith("MBEDTLS_ERR_"): + logger.info("TLS library error %s with code %d", error.strerror, error.errno) + else: + raise if switch.rose or switch.fell: is_door_open = not switch.value @@ -74,7 +112,8 @@ def main(logger: adafruit_logging.Logger) -> None: "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) + if mqtt is not None: + publish_sensor_state_message(mqtt, mqtt_state_topic, is_door_open) logger = create_logger() diff --git a/src/public_html/icon-192.avif b/src/public_html/icon-192.avif new file mode 100644 index 0000000000000000000000000000000000000000..9bdcee6c84d1f5dcde9b393798fa046036900f46 Binary files /dev/null and b/src/public_html/icon-192.avif differ diff --git a/src/public_html/icon-512.avif b/src/public_html/icon-512.avif new file mode 100644 index 0000000000000000000000000000000000000000..f82377c1e8c5437984d9c102b45f3a5fd26322e7 Binary files /dev/null and b/src/public_html/icon-512.avif differ diff --git a/src/public_html/index.html b/src/public_html/index.html new file mode 100644 index 0000000000000000000000000000000000000000..cb5afa718881d0d3de9a23bc13111d688a24cf1c --- /dev/null +++ b/src/public_html/index.html @@ -0,0 +1,16 @@ +<!DOCTYPE html> +<html lang="en-US"> + <head> + <title>Garage Door Sensor</title> + <meta name="viewport" content="width=device-width" /> + <link rel="manifest" href="/manifest.webmanifest" /> + <link rel="icon" type="image/avif" href="/icon-192.avif" /> + <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter" /> + <link rel="stylesheet" href="/style.css" /> + <script type="module" src="/script.js"></script> + </head> + <body> + <h1>My Garage</h1> + <button id="subscribe-button">Get Notified</button> + </body> +</html> diff --git a/src/public_html/manifest.webmanifest b/src/public_html/manifest.webmanifest new file mode 100644 index 0000000000000000000000000000000000000000..02e946d81fca657b74e0859f9dc1136ce141e7ad --- /dev/null +++ b/src/public_html/manifest.webmanifest @@ -0,0 +1,21 @@ +{ + "$schema": "https://json.schemastore.org/web-manifest-combined.json", + "name": "Garage Door Sensor", + "short_name": "Garage", + "start_url": "/", + "display": "fullscreen", + "background_color": "#252526", + "description": "Check on whether your garage door is open", + "icons": [ + { + "src": "icon-192.avif", + "sizes": "192x192", + "type": "image/avif" + }, + { + "src": "icon-512.avif", + "sizes": "512x512", + "type": "image/avif" + } + ] +} diff --git a/src/public_html/script.js b/src/public_html/script.js new file mode 100644 index 0000000000000000000000000000000000000000..c9528ffec56e9541058fad1714d860dc1e253880 --- /dev/null +++ b/src/public_html/script.js @@ -0,0 +1,16 @@ +// Keep the service worker up to date +navigator.serviceWorker.register('/service-worker.js', { type: 'module' }).then((registration) => { + registration.update(); +}); + +// Subscribe to notifications when the user wishes to do so +const subscribeButton = document.getElementById('subscribe-button'); +subscribeButton.addEventListener('click', async () => { + const serviceWorkerRegistration = await navigator.serviceWorker.ready; + const pushSubscription = await serviceWorkerRegistration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: + 'BP1ZvYiUs2YDFtbJu2weWZdBS4DFA0kKw3pFK6iMVV7zue-fmg_gJM8lEshHkbJO5KkiO8wYh15Xn8y0BZNpRCs', + }); + console.log(pushSubscription.toJSON()); +}); diff --git a/src/public_html/service-worker.js b/src/public_html/service-worker.js new file mode 100644 index 0000000000000000000000000000000000000000..b1e2819cad3bd5f593af46ea2f27fc577aa2645d --- /dev/null +++ b/src/public_html/service-worker.js @@ -0,0 +1,22 @@ +// Keep the service worker up to date by letting the most recent script become the active service +// worker even if another is registered. This is acceptable since there is no API contract that +// could break between the web page and this service worker in the event a newer version of the +// service worker becomes active while the user is viewing an older version of the web page. +self.addEventListener('install', (event) => { + event.waitUntil(self.skipWaiting()); +}); + +self.addEventListener('push', (event) => { + const message = event.data.json(); + console.log(message); + event.waitUntil( + self.registration.showNotification(message.title, { + body: message.body, + icon: '/icon-192.avif', + tag: message.tag, + timestamp: message.timestamp ? message.timestamp * 1000 : undefined, + }) + ); +}); + +self.addEventListener('pushsubscriptionchange', (event) => {}); diff --git a/src/public_html/style.css b/src/public_html/style.css new file mode 100644 index 0000000000000000000000000000000000000000..c867eeda9336e865074622417f14592245726007 --- /dev/null +++ b/src/public_html/style.css @@ -0,0 +1,6 @@ +html { + background: #252526; + color: #fff; + font-family: 'Inter', sans-serif; +} + diff --git a/src/tls.py b/src/tls.py new file mode 100644 index 0000000000000000000000000000000000000000..c0298ae4dea1a73b674c81e075c7c12ee86230d4 --- /dev/null +++ b/src/tls.py @@ -0,0 +1,48 @@ +import ssl + +import socketpool + + +class SSLServerSocketPool: + def __init__(self, pool: socketpool.SocketPool, ssl_context: ssl.SSLContext): + self._pool = pool + self._ssl_context = ssl_context + + @property + def AF_INET(self) -> int: + return self._pool.AF_INET + + @property + def AF_INET6(self) -> int: + return self._pool.AF_INET6 + + @property + def SOCK_STREAM(self) -> int: + return self._pool.SOCK_STREAM + + @property + def SOCK_DGRAM(self) -> int: + return self._pool.SOCK_DGRAM + + @property + def SOCK_RAW(self) -> int: + return self._pool.SOCK_RAW + + @property + def EAI_NONAME(self) -> int: + return self._pool.EAI_NONAME + + @property + def TCP_NODELAY(self) -> int: + return self._pool.TCP_NODELAY + + @property + def IPPROTO_TCP(self) -> int: + return self._pool.IPPROTO_TCP + + def socket(self, family: int = None, type: int = None) -> ssl.SSLSocket: + socket = self._pool.socket(family, type) + return self._ssl_context.wrap_socket(socket, server_side=True) + + def getaddrinfo(self, *args, **kwargs): + return self._pool.getaddrinfo(*args, **kwargs)