From 44e7beba8396be97f28ab5dcab07bd37b0c5d1a1 Mon Sep 17 00:00:00 2001 From: Jeremy Mayeres Date: Sat, 7 Jan 2023 16:13:28 +0200 Subject: [PATCH 1/6] =?UTF-8?q?=E2=9C=A8=20Support=20extra=20DHT=20sensor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .pre-commit-config.yaml | 6 ++-- README.md | 13 ++++++++- co2mini/config.py | 8 ++++++ co2mini/dht.py | 64 +++++++++++++++++++++++++++++++++++++++++ co2mini/homekit.py | 37 +++++++++++++++++++++++- co2mini/main.py | 34 ++++++++++++++++------ pyproject.toml | 39 ++++++++++++++++++++++--- requirements-dev.txt | 1 + requirements.txt | 1 + setup.cfg | 25 ---------------- 10 files changed, 185 insertions(+), 43 deletions(-) create mode 100644 co2mini/config.py create mode 100644 co2mini/dht.py delete mode 100644 setup.cfg diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a2bc9aa..570cade 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,7 +1,7 @@ --- repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.2.0 + rev: v4.4.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer @@ -9,10 +9,10 @@ repos: - id: check-added-large-files - id: requirements-txt-fixer - repo: https://github.com/psf/black - rev: 22.3.0 + rev: 22.12.0 hooks: - id: black - repo: https://github.com/PyCQA/isort - rev: 5.10.1 + rev: 5.11.4 hooks: - id: isort diff --git a/README.md b/README.md index 6897591..b8e501d 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,19 @@ The core logic comes from [this hackaday article](https://hackaday.io/project/53 Note this assumes you are running on a Raspberry Pi running Raspberry Pi OS (Buster) -1. Install Python 3 +1. Install Python 3 (`sudo apt-get install python3`). 2. Install the monitor with `python3 -m pip install co2mini[homekit]` (remove `[homekit]` if you don't use HomeKit) 3. Set up CO2 udev rules by copying `90-co2mini.rules` to `/etc/udev/rules.d/90-co2mini.rules` 4. Set up the service by copying `co2mini.service` to `/etc/systemd/system/co2mini.service` 5. Run `systemctl enable co2mini.service` + +## DHT Sensor support + +If you have an additional DHT11/DHT22 sensor on your device, the monitor can also support reporting from that sensor. +The only additional system dependency is for libgpiod2 (`sudo apt-get install libgpiod2`), and has been tested on a Raspberry Pi 4 with a DHT22 sensor. +You can then set the environment variables (e.g. in the `co2mini.service` file): + +- `CO2_DHT_DEVICE`: either `DHT11` or `DHT22` +- `CO2_DHT_PIN`: The corresponding pin, see the [CircuitPython documentation](https://learn.adafruit.com/arduino-to-circuitpython/the-board-module) for more information on what to set this to. Example for GPIO4 (pin 7) on a Raspberry Pi, you should set this to `D4`. + +If the variables are not set, then the DHT sensor is not used. Note that not every refresh of the sensor works, information would be available in the logs to further debug. diff --git a/co2mini/config.py b/co2mini/config.py new file mode 100644 index 0000000..4f83635 --- /dev/null +++ b/co2mini/config.py @@ -0,0 +1,8 @@ +import os + +PROMETHEUS_PORT = int(os.getenv("CO2_PROMETHEUS_PORT", 9999)) +PROMETHEUS_NAMESPACE = os.getenv("CO2_PROMETHEUS_NAMESPACE", "") + +# DHT Device setup (DHT11, DHT22, or None for no extra temperature/humidity sensor) +DHT_DEVICE = os.getenv("CO2_DHT_DEVICE") +DHT_PIN = os.getenv("CO2_DHT_PIN") diff --git a/co2mini/dht.py b/co2mini/dht.py new file mode 100644 index 0000000..31caec6 --- /dev/null +++ b/co2mini/dht.py @@ -0,0 +1,64 @@ +import logging +import threading +import time +from typing import Optional + +from . import config + +logger = logging.getLogger(__name__) + +if config.DHT_DEVICE: + import adafruit_dht + import board + + DHT = getattr(adafruit_dht, config.DHT_DEVICE, None) + PIN = getattr(board, config.DHT_PIN, None) +else: + DHT = None + PIN = None + + +class DHTSensor(threading.Thread): + running = True + + def __init__(self, callback=None): + super().__init__(daemon=True) + self._callback = callback + self.last_results = {} + if DHT is not None and PIN is not None: + self.DHT = DHT(PIN) + else: + self.DHT = None + self.running = False + + def run(self): + while self.running: + results = self.get_data() + self.last_results.update(results) + if self._callback is not None: + self._callback(results) + time.sleep(2) + + def get_temperature(self) -> Optional[float]: + try: + return self.DHT.temperature + except RuntimeError: + logger.exception("Failed to fetch temperature data from DHT") + return None + + def get_humidity(self) -> Optional[float]: + try: + return self.DHT.humidity + except RuntimeError: + logger.exception("Failed to fetch humidity data from DHT") + return None + + def get_data(self) -> dict: + result = {} + temperature = self.get_temperature() + if temperature is not None: + result["temperature"] = temperature + humidity = self.get_humidity() + if humidity is not None: + result["humidity"] = humidity + return result diff --git a/co2mini/homekit.py b/co2mini/homekit.py index 39077ae..f738a61 100644 --- a/co2mini/homekit.py +++ b/co2mini/homekit.py @@ -1,9 +1,12 @@ import signal +from typing import Optional from pyhap.accessory import Accessory from pyhap.accessory_driver import AccessoryDriver from pyhap.const import CATEGORY_SENSOR +from . import dht + # PPM at which to trigger alert CO2_ALERT_THRESHOLD = 1200 # PPM at which to clear alert (set lower to avoid flapping alerts) @@ -45,13 +48,45 @@ async def stop(self): self.co2meter.running = False -def start_homekit(co2meter): +class DHTSensor(Accessory): + """DHT HomeKit Sensor""" + + category = CATEGORY_SENSOR + + def __init__(self, dht_sensor, *args, **kwargs): + super().__init__(*args, **kwargs) + self.dht_sensor = dht_sensor + + serv_temp = self.add_preload_service("TemperatureSensor") + serv_hum = self.add_preload_service("HumiditySensor") + self.char_temp = serv_temp.configure_char("CurrentTemperature") + self.char_hum = serv_hum.configure_char("CurrentRelativeHumidity") + + @Accessory.run_at_interval(UPDATE_INTERVAL_SECONDS) + async def run(self): + values = self.dht_sensor.get_data() + if "temperature" in values: + self.char_temp.set_value(values["temperature"]) + if "humidity" in values: + self.char_hum.set_value(values["humidity"]) + + async def stop(self): + self.dht_sensor.running = False + + +def start_homekit(co2meter, dht_sensor: Optional[dht.DHT] = None): # Start the accessory on port 51826 driver = AccessoryDriver(port=51826) driver.add_accessory( accessory=CO2Sensor(co2meter=co2meter, driver=driver, display_name="CO2 Sensor") ) + if dht_sensor is not None: + driver.add_accessory( + accessory=DHTSensor( + dht_sensor=dht_sensor, driver=driver, display_name="DHT Sensor" + ) + ) # We want SIGTERM (terminate) to be handled by the driver itself, # so that it can gracefully stop the accessory, server and advertising. diff --git a/co2mini/main.py b/co2mini/main.py index f46d914..c102f6c 100644 --- a/co2mini/main.py +++ b/co2mini/main.py @@ -1,26 +1,34 @@ #!/usr/bin/env python3 import logging -import os import sys from prometheus_client import Gauge, start_http_server -from . import meter +from . import config, dht, meter -co2_gauge = Gauge("co2", "CO2 levels in PPM") -temp_gauge = Gauge("temperature", "Temperature in C") +co2_gauge = Gauge("co2", "CO2 levels in PPM", ["sensor"]) +temp_gauge = Gauge("temperature", "Temperature in C", ["sensor"]) +humidity_gauge = Gauge("humidity", "Humidity in RH%", ["sensor"]) logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) -PROMETHEUS_PORT = os.getenv("CO2_PROMETHEUS_PORT", 9999) def co2_callback(sensor, value): if sensor == meter.CO2METER_CO2: - co2_gauge.set(value) + co2_gauge.labels("co2mini").set(value) elif sensor == meter.CO2METER_TEMP: - temp_gauge.set(value) + temp_gauge.labels("co2mini").set(value) + elif sensor == meter.CO2METER_HUM: + humidity_gauge.labels("co2mini").set(value) + + +def dht_callback(results): + if "temperature" in results: + temp_gauge.labels("dht").set(results["temperature"]) + if "humidity" in results: + humidity_gauge.labels("dht").set(results["humidity"]) def main(): @@ -29,21 +37,29 @@ def main(): logger.info("Starting with device %s", device) # Expose metrics - start_http_server(PROMETHEUS_PORT) + start_http_server(config.PROMETHEUS_PORT) co2meter = meter.CO2Meter(device=device, callback=co2_callback) co2meter.start() + dht_sensor = None + + if config.DHT_DEVICE is not None and config.DHT_PIN is not None: + dht_sensor = dht.DHT(callback=dht_callback) + dht_sensor.start() + try: from .homekit import start_homekit logging.info("Starting homekit") - start_homekit(co2meter) + start_homekit(co2meter, dht_sensor) except ImportError: pass # Ensure thread doesn't just end without cleanup co2meter.join() + if dht_sensor is not None: + dht_sensor.join() if __name__ == "__main__": diff --git a/pyproject.toml b/pyproject.toml index e7c4074..611ca43 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,37 @@ -[tool.isort] -profile = "black" - [build-system] -requires = ["setuptools>=45", "wheel"] +requires = ["setuptools>=45", "wheel", "setuptools-scm"] build-backend = "setuptools.build_meta" + +[project] +name = "co2mini" +description = "Monitor CO2 levels with Prometheus and/or HomeKit" +readme = "README.md" +authors = [ + {email = "jeremy@jerr.dev"}, + {name = "Jeremy Mayeres"} +] +requires-python = ">=3.9" +keywords = ["co2", "co2mini", "temperature", "humidity", "sensors", "prometheus", "homekit", "dht", "dht11", "dht22"] +license = {file = "LICENSE"} +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Development Status :: 3 - Alpha", +] +dependencies = ["prometheus_client"] +dynamic = ["version"] + +[project.urls] +repository = "https://github.com/jerr0328/co2-mini" + +[project.optional-dependencies] +homekit = ["HAP-python"] +dht = ["adafruit-circuitpython-dht"] +all = ["adafruit-circuitpython-dht", "HAP-python"] + +[project.scripts] +co2mini = "co2mini.main:main" + +[tool.isort] +profile = "black" diff --git a/requirements-dev.txt b/requirements-dev.txt index 6b90ee6..2eeabe1 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -5,5 +5,6 @@ flake8 isort pre-commit setuptools +setuptools-scm twine wheel diff --git a/requirements.txt b/requirements.txt index dd30d89..bd87c2e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ +adafruit-circuitpython-dht HAP-Python prometheus_client diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 2d08121..0000000 --- a/setup.cfg +++ /dev/null @@ -1,25 +0,0 @@ -[metadata] -name = co2mini -version = 0.3.0 -author = Jeremy Mayeres -author_email = jeremy@jerr.dev -description = Monitor CO2 levels with Prometheus and/or HomeKit -url = https://github.com/jerr0328/co2-mini -long_description = file: README.md -long_description_content_type = text/markdown -classifiers = - Programming Language :: Python :: 3 - License :: OSI Approved :: MIT License - Operating System :: OS Independent - Development Status :: 3 - Alpha - -[options] -packages = find: -install_requires = prometheus_client -python_requires = >=3.7 - -[options.entry_points] -console_scripts = co2mini = co2mini.main:main - -[options.extras_require] -homekit = HAP-python From c76729d1453dac6dcf912a96170305ebbc46c843 Mon Sep 17 00:00:00 2001 From: Jeremy Mayeres Date: Sat, 7 Jan 2023 16:22:15 +0200 Subject: [PATCH 2/6] Update workflows for pre-release builds --- .github/workflows/build.yaml | 27 +++++++++++++++++++++++++++ .github/workflows/pre-commit.yaml | 2 +- .github/workflows/release.yaml | 2 +- pyproject.toml | 2 ++ 4 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/build.yaml diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000..596c5ee --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,27 @@ +--- +name: Build and push pre-release version + +"on": + push: + branches: [main] + +jobs: + build-and-push: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.9" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements-dev.txt + - name: Build and publish pre-release + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} + run: | + python -m build + twine upload dist/* diff --git a/.github/workflows/pre-commit.yaml b/.github/workflows/pre-commit.yaml index 859f033..95950d6 100644 --- a/.github/workflows/pre-commit.yaml +++ b/.github/workflows/pre-commit.yaml @@ -1,7 +1,7 @@ --- name: pre-commit -on: +"on": pull_request: push: branches: [main] diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 0f3b137..6f37d75 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -1,7 +1,7 @@ --- name: Upload Python Package -on: +"on": release: types: [created] diff --git a/pyproject.toml b/pyproject.toml index 611ca43..ce6c71b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,3 +35,5 @@ co2mini = "co2mini.main:main" [tool.isort] profile = "black" + +[tool.setuptools_scm] From 021d8e83951233094fa66802186cd2b580fe17ec Mon Sep 17 00:00:00 2001 From: Jeremy Mayeres Date: Sat, 7 Jan 2023 16:34:10 +0200 Subject: [PATCH 3/6] Fix wrong class used --- co2mini/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/co2mini/main.py b/co2mini/main.py index c102f6c..0fe24ad 100644 --- a/co2mini/main.py +++ b/co2mini/main.py @@ -45,7 +45,7 @@ def main(): dht_sensor = None if config.DHT_DEVICE is not None and config.DHT_PIN is not None: - dht_sensor = dht.DHT(callback=dht_callback) + dht_sensor = dht.DHTSensor(callback=dht_callback) dht_sensor.start() try: From 00ed38ec606828adb9cf093dea44cba9edc65987 Mon Sep 17 00:00:00 2001 From: Jeremy Mayeres Date: Sat, 7 Jan 2023 17:00:03 +0200 Subject: [PATCH 4/6] Run two homekit accessories --- co2mini/homekit.py | 36 ++++++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/co2mini/homekit.py b/co2mini/homekit.py index f738a61..f74d016 100644 --- a/co2mini/homekit.py +++ b/co2mini/homekit.py @@ -1,3 +1,4 @@ +import asyncio import signal from typing import Optional @@ -74,23 +75,30 @@ async def stop(self): self.dht_sensor.running = False -def start_homekit(co2meter, dht_sensor: Optional[dht.DHT] = None): - # Start the accessory on port 51826 - driver = AccessoryDriver(port=51826) - +async def start_co2(co2meter, loop): + driver = AccessoryDriver(port=51826, persist_file="co2accessory.state", loop=loop) driver.add_accessory( accessory=CO2Sensor(co2meter=co2meter, driver=driver, display_name="CO2 Sensor") ) - if dht_sensor is not None: - driver.add_accessory( - accessory=DHTSensor( - dht_sensor=dht_sensor, driver=driver, display_name="DHT Sensor" - ) - ) + signal.signal(signal.SIGTERM, driver.signal_handler) + + await driver.async_start() + - # We want SIGTERM (terminate) to be handled by the driver itself, - # so that it can gracefully stop the accessory, server and advertising. +async def start_dht(dht_sensor, loop): + driver = AccessoryDriver(port=51827, persist_file="dhtaccessory.state", loop=loop) + driver.add_accessory( + accessory=DHTSensor( + dht_sensor=dht_sensor, driver=driver, display_name="DHT Sensor" + ) + ) signal.signal(signal.SIGTERM, driver.signal_handler) + await driver.async_start() - # Start it! - driver.start() + +def start_homekit(co2meter, dht_sensor: Optional[dht.DHT] = None): + loop = asyncio.new_event_loop() + loop.create_task(start_co2(co2meter=co2meter, loop=loop)) + if dht_sensor is not None: + loop.create_task(start_dht(dht_sensor=dht_sensor, loop=loop)) + loop.run_forever() From aeec14ecf6408f325cc1282040da1f9b35a38746 Mon Sep 17 00:00:00 2001 From: Jeremy Mayeres Date: Sat, 7 Jan 2023 17:10:53 +0200 Subject: [PATCH 5/6] Reduce frequency of data updates for DHT --- co2mini/dht.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/co2mini/dht.py b/co2mini/dht.py index 31caec6..698cbee 100644 --- a/co2mini/dht.py +++ b/co2mini/dht.py @@ -37,7 +37,7 @@ def run(self): self.last_results.update(results) if self._callback is not None: self._callback(results) - time.sleep(2) + time.sleep(5) def get_temperature(self) -> Optional[float]: try: From 6f7c5cec3c45ec9ecf260f421fb01db0a0066b63 Mon Sep 17 00:00:00 2001 From: Jeremy Mayeres Date: Sat, 14 Jan 2023 21:04:58 +0200 Subject: [PATCH 6/6] Fix loop not handling signal, better linux support --- co2mini/dht.py | 6 +++--- co2mini/homekit.py | 13 +++++++++++-- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/co2mini/dht.py b/co2mini/dht.py index 698cbee..ffdc101 100644 --- a/co2mini/dht.py +++ b/co2mini/dht.py @@ -26,7 +26,7 @@ def __init__(self, callback=None): self._callback = callback self.last_results = {} if DHT is not None and PIN is not None: - self.DHT = DHT(PIN) + self.DHT = DHT(PIN, use_pulseio=False) else: self.DHT = None self.running = False @@ -42,14 +42,14 @@ def run(self): def get_temperature(self) -> Optional[float]: try: return self.DHT.temperature - except RuntimeError: + except (RuntimeError, OSError): logger.exception("Failed to fetch temperature data from DHT") return None def get_humidity(self) -> Optional[float]: try: return self.DHT.humidity - except RuntimeError: + except (RuntimeError, OSError): logger.exception("Failed to fetch humidity data from DHT") return None diff --git a/co2mini/homekit.py b/co2mini/homekit.py index f74d016..d8ba897 100644 --- a/co2mini/homekit.py +++ b/co2mini/homekit.py @@ -15,6 +15,8 @@ # Seconds between updates to homekit UPDATE_INTERVAL_SECONDS = 60 +loop = asyncio.new_event_loop() + class CO2Sensor(Accessory): """CO2 HomeKit Sensor""" @@ -96,9 +98,16 @@ async def start_dht(dht_sensor, loop): await driver.async_start() +def stop_homekit(): + loop.stop() + + def start_homekit(co2meter, dht_sensor: Optional[dht.DHT] = None): - loop = asyncio.new_event_loop() loop.create_task(start_co2(co2meter=co2meter, loop=loop)) if dht_sensor is not None: loop.create_task(start_dht(dht_sensor=dht_sensor, loop=loop)) - loop.run_forever() + loop.add_signal_handler(signal.SIGTERM, stop_homekit) + try: + loop.run_forever() + finally: + loop.close()