Skip to content

✨ Support extra DHT sensor #8

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 6 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .github/workflows/build.yaml
Original file line number Diff line number Diff line change
@@ -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/*
2 changes: 1 addition & 1 deletion .github/workflows/pre-commit.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
name: pre-commit

on:
"on":
pull_request:
push:
branches: [main]
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
name: Upload Python Package

on:
"on":
release:
types: [created]

Expand Down
6 changes: 3 additions & 3 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
---
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
- id: check-yaml
- 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
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
8 changes: 8 additions & 0 deletions co2mini/config.py
Original file line number Diff line number Diff line change
@@ -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")
64 changes: 64 additions & 0 deletions co2mini/dht.py
Original file line number Diff line number Diff line change
@@ -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
66 changes: 59 additions & 7 deletions co2mini/homekit.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
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)
CO2_ALERT_CLEAR_THRESHOLD = 1100
# Seconds between updates to homekit
UPDATE_INTERVAL_SECONDS = 60

loop = asyncio.new_event_loop()


class CO2Sensor(Accessory):
"""CO2 HomeKit Sensor"""
Expand Down Expand Up @@ -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()
34 changes: 25 additions & 9 deletions co2mini/main.py
Original file line number Diff line number Diff line change
@@ -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():
Expand All @@ -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__":
Expand Down
39 changes: 36 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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]
1 change: 1 addition & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@ flake8
isort
pre-commit
setuptools
setuptools-scm
twine
wheel
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
adafruit-circuitpython-dht
HAP-Python
prometheus_client
Loading