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/.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..ffdc101 --- /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, use_pulseio=False) + 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(5) + + def get_temperature(self) -> Optional[float]: + try: + return self.DHT.temperature + 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, OSError): + 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..d8ba897 100644 --- a/co2mini/homekit.py +++ b/co2mini/homekit.py @@ -1,9 +1,13 @@ +import asyncio 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) @@ -11,6 +15,8 @@ # Seconds between updates to homekit UPDATE_INTERVAL_SECONDS = 60 +loop = asyncio.new_event_loop() + class CO2Sensor(Accessory): """CO2 HomeKit Sensor""" @@ -45,17 +51,63 @@ async def stop(self): self.co2meter.running = False -def start_homekit(co2meter): - # Start the accessory on port 51826 - driver = AccessoryDriver(port=51826) +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 + +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") ) + 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() + + +def stop_homekit(): + loop.stop() + - # Start it! - driver.start() +def start_homekit(co2meter, dht_sensor: Optional[dht.DHT] = None): + 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.add_signal_handler(signal.SIGTERM, stop_homekit) + try: + loop.run_forever() + finally: + loop.close() diff --git a/co2mini/main.py b/co2mini/main.py index f46d914..0fe24ad 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.DHTSensor(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..ce6c71b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,39 @@ +[build-system] +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" -[build-system] -requires = ["setuptools>=45", "wheel"] -build-backend = "setuptools.build_meta" +[tool.setuptools_scm] 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