commit 2e58083957f5b020a83098d4a4408d04f3270b23 Author: d3vyce Date: Tue Dec 19 22:24:29 2023 +0100 Initial commit diff --git a/.github/workflows/docker-build-version.yml b/.github/workflows/docker-build-version.yml new file mode 100644 index 0000000..456e471 --- /dev/null +++ b/.github/workflows/docker-build-version.yml @@ -0,0 +1,29 @@ +name: Build and Push Docker Image + +on: + release: + types: [published] + +jobs: + build docker: + runs-on: ubuntu-latest + steps: + - name: checkout code + uses: actions/checkout@v3 + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + - name: Login to Docker registry + uses: docker/login-action@v2 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and push + uses: docker/build-push-action@v4 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ env.REGISTRY }}/${{ github.repository }}:latest,${{ env.REGISTRY }}/${{ github.repository }}:${{ github.ref_name }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..c7f2a27 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,23 @@ +name: Python Lint +on: push + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - name: Check out repository code + uses: actions/checkout@v3 + - name: Set up Python 3.11 + run: | + apt update + apt install -y python3 python3-pip + - name: Install dependencies + run: | + python3 -m pip install --upgrade pip + pip install -r requirements.txt + pip install pylint black isort + - name: Lint code + run: | + find . -type f -name "*.py" | xargs pylint + find . -type f -name "*.py" | xargs black --check + find . -type f -name "*.py" | xargs isort --check diff --git a/.github/workflows/pypi-build-version.yml b/.github/workflows/pypi-build-version.yml new file mode 100644 index 0000000..48753f8 --- /dev/null +++ b/.github/workflows/pypi-build-version.yml @@ -0,0 +1,36 @@ +name: Build Pypi Package + +on: + release: + types: [published] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install --yes python3-setuptools python3-wheel + - name: Build + run: | + python3 -m build + - uses: actions/upload-artifact@v3 + with: + path: ./dist + name: dist + pypi-publish: + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/teleinfo_exporter/ + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v3 + with: + path: ./dist + name: dist + - name: Publish package distributions to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6769e21 --- /dev/null +++ b/.gitignore @@ -0,0 +1,160 @@ +# 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/ +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/ +cover/ + +# 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 +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# 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 + +# 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 + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__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/ + +# 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/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4b58d8d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## [1.0.0] - 12-19-2023 + +Initial release \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f6fc3c0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,8 @@ +FROM python:3.12-slim + +COPY requirements.txt requirements.txt +RUN pip3 install -r requirements.txt + +COPY src/ /opt/ + +CMD [ "python3", "/opt/teleinfo_exporter" ] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c07025a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 d3vyce + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..6b017d5 --- /dev/null +++ b/README.md @@ -0,0 +1,77 @@ +# Teleinfo Exporter + +Simple prometheus exporter for Linky teleinfo. +Teleinfo Tasmota project : +https://github.com/NicolasBernaerts/tasmota/tree/master/teleinfo + +## Installation +### Pip +``` +python3 -m pip install teleinfo-exporter +teleinfo-exporter --help +``` + +### Docker +``` +docker pull teleinfo-exporter +``` + +Minimal Docker compose: +```yaml +services: + web: + image: teleinfo_exporter:latest + environment: + - BROKER_HOSTNAME=10.10.0.10 + ports: + - 8000:8000 + restart: always +``` + +#### Architectures +| Architecture | Available | Tag | +| ------------ | --------- | ----------------------- | +| x86-64 | ✅ | amd64-\ | +| arm64 | ✅ | arm64-\ | + +#### Version Tags +| Tag | Available | Description | +| ------ | --------- | ---------------------------------------------------- | +| latest | ✅ | Latest version | + +#### Variables +| Argument | Variable | Description | Default | +| ------------------- | -------------------- | ------------------ | ---------------------- | +| `--http_port` | `-e HTTP_PORT` | HTTP Port | `8000` | +| `--auth_user` | `-e AUTH_USER` | Basic Auth User | | +| `--auth_hash` | `-e AUTH_HASH` | Basic Auth Hash | | +| `--http_cert` | `-e HTTP_CERT` | HTTP Certificate | | +| `--http_key` | `-e HTTP_KEY` | HTTP Key | | +| `--broker_host` | `-e BROKER_HOST` | MQTT Host | | +| `--broker_port` | `-e BROKER_PORT` | MQTT Port | `1883` | +| `--broker_user` | `-e BROKER_USER` | MQTT User | | +| `--broker_password` | `-e BROKER_PASSWORD` | MQTT Password | | +| `--broker_topic` | `-e BROKER_TOPIC` | Teleinfo Topic | `teleinfo/tele/SENSOR` | + +## Configuration +### HTTP Authentication +To generate the password hash use the following command: +```bash +htpasswd -bnBC 10 "" PASSWORD | tr -d ':' +``` + +### Prometheus +Config example: +```yaml +scrape_configs: + - job_name: 'Teleinfo' + scheme: https + tls_config: + ca_file: teleinfo.crt + basic_auth: + username: USER + password: PASSWORD + static_configs: + - targets: + - 192.168.1.2:8000 +``` diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..4fe7d31 --- /dev/null +++ b/compose.yml @@ -0,0 +1,8 @@ +services: + web: + image: teleinfo_exporter:latest + environment: + - BROKER_HOSTNAME=10.10.0.10 + ports: + - 8000:8000 + restart: always diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a102707 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,36 @@ +[project] +name = "teleinfo-exporter" +version = "1.0.0" +dependencies = [ + "bcrypt ~= 4.1", + "configargparse ~= 1.7", + "flask ~= 3.0", + "flask-httpauth ~= 4.8", + "paho-mqtt ~= 1.6", + "prometheus-client ~= 0.19", +] +requires-python = ">=3.8" +authors = [ + { name="d3vyce", email="contact@d3vyce.fr" }, +] +description = "Simple prometheus exporter for Linky teleinfo." +readme = "README.md" +license = {file = "LICENSE"} +keywords = ["teleinfo", "linky", "prometheus", "exporter"] +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", +] + +[project.urls] +Homepage = "https://github.com/d3vyce/teleinfo-exporter" +Repository = "https://github.com/d3vyce/teleinfo-exporter.git" +Issues = "https://github.com/d3vyce/teleinfo-exporter/issues" +Changelog = "https://github.com/d3vyce/teleinfo-exporter/blob/main/CHANGELOG.md" + +[project.scripts] +teleinfo-exporter = "teleinfo_exporter.app:main" + +[tool.isort] +profile = "black" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c9a8496 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +bcrypt~=4.1 +configargparse~=1.7 +flask~=3.0 +flask-httpauth~=4.8 +paho-mqtt~=1.6 +prometheus-client~=0.19 diff --git a/src/teleinfo_exporter/__init__.py b/src/teleinfo_exporter/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/teleinfo_exporter/__main__.py b/src/teleinfo_exporter/__main__.py new file mode 100644 index 0000000..082450c --- /dev/null +++ b/src/teleinfo_exporter/__main__.py @@ -0,0 +1,8 @@ +# pylint: disable=import-error +""" +Teleinfo exporter for the Prometheus monitoring system +""" + +from app import main + +main() diff --git a/src/teleinfo_exporter/app.py b/src/teleinfo_exporter/app.py new file mode 100644 index 0000000..da79322 --- /dev/null +++ b/src/teleinfo_exporter/app.py @@ -0,0 +1,212 @@ +# pylint: disable=missing-module-docstring,missing-function-docstring + +import json +import random +import string + +import bcrypt +import configargparse +import paho.mqtt.client as mqttClient +from flask import Flask +from flask_httpauth import HTTPBasicAuth +from prometheus_client import Gauge, make_wsgi_app + +app = Flask(__name__) +app.config["SECRET_KEY"] = "".join(random.sample(string.ascii_lowercase, 16)) +auth = HTTPBasicAuth() + +# ENERGY +teleinfo_total = Gauge("teleinfo_total", "energy total") +teleinfo_yesterday = Gauge("teleinfo_yesterday", "energy yesterday") +teleinfo_today = Gauge("teleinfo_today", "energy today") +teleinfo_power = Gauge("teleinfo_power", "active power") +teleinfo_apparent_power = Gauge("teleinfo_apparent_power", "apparent power") +teleinfo_reactive_power = Gauge("teleinfo_reactive_power", "reactive power") +teleinfo_power_factor = Gauge("teleinfo_power_factor", "power factor") +teleinfo_voltage = Gauge("teleinfo_voltage", "voltage") +teleinfo_current = Gauge("teleinfo_current", "current") + +# METER +teleinfo_phases_count = Gauge("teleinfo_phases_count", "number of phases") +teleinfo_max_current_per_phase = Gauge( + "teleinfo_current_per_phase", "current per phase in the contract" +) +teleinfo_max_power_per_phase = Gauge( + "teleinfo_max_power_per_phase", "power per phase in the contract (VA)" +) +teleinfo_max_power_per_phase_with_overload = Gauge( + "teleinfo_max_power_per_phase_with_overload", + "maximum power per phase including an accetable % of overload (VA)", +) +teleinfo_instant_voltage_per_phase = Gauge( + "teleinfo_instant_voltage_per_phase", + "instant voltage on phase x", + ["phase"], +) +teleinfo_instant_apparent_power_per_phase = Gauge( + "teleinfo_instant_apparent_power_per_phase", + "instant apparent power on phase x", + ["phase"], +) +teleinfo_instant_active_power_per_phase = Gauge( + "teleinfo_instant_active_power_per_phase", + "instant active power on phase x", + ["phase"], +) +teleinfo_instant_current_per_phase = Gauge( + "teleinfo_instant_current_per_phase", + "instant current on phase x", + ["phase"], +) +teleinfo_power_factor_per_phase = Gauge( + "teleinfo_power_factor_per_phase", + "current calculated power factor (cos φ) on phase x", + ["phase"], +) +teleinfo_total_apparent_power = Gauge( + "teleinfo_total_apparent_power", "total instant apparent power (on all phases)" +) +teleinfo_total_active_power = Gauge( + "teleinfo_total_active_power", "total instant active power (on all phases)" +) +teleinfo_total_current = Gauge( + "teleinfo_total_current", "total instant current (on all phases)" +) + +# PROD +teleinfo_production_instant_apparent_power = Gauge( + "teleinfo_production_instant_apparent_power", "instant apparent power" +) +teleinfo_production_instant_active_power = Gauge( + "teleinfo_production_instant_active_power", "instant active power" +) +teleinfo_production_power_factor = Gauge( + "teleinfo_production_power_factor", "current calculated power factor (cos φ)" +) + +# TIC +teleinfo_contract_number = Gauge( + "teleinfo_contract_number", "contract number", ["number"] +) +teleinfo_contract_type = Gauge("teleinfo_contract_type", "contract type", ["type"]) + + +def on_connect(client, userdata, flags, rc): # pylint: disable=unused-argument + if rc == 0: + print("Connected to broker") + else: + print("Connection failed") + + +def on_message(client, userdata, message): # pylint: disable=unused-argument + message = json.loads(message.payload.decode("utf-8")) + if "ENERGY" in message: + teleinfo_total.set(message["ENERGY"]["Total"]) + teleinfo_yesterday.set(message["ENERGY"]["Yesterday"]) + teleinfo_today.set(message["ENERGY"]["Today"]) + teleinfo_power.set(message["ENERGY"]["Power"]) + teleinfo_apparent_power.set(message["ENERGY"]["ApparentPower"]) + teleinfo_reactive_power.set(message["ENERGY"]["ReactivePower"]) + teleinfo_power_factor.set(message["ENERGY"]["Factor"]) + teleinfo_voltage.set(message["ENERGY"]["Voltage"]) + teleinfo_current.set(message["ENERGY"]["Current"]) + elif "METER" in message: + teleinfo_phases_count.set(message["METER"]["PH"]) + teleinfo_max_current_per_phase.set(message["METER"]["ISUB"]) + teleinfo_max_power_per_phase.set(message["METER"]["PSUB"]) + teleinfo_max_power_per_phase_with_overload.set(message["METER"]["PMAX"]) + teleinfo_total_apparent_power.set(message["METER"]["P"]) + teleinfo_total_active_power.set(message["METER"]["W"]) + teleinfo_total_current.set(message["METER"]["I"]) + for i in range(1, 4): + if not message["METER"].get(f"U{i}"): + break + teleinfo_instant_voltage_per_phase.labels(f"U{i}").set( + message["METER"][f"U{i}"] + ) + teleinfo_instant_apparent_power_per_phase.labels(f"P{i}").set( + message["METER"][f"P{i}"] + ) + teleinfo_instant_active_power_per_phase.labels(f"W{i}").set( + message["METER"][f"W{i}"] + ) + teleinfo_instant_current_per_phase.labels(f"I{i}").set( + message["METER"][f"I{i}"] + ) + teleinfo_power_factor_per_phase.labels(f"C{i}").set( + message["METER"][f"C{i}"] + ) + + elif "PROD" in message: + teleinfo_production_instant_apparent_power.set(message["PROD"]["VA"]) + teleinfo_production_instant_active_power.set(message["PROD"]["W"]) + teleinfo_production_power_factor.set(message["PROD"]["COS"]) + elif "TIC" in message: + teleinfo_contract_number.labels(message["TIC"]["ADCO"]).set(0) + teleinfo_contract_type.labels(message["TIC"]["OPTARIF"]).set(0) + + +@app.before_request +@auth.login_required() +def global_auth(): + return + + +@auth.verify_password +def verify_password(username, password): + if ( + not app.config.get("USERS") + or username in app.config["USERS"] + and bcrypt.hashpw(password.encode(), app.config["USERS"].get(username)) + ): + return username + return None + + +@app.route("/metrics") +def metrics(): + return make_wsgi_app() + + +def main(): + p = configargparse.ArgParser() + p.add("--broker_host", required=True, help="MQTT Host", env_var="BROKER_HOST") + p.add("--broker_port", help="MQTT Port", env_var="BROKER_PORT", default=1883) + p.add( + "--broker_topic", + help="Teleinfo Topic", + env_var="BROKER_TOPIC", + default="teleinfo/tele/SENSOR", + ) + p.add("--broker_user", help="MQTT user", env_var="BROKER_USER") + p.add("--broker_password", help="MQTT Password", env_var="BROKER_PASSWORD") + p.add("--auth_user", help="Basic Auth user", env_var="AUTH_USER") + p.add("--auth_hash", help="Basic Auth hash", env_var="AUTH_HASH") + p.add("--http_port", help="HTTP Server Port", env_var="HTTP_PORT", default=8000) + p.add("--http_cert", help="HTTP Server Certificate", env_var="HTTP_CERT") + p.add("--http_key", help="HTTP Server Key", env_var="HTTP_KEY") + options = p.parse_args() + print(options) + + if options.auth_user and options.auth_hash: + app.config["USERS"] = {options.auth_user: options.auth_hash.encode()} + + client = mqttClient.Client( + f"teleinfo-exporter_{''.join(random.sample(string.ascii_lowercase, 8))}" + ) + + if options.broker_user and options.broker_password: + client.username_pw_set(options.broker_user, password=options.broker_password) + + client.on_connect = on_connect + client.on_message = on_message + client.connect(options.broker_host, port=options.broker_port) + client.loop_start() + + client.subscribe(options.broker_topic) + + if options.http_cert and options.http_key: + ssl_context = (options.http_cert, options.http_key) + else: + ssl_context = None + app.run(host="0.0.0.0", port=options.http_port, ssl_context=ssl_context)