From eef5e618e54a4f167216b150ab758507bfb3422d Mon Sep 17 00:00:00 2001 From: Jeremy Mayeres <1524722+jerr0328@users.noreply.github.com> Date: Sun, 8 Dec 2019 17:14:03 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=89=20Initial=20commit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .flake8 | 9 +++ .gitignore | 131 ++++++++++++++++++++++++++++++++++++++++ .isort.cfg | 6 ++ .pre-commit-config.yaml | 16 +++++ 90-co2mini.rules | 5 ++ README.md | 12 ++++ co2_prometheus.service | 10 +++ co2_prometheus/main.py | 82 +++++++++++++++++++++++++ requirements.txt | 1 + 9 files changed, 272 insertions(+) create mode 100644 .flake8 create mode 100644 .gitignore create mode 100644 .isort.cfg create mode 100644 .pre-commit-config.yaml create mode 100644 90-co2mini.rules create mode 100644 README.md create mode 100644 co2_prometheus.service create mode 100644 co2_prometheus/main.py create mode 100644 requirements.txt diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..498b2cb --- /dev/null +++ b/.flake8 @@ -0,0 +1,9 @@ +[flake8] +ignore = E203, E266, E501, W503 +max-line-length = 80 +max-complexity = 18 +select = B,C,E,F,W,T4,B9 +# We need to configure the mypy.ini because the flake8-mypy's default +# options don't properly override it, so if we don't specify it we get +# half of the config from mypy.ini and half from flake8-mypy. +mypy_config = mypy.ini diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a639cda --- /dev/null +++ b/.gitignore @@ -0,0 +1,131 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +data diff --git a/.isort.cfg b/.isort.cfg new file mode 100644 index 0000000..ba2778d --- /dev/null +++ b/.isort.cfg @@ -0,0 +1,6 @@ +[settings] +multi_line_output=3 +include_trailing_comma=True +force_grid_wrap=0 +use_parentheses=True +line_length=88 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..5c5212f --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,16 @@ +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v2.4.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files +- repo: https://github.com/psf/black + rev: 19.10b0 + hooks: + - id: black +- repo: https://github.com/pre-commit/mirrors-isort + rev: v4.3.21 + hooks: + - id: isort diff --git a/90-co2mini.rules b/90-co2mini.rules new file mode 100644 index 0000000..848912b --- /dev/null +++ b/90-co2mini.rules @@ -0,0 +1,5 @@ +ACTION=="remove", GOTO="co2mini_end" + +SUBSYSTEMS=="usb", KERNEL=="hidraw*", ATTRS{idVendor}=="04d9", ATTRS{idProduct}=="a052", GROUP="plugdev", MODE="0660", SYMLINK+="co2mini%n", GOTO="co2mini_end" + +LABEL="co2mini_end" diff --git a/README.md b/README.md new file mode 100644 index 0000000..6dc2288 --- /dev/null +++ b/README.md @@ -0,0 +1,12 @@ +# CO2 monitoring with Prometheus + +This reads from the CO2 Meter and makes it available as a Prometheus service. +The core logic comes from [this hackaday article](https://hackaday.io/project/5301-reverse-engineering-a-low-cost-usb-co-monitor/log/17909-all-your-base-are-belong-to-us). + +## Setup + +1. Install Python 3 +2. Install python3-prometheus-client +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 `co2_prometheus.service` to `/lib/systemd/system/co2_prometheus.service` +5. Run `systemctl enable co2_prometheus.service` diff --git a/co2_prometheus.service b/co2_prometheus.service new file mode 100644 index 0000000..8991507 --- /dev/null +++ b/co2_prometheus.service @@ -0,0 +1,10 @@ +[Unit] +Description=CO2 Monitoring via Prometheus +After=multi-user.target + +[Service] +Type=idle +ExecStart=/usr/bin/python3 /home/pi/co2_prometheus/main.py /dev/co2mini0 + +[Install] +WantedBy=multi-user.target diff --git a/co2_prometheus/main.py b/co2_prometheus/main.py new file mode 100644 index 0000000..95c5e36 --- /dev/null +++ b/co2_prometheus/main.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 + +import fcntl +import logging +import os +import sys + +from prometheus_client import Counter, Gauge, start_http_server + +co2_gauge = Gauge("co2", "CO2 levels in PPM") +temp_gauge = Gauge("temperature", "Temperature in C") +checksum_error_counter = Counter("checksum_errors", "Number of checksum errors seen") + +logger = logging.getLogger(__name__) +PROMETHEUS_PORT = os.getenv("CO2_PROMETHEUS_PORT", 9999) + +# From http://co2meters.com/Documentation/AppNotes/AN146-RAD-0401-serial-communication.pdf +OP_CO2 = 0x50 +OP_TEMP = 0x42 + + +def decrypt(key, data): + # Magic from https://hackaday.io/project/5301-reverse-engineering-a-low-cost-usb-co-monitor/log/17909-all-your-base-are-belong-to-us + cstate = [0x48, 0x74, 0x65, 0x6D, 0x70, 0x39, 0x39, 0x65] + shuffle = [2, 4, 0, 7, 1, 6, 5, 3] + + phase1 = [0] * 8 + for i, o in enumerate(shuffle): + phase1[o] = data[i] + + phase2 = [0] * 8 + for i in range(8): + phase2[i] = phase1[i] ^ key[i] + + phase3 = [0] * 8 + for i in range(8): + phase3[i] = ((phase2[i] >> 3) | (phase2[(i - 1 + 8) % 8] << 5)) & 0xFF + + ctmp = [0] * 8 + for i in range(8): + ctmp[i] = ((cstate[i] >> 4) | (cstate[i] << 4)) & 0xFF + + out = [0] * 8 + for i in range(8): + out[i] = (0x100 + phase3[i] - ctmp[i]) & 0xFF + + return out + + +def hd(d): + return " ".join("%02X" % e for e in d) + + +if __name__ == "__main__": + # Key retrieved from /dev/random, guaranteed to be random ;) + key = [0xC4, 0xC6, 0xC0, 0x92, 0x40, 0x23, 0xDC, 0x96] + + fp = open(sys.argv[1], "a+b", 0) + + HIDIOCSFEATURE_9 = 0xC0094806 + set_report = [0] + key + fcntl.ioctl(fp, HIDIOCSFEATURE_9, bytearray(set_report)) + + values = {} + + # Expose metrics + start_http_server(PROMETHEUS_PORT) + + while True: + data = list(fp.read(8)) + decrypted = decrypt(key, data) + if decrypted[4] != 0x0D or (sum(decrypted[:3]) & 0xFF) != decrypted[3]: + logger.warning("Checksum error: %s => %s", hd(data), hd(decrypted)) + checksum_error_counter.inc() + else: + op = decrypted[0] + val = decrypted[1] << 8 | decrypted[2] + + if op == OP_CO2: + co2_gauge.set(val) + if op == OP_TEMP: + temp_gauge.set(val / 16.0 - 273.15) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a0753df --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +prometheus_client