From 0df11c52be1ce804ee624e0a4969905685866e57 Mon Sep 17 00:00:00 2001 From: Jeremy Mayeres Date: Fri, 2 Jan 2026 17:37:00 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=A7=20Migrate=20to=20uv,=20ruff,=20Pyt?= =?UTF-8?q?hon=203.10=20minimum?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use ruff for formatter and linter (replaces black, isort, flake8, etc.) - Use uv instead of requirements.txt files - Fix issues from formatter - Use prek instead of pre-commit - New minimum Python version of 3.10 (3.9 is EOL) - Refresh gitignore --- .flake8 | 9 --- .github/dependabot.yml | 2 +- .github/workflows/pre-commit.yaml | 5 +- .github/workflows/release.yaml | 9 ++- .gitignore | 105 +++++++++++++++++++++++++++--- .pre-commit-config.yaml | 26 ++++---- README.md | 7 +- co2mini/main.py | 26 +------- co2mini/meter.py | 54 +++++++-------- co2mini/mqtt.py | 6 +- pyproject.toml | 41 +++++++++++- requirements-dev.txt | 9 --- requirements.txt | 4 -- 13 files changed, 196 insertions(+), 107 deletions(-) delete mode 100644 .flake8 delete mode 100644 requirements-dev.txt delete mode 100644 requirements.txt diff --git a/.flake8 b/.flake8 deleted file mode 100644 index 498b2cb..0000000 --- a/.flake8 +++ /dev/null @@ -1,9 +0,0 @@ -[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/.github/dependabot.yml b/.github/dependabot.yml index d03e9b2..5350997 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,7 +5,7 @@ updates: directory: "/" schedule: interval: "monthly" - - package-ecosystem: "pip" + - package-ecosystem: "uv" directory: "/" schedule: interval: "monthly" diff --git a/.github/workflows/pre-commit.yaml b/.github/workflows/pre-commit.yaml index 60dc92a..0af6eb7 100644 --- a/.github/workflows/pre-commit.yaml +++ b/.github/workflows/pre-commit.yaml @@ -11,7 +11,4 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - - uses: actions/setup-python@v6 - with: - python-version: "3.9" - - uses: pre-commit/action@v3.0.1 + - uses: j178/prek-action@v1 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 87ee301..d1e3f30 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -17,11 +17,14 @@ jobs: - name: Set up Python uses: actions/setup-python@v6 with: - python-version: "3.9" + python-version-file: "pyproject.toml" + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + activate-environment: "true" - name: Install dependencies run: | - python -m pip install --upgrade pip - pip install -r requirements-dev.txt + uv sync --all-extras --dev - name: Build run: python -m build - name: Publish package distributions to PyPI diff --git a/.gitignore b/.gitignore index a639cda..0a764ef 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ # Byte-compiled / optimized / DLL files __pycache__/ -*.py[cod] +*.py[codz] *$py.class # C extensions @@ -20,7 +20,6 @@ parts/ sdist/ var/ wheels/ -pip-wheel-metadata/ share/python-wheels/ *.egg-info/ .installed.cfg @@ -28,8 +27,8 @@ share/python-wheels/ 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. +# 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 @@ -47,9 +46,10 @@ htmlcov/ nosetests.xml coverage.xml *.cover -*.py,cover +*.py.cover .hypothesis/ .pytest_cache/ +cover/ # Translations *.mo @@ -72,6 +72,7 @@ instance/ docs/_build/ # PyBuilder +.pybuilder/ target/ # Jupyter Notebook @@ -82,27 +83,73 @@ profile_default/ ipython_config.py # pyenv -.python-version +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .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 +# Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +# poetry.lock +# poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +# pdm.lock +# pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +# pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid +# Redis +*.rdb +*.aof +*.pid + +# RabbitMQ +mnesia/ +rabbitmq/ +rabbitmq-data/ + +# ActiveMQ +activemq-data/ + # SageMath parsed files *.sage.py # Environments .env +.envrc .venv env/ venv/ @@ -128,4 +175,42 @@ dmypy.json # Pyre type checker .pyre/ -data +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +# .idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ + +# Streamlit +.streamlit/secrets.toml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 09d0511..a6f5cc5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,18 +1,20 @@ --- repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.5.0 + rev: v6.0.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-pre-commit-mirror - rev: 23.11.0 - hooks: - - id: black - - repo: https://github.com/PyCQA/isort - rev: 5.12.0 + - id: check-merge-conflict + - id: check-toml + - id: check-yaml + - id: end-of-file-fixer + - id: trailing-whitespace + - repo: https://github.com/astral-sh/ruff-pre-commit + # Ruff version. + rev: v0.14.10 hooks: - - id: isort + # Run the linter. + - id: ruff-check + args: [ --fix ] + # Run the formatter. + - id: ruff-format diff --git a/README.md b/README.md index 96461f9..29b51b6 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ The core logic comes from [this hackaday article](https://hackaday.io/project/53 ## Setup -Note this assumes you are running on a Raspberry Pi running Raspberry Pi OS (Bullseye) +Note this assumes you are running on a Raspberry Pi running Raspberry Pi OS (Bullseye/Trixie) 1. Install Python 3 2. Install the monitor with `python3 -m pip install co2mini[homekit]` (remove `[homekit]` if you don't use HomeKit) @@ -59,3 +59,8 @@ If this happens, it seems like the easiest thing to do is to remove the device f - Be sure to install `Python3 pip` as well (ID `130`) - Make sure the dietpi user is in `plugdev` group (`sudo usermod -aG plugdev dietpi`) + +## Development + +This project assumes you will use [uv](https://docs.astral.sh/uv/) to configure the virtual environment and manage dependencies. +Formatting and linting is handled with [ruff](https://docs.astral.sh/ruff/). Pre-commit is using [prek](https://prek.j178.dev), though pre-commit would also work (be sure to install it separately). diff --git a/co2mini/main.py b/co2mini/main.py index 0924e89..1451cff 100644 --- a/co2mini/main.py +++ b/co2mini/main.py @@ -6,29 +6,7 @@ from prometheus_client import Gauge, start_http_server -from . import config, meter - -try: - from . import mqtt -except ImportError: - - class mqtt: - @staticmethod - def send_co2_value(*args, **kwargs): - pass - - @staticmethod - def send_temp_value(*args, **kwargs): - pass - - @staticmethod - def get_mqtt_client(): - pass - - @staticmethod - def start_client(*args, **kwargs): - pass - +from . import config, meter, mqtt co2_gauge = Gauge("co2", "CO2 levels in PPM") temp_gauge = Gauge("temperature", "Temperature in C") @@ -79,7 +57,7 @@ def main(): try: from .homekit import start_homekit - logging.info("Starting homekit") + logger.info("Starting homekit") start_homekit(co2meter) except ImportError: pass diff --git a/co2mini/meter.py b/co2mini/meter.py index f08c3de..25ac2ef 100644 --- a/co2mini/meter.py +++ b/co2mini/meter.py @@ -2,18 +2,26 @@ Module for reading out CO2Meter USB devices Code adapted from Michael Heinemann under MIT License: https://github.com/heinemml/CO2Meter """ + import fcntl import logging import threading +from pathlib import Path CO2METER_CO2 = 0x50 CO2METER_TEMP = 0x42 CO2METER_HUM = 0x41 HIDIOCSFEATURE_9 = 0xC0094806 +KEY = [0xC4, 0xC6, 0xC0, 0x92, 0x40, 0x23, 0xDC, 0x96] + logger = logging.getLogger(__name__) +class ThreadNotRunningError(OSError): + """Exception raised when the reading thread is not running""" + + def _convert_value(sensor, value): """Apply Conversion of value dending on sensor type""" if sensor == CO2METER_TEMP: @@ -26,7 +34,7 @@ def _convert_value(sensor, value): def _hd(data): """Helper function for printing the raw data""" - return " ".join("%02X" % e for e in data) + return " ".join(f"{e:02X}" for e in data) def _is_valid_msg(data): @@ -34,25 +42,20 @@ def _is_valid_msg(data): class CO2Meter(threading.Thread): - _key = [0xC4, 0xC6, 0xC0, 0x92, 0x40, 0x23, 0xDC, 0x96] - _device = "" - _values = {} - _file = "" - running = True - _callback = None - def __init__(self, device="/dev/co2mini0", callback=None): super().__init__(daemon=True) - self._device = device + self._device = Path(device) self._callback = callback - self._file = open(device, "a+b", 0) - - set_report = [0] + self._key - fcntl.ioctl(self._file, HIDIOCSFEATURE_9, bytearray(set_report)) + self._values = {} + self.running = True def run(self): - while self.running: - self._read_data() + with self._device.open("a+b", 0) as f: + self._file = f + set_report = [0, *KEY] + fcntl.ioctl(self._file, HIDIOCSFEATURE_9, bytearray(set_report)) + while self.running: + self._read_data() def _read_data(self): """ @@ -62,19 +65,16 @@ def _read_data(self): """ try: data = list(self._file.read(8)) - if _is_valid_msg(data): - decrypted = data - else: - decrypted = self._decrypt(data) + decrypted = data if _is_valid_msg(data) else self._decrypt(data) if _is_valid_msg(decrypted): operation = decrypted[0] val = decrypted[1] << 8 | decrypted[2] self._values[operation] = _convert_value(operation, val) - if self._callback is not None: - if operation in {CO2METER_CO2, CO2METER_TEMP} or ( - operation == CO2METER_HUM and val != 0 - ): - self._callback(sensor=operation, value=self._values[operation]) + if self._callback is not None and ( + operation in {CO2METER_CO2, CO2METER_TEMP} + or (operation == CO2METER_HUM and val != 0) + ): + self._callback(sensor=operation, value=self._values[operation]) else: logger.error("Checksum error: %s => %s", _hd(data), _hd(decrypted)) @@ -117,7 +117,7 @@ def get_co2(self): :returns dict with value or empty """ if not self.running: - raise IOError("worker thread couldn't read data") + raise ThreadNotRunningError() result = {} if CO2METER_CO2 in self._values: result = {"co2": self._values[CO2METER_CO2]} @@ -130,7 +130,7 @@ def get_temperature(self): :returns dict with value or empty """ if not self.running: - raise IOError("worker thread couldn't read data") + raise ThreadNotRunningError() result = {} if CO2METER_TEMP in self._values: result = {"temperature": self._values[CO2METER_TEMP]} @@ -145,7 +145,7 @@ def get_humidity(self): # not implemented by all devices :returns dict with value or empty """ if not self.running: - raise IOError("worker thread couldn't read data") + raise ThreadNotRunningError() result = {} if CO2METER_HUM in self._values and self._values[CO2METER_HUM] != 0: result = {"humidity": self._values[CO2METER_HUM]} diff --git a/co2mini/mqtt.py b/co2mini/mqtt.py index 5046a2d..e6f9eb0 100644 --- a/co2mini/mqtt.py +++ b/co2mini/mqtt.py @@ -1,7 +1,11 @@ import json import logging -import paho.mqtt.client as mqtt +try: + import paho.mqtt.client as mqtt +except ImportError: + mqtt = None + from . import config diff --git a/pyproject.toml b/pyproject.toml index afdbe06..296e5c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,10 +4,10 @@ build-backend = "setuptools.build_meta" [project] name = "co2mini" -description = "Monitor CO2 levels with Prometheus and/or HomeKit" +description = "Monitor CO2 levels with Prometheus, MQTT, and/or HomeKit" readme = "README.md" authors = [{ email = "jeremy@jerr.dev" }, { name = "Jeremy Mayeres" }] -requires-python = ">=3.9" +requires-python = ">=3.10" keywords = [ "co2", "co2mini", @@ -38,7 +38,44 @@ all = ["co2mini[homekit,mqtt]"] [project.scripts] co2mini = "co2mini.main:main" +[tool.ruff.lint] +extend-select = [ + "A", # flake8-builtins + "B", # flake8-bugbear + "C", # mccabe complexity + "C4", # flake8-comprehensions + "DTZ", # flake8-datetimez + "E", # pycodestyle errors + "F", # pyflakes + "FLY", # flake8-flynt + "FURB", # refurb + "I", # isort + "LOG", # flake8-logging + "N", # pep8 naming + "PERF", # perflint + "PGH", # pygrep hooks + "PIE", # flake8-pie + "PTH", # flake8-pathlib + "RET", # flake8-return + "RUF", # ruff + "S", # bandit security + "SIM", # flake8-simplify + "T20", # Print + "TRY", # tryceratops + "UP", # pyupgrade + "W", # pycodestyle warnings +] + [tool.isort] profile = "black" [tool.setuptools_scm] + +[dependency-groups] +dev = [ + "build>=1.3.0", + "ruff>=0.14.10", + "setuptools>=80.9.0", + "setuptools-scm>=9.2.2", + "wheel>=0.45.1", +] diff --git a/requirements-dev.txt b/requirements-dev.txt deleted file mode 100644 index ce84570..0000000 --- a/requirements-dev.txt +++ /dev/null @@ -1,9 +0,0 @@ --c requirements.txt -black -build -flake8 -isort -pre-commit -setuptools -setuptools-scm -wheel diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 2318db2..0000000 --- a/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -environs -hap-python -paho-mqtt -prometheus_client