├── .codacy.yml ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── pull_request_template.md └── workflows │ ├── ci.yml │ ├── docker-release.yml │ ├── helm-release.yml │ └── pypi.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── charts └── mqtt-exporter │ ├── .helmignore │ ├── Chart.yaml │ ├── templates │ ├── NOTES.txt │ ├── _helpers.tpl │ ├── deployment.yaml │ ├── ingress.yaml │ ├── service.yaml │ ├── serviceaccount.yaml │ ├── servicemonitor.yaml │ └── tests │ │ └── test-connection.yaml │ └── values.yaml ├── doc └── example │ ├── config │ ├── alertmanager │ │ └── alertmanager.yml │ ├── mosquitto │ │ ├── mosquitto.conf │ │ └── passwd.conf │ ├── prometheus │ │ ├── alert.rules │ │ └── prometheus.yml │ └── zigbee2mqtt │ │ └── configuration.yaml │ └── docker-compose.yml ├── docker-compose.dev.yml ├── docker-compose.yml ├── exporter.py ├── mqtt_exporter ├── __init__.py ├── main.py └── settings.py ├── pyproject.toml ├── requirements ├── base.in ├── base.txt ├── dev.txt ├── tests.in └── tests.txt ├── tasks.py └── tests ├── __init__.py ├── functional ├── __init__.py ├── test_expose_metrics.py ├── test_parse_message.py └── test_parse_metrics.py └── unit ├── __init__.py └── test_normalize_prometheus_name.py /.codacy.yml: -------------------------------------------------------------------------------- 1 | --- 2 | exclude_paths: 3 | - "README.md" 4 | - "CONTRIBUTING.md" 5 | - "tests/**" 6 | - ".github/**" 7 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [kpetremann] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: kpetremann 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is 12 | 13 | If the bug is related to parsing, please provide the original MQTT message. 14 | 15 | **To Reproduce** 16 | Steps to reproduce the behavior: 17 | 1. Go to '...' 18 | 2. Click on '....' 19 | 3. Scroll down to '....' 20 | 4. See error 21 | 22 | **Expected behavior** 23 | A clear and concise description of what you expected to happen. 24 | 25 | **Screenshots** 26 | If applicable, add screenshots to help explain your problem. 27 | 28 | **Additional context** 29 | Add any other context about the problem here. 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: kpetremann 7 | 8 | --- 9 | 10 | **Describe the solution you'd like** 11 | A clear and concise description of what you want to happen. 12 | 13 | If you want the support of MQTT message produced by a specific vendor, please add MQTT messages example. 14 | 15 | **Additional context** 16 | Add any other context or screenshots about the feature request here. 17 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | Fixes/Implement: # 4 | 5 | **Description:** 6 | 7 | 8 | ... 9 | 10 | **Before the commit:** 11 | 12 | 13 | ... 14 | 15 | **After the commit:** 16 | 17 | 18 | ... 19 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: "Run linters and tests" 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | ci: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | python-version: ["3.10", "3.11", "3.12"] 11 | 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v4 15 | 16 | - name: Set up Python ${{ matrix.python-version }} 17 | uses: actions/setup-python@v5 18 | with: 19 | python-version: ${{ matrix.python-version }} 20 | 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install -r requirements/dev.txt 25 | 26 | - name: Lint with Ruff 27 | run: ruff check . 28 | 29 | - name: Lint with Black 30 | run: black . --check --diff 31 | 32 | - name: Lint with isort 33 | run: isort . --check --diff 34 | 35 | - name: Test with pytest 36 | run: pytest tests/ 37 | -------------------------------------------------------------------------------- /.github/workflows/docker-release.yml: -------------------------------------------------------------------------------- 1 | name: "Docker image release" 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | tags: 8 | - "v*" 9 | 10 | jobs: 11 | release: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v3 16 | 17 | - name: Docker meta 18 | id: meta 19 | uses: docker/metadata-action@v4 20 | with: 21 | images: | 22 | ${{ secrets.DOCKERHUB_USERNAME }}/mqtt-exporter 23 | ghcr.io/${{ github.repository_owner }}/mqtt-exporter 24 | tags: | 25 | type=ref,event=branch 26 | type=semver,pattern={{version}} 27 | type=semver,pattern={{major}}.{{minor}} 28 | type=semver,pattern={{major}} 29 | 30 | - name: Login to GitHub Container Registry 31 | uses: docker/login-action@v2 32 | with: 33 | registry: ghcr.io 34 | username: ${{ github.actor }} 35 | password: ${{ secrets.GITHUB_TOKEN }} 36 | 37 | - name: Login to DockerHub 38 | uses: docker/login-action@v2 39 | with: 40 | username: ${{ secrets.DOCKERHUB_USERNAME }} 41 | password: ${{ secrets.DOCKERHUB_TOKEN }} 42 | 43 | - name: Set up QEMU 44 | id: qemu 45 | uses: docker/setup-qemu-action@v2 46 | 47 | - name: Set up Docker Buildx 48 | id: buildx 49 | uses: docker/setup-buildx-action@v2 50 | 51 | - name: Build and push 52 | uses: docker/build-push-action@v2 53 | with: 54 | context: . 55 | file: ./Dockerfile 56 | platforms: linux/386,linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x 57 | push: true 58 | tags: ${{ steps.meta.outputs.tags }} 59 | -------------------------------------------------------------------------------- /.github/workflows/helm-release.yml: -------------------------------------------------------------------------------- 1 | name: "Helm chart release" 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | tags: 8 | - "v*" 9 | 10 | jobs: 11 | release: 12 | runs-on: ubuntu-latest 13 | if: startsWith(github.ref, 'refs/tags/v') 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v3 17 | with: 18 | fetch-depth: 0 19 | 20 | - name: Configure Git 21 | run: | 22 | git config user.name "$GITHUB_ACTOR" 23 | git config user.email "$GITHUB_ACTOR@users.noreply.github.com" 24 | 25 | - name: Run chart-releaser 26 | uses: helm/chart-releaser-action@v1.7.0 27 | env: 28 | CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}" 29 | -------------------------------------------------------------------------------- /.github/workflows/pypi.yml: -------------------------------------------------------------------------------- 1 | name: "Publish to pypi" 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | jobs: 9 | build: 10 | name: Build distribution 📦 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: Set up Python 16 | uses: actions/setup-python@v5 17 | with: 18 | python-version: "3.x" 19 | - name: Install pypa/build 20 | run: >- 21 | python3 -m 22 | pip install 23 | build 24 | --user 25 | - name: Build a binary wheel and a source tarball 26 | run: python3 -m build 27 | - name: Store the distribution packages 28 | uses: actions/upload-artifact@v4 29 | with: 30 | name: python-package-distributions 31 | path: dist/ 32 | 33 | publish-to-pypi: 34 | name: >- 35 | Publish Python 🐍 distribution 📦 to PyPI 36 | if: startsWith(github.ref, 'refs/tags/') # only publish to PyPI on tag pushes 37 | needs: 38 | - build 39 | runs-on: ubuntu-latest 40 | environment: 41 | name: pypi 42 | url: https://pypi.org/p/ # Replace with your PyPI project name 43 | permissions: 44 | id-token: write # IMPORTANT: mandatory for trusted publishing 45 | 46 | steps: 47 | - name: Download all the dists 48 | uses: actions/download-artifact@v4 49 | with: 50 | name: python-package-distributions 51 | path: dist/ 52 | - name: Publish distribution 📦 to PyPI 53 | uses: pypa/gh-action-pypi-publish@release/v1 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | .ruff_cache/ 50 | 51 | # Translations 52 | *.mo 53 | *.pot 54 | 55 | # Django stuff: 56 | *.log 57 | local_settings.py 58 | db.sqlite3 59 | 60 | # Flask stuff: 61 | instance/ 62 | .webassets-cache 63 | 64 | # Scrapy stuff: 65 | .scrapy 66 | 67 | # Sphinx documentation 68 | docs/_build/ 69 | 70 | # PyBuilder 71 | target/ 72 | 73 | # Jupyter Notebook 74 | .ipynb_checkpoints 75 | 76 | # pyenv 77 | .python-version 78 | 79 | # celery beat schedule file 80 | celerybeat-schedule 81 | 82 | # SageMath parsed files 83 | *.sage.py 84 | 85 | # Environments 86 | .env 87 | .venv 88 | env/ 89 | venv/ 90 | ENV/ 91 | env.bak/ 92 | venv.bak/ 93 | 94 | # Spyder project settings 95 | .spyderproject 96 | .spyproject 97 | 98 | # Rope project settings 99 | .ropeproject 100 | 101 | # mkdocs documentation 102 | /site 103 | 104 | # mypy 105 | .mypy_cache/ 106 | 107 | # visual studio code 108 | .vscode 109 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/psf/black 3 | rev: 21.7b0 4 | hooks: 5 | - id: black 6 | - repo: https://github.com/gvanderest/pylama-pre-commit 7 | rev: 0.1.2 8 | hooks: 9 | - id: pylama 10 | - repo: https://github.com/pycqa/isort 11 | rev: 5.9.2 12 | hooks: 13 | - id: isort 14 | name: isort -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Developer guide 2 | 3 | ## Requirements 4 | 5 | Please add/adapt **unit tests** for new features or bug fixes. 6 | 7 | Please ensure you have run the following before pushing a commit: 8 | * `black` and `isort` (or `invoke reformat`) 9 | * `pylama` to run all linters 10 | 11 | ## Coding style 12 | 13 | Follow usual best practices: 14 | * document your code (inline and docstrings) 15 | * constants are in upper case 16 | * use comprehensible variable name 17 | * one function = one purpose 18 | * function name should perfectly define its purpose 19 | 20 | ## Dev environment 21 | 22 | You can install invoke package on your system and then use it to install environement, run an autoformat or just run the exporter: 23 | 24 | * `invoke install`: to install virtualenv under .venv/ and install all dev requirements 25 | * `invoke reformat`: reformat using black and isort 26 | * `invoke start`: start the app 27 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.10-alpine 2 | 3 | # install 4 | COPY exporter.py /opt/mqtt-exporter/ 5 | COPY mqtt_exporter /opt/mqtt-exporter/mqtt_exporter 6 | COPY requirements/base.txt ./ 7 | RUN pip install -r base.txt 8 | 9 | # clean 10 | RUN rm base.txt 11 | 12 | EXPOSE 9000 13 | 14 | CMD [ "python", "/opt/mqtt-exporter/exporter.py" ] 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Kevin Petremann 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![CI](https://github.com/kpetremann/mqtt-exporter/actions/workflows/ci.yml/badge.svg) 2 | [![Codacy Badge](https://api.codacy.com/project/badge/Grade/b1ca990b576342a48d771d472e64bc24)](https://www.codacy.com/app/kpetremann/mqtt-exporter?utm_source=github.com&utm_medium=referral&utm_content=kpetremann/mqtt-exporter&utm_campaign=Badge_Grade) 3 | [![Maintainability](https://api.codeclimate.com/v1/badges/635c98a1b4701d1ab4cf/maintainability)](https://codeclimate.com/github/kpetremann/mqtt-exporter/maintainability) 4 | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 5 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 6 | 7 | [![Build and publish docker images](https://github.com/kpetremann/mqtt-exporter/actions/workflows/docker-release.yml/badge.svg)](https://hub.docker.com/r/kpetrem/mqtt-exporter) 8 | 9 | [![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/kpetremann) 10 | 11 | Buy Me A Coffee 12 | 13 | # MQTT-exporter 14 | 15 | ## Description 16 | 17 | Simple and generic Prometheus exporter for MQTT. 18 | Tested with Mosquitto MQTT and Xiaomi sensors. 19 | 20 | It exposes metrics from MQTT message out of the box. You just need to specify the target if not on localhost. 21 | 22 | MQTT-exporter expects a topic and a flat JSON payload, the value must be numeric values. 23 | 24 | It also provides message counters for each MQTT topic: 25 | ``` 26 | mqtt_message_total{instance="mqtt-exporter:9000", job="mqtt-exporter", topic="zigbee2mqtt_0x00157d00032b1234"} 10 27 | ``` 28 | 29 | ### Tested devices 30 | 31 | Note: This exporter aims to be as generic as possible. If the sensor you use is using the following format, it will work: 32 | ``` 33 | topic '/', payload '{"temperature":26.24,"humidity":45.37}' 34 | ``` 35 | 36 | Also, the Shelly format is supported: 37 | ``` 38 | topic '//sensor/temperature' '20.00' 39 | ``` 40 | 41 | The exporter is tested with: 42 | * Aqara/Xiaomi sensors (WSDCGQ11LM and VOCKQJK11LM) 43 | * SONOFF sensors (SNZB-02) 44 | * Shelly sensors (H&T wifi) 45 | * Shelly power sensors (3EM - only with `KEEP_FULL_TOPIC` enabled) 46 | 47 | It is also being used by users on: 48 | * https://github.com/jomjol/AI-on-the-edge-device 49 | * https://github.com/kbialek/deye-inverter-mqtt 50 | 51 | ### Metrics conversion example 52 | ``` 53 | topic 'zigbee2mqtt/0x00157d00032b1234', payload '{"temperature":26.24,"humidity":45.37}' 54 | ``` 55 | will be converted as: 56 | ``` 57 | mqtt_temperature{topic="zigbee2mqtt_0x00157d00032b1234"} 25.24 58 | mqtt_humidity{topic="zigbee2mqtt_0x00157d00032b1234"} 45.37 59 | ``` 60 | 61 | ### Zigbee2MQTT device availability support 62 | 63 | **Important notice: legacy availability payload is not supported and must be disabled** - see [Device availability advanced](https://www.zigbee2mqtt.io/guide/configuration/device-availability.html#availability-payload) 64 | 65 | When exposing device availability, Zigbee2MQTT add /availability suffix in the topic. So we end up with inconsistent metrics: 66 | 67 | ``` 68 | mqtt_state{topic="zigbee2mqtt_garage_availability"} 1.0 69 | mqtt_temperature{topic="zigbee2mqtt_garage"} 1.0 70 | ``` 71 | 72 | To avoid having different topics for the same device, the exporter has a normalization feature disabled by default. 73 | It can be enabled by setting ZIGBEE2MQTT_AVAILABILITY varenv to "True". 74 | 75 | It will remove the suffix from the topic and change the metric name accordingly: 76 | 77 | ``` 78 | mqtt_zigbee_availability{topic="zigbee2mqtt_garage"} 1.0 79 | mqtt_temperature{topic="zigbee2mqtt_garage"} 1.0 80 | ``` 81 | 82 | Note: the metric name mqtt_state is not kept reducing collision risks as it is too common. 83 | 84 | ### Zwavejs2Mqtt 85 | 86 | This exporter also supports Zwavejs2Mqtt metrics, preferably using "named topics" (see [official documentation](https://zwave-js.github.io/zwavejs2mqtt/#/usage/setup?id=gateway)). 87 | 88 | To set up this, you need to specify the topic prefix used by Zwavejs2Mqtt in `ZWAVE_TOPIC_PREFIX` the environment variable (default being "zwave/"). 89 | 90 | ### ESPHome 91 | 92 | ESPHome is supported only when using the default `state_topic`: `///state`. (see [official documentation](https://esphome.io/components/mqtt.html#mqtt-component-base-configuration)). 93 | 94 | To set up this, you need to specify the topic prefix list used by ESPHome in `ESPHOME_TOPIC_PREFIXES` the environment variable (default being "", so disabled). 95 | 96 | This is a list so you can simply set one or more topic prefixes, the separator being a comma. 97 | 98 | Example: `ESPHOME_TOPIC_PREFIXES="esphome-weather-indoor,esphome-weather-outdoor"` 99 | 100 | If all of your ESPHome topics share a same prefix, you can simply put the common part. In the above example, `"esphome"` will match all topic starting by "esphome". 101 | 102 | ### Hubitat 103 | 104 | Hubitat is supported. By default all topic starting with `hubitat/` will be identified and parsed as Hubitat messages. 105 | 106 | Topics look like `hubitat///attributes//value`. 107 | 108 | Like for ESPHome, `HUBITAT_TOPIC_PREFIXES` is a list with `,` as a separator. 109 | 110 | ### Configuration 111 | 112 | Parameters are passed using environment variables. 113 | 114 | The list of parameters are: 115 | * `KEEP_FULL_TOPIC`: Keep entire topic instead of the first two elements only. Usecase: Shelly 3EM (default: False) 116 | * `LOG_LEVEL`: Logging level (default: INFO) 117 | * `LOG_MQTT_MESSAGE`: Log MQTT original message, only if LOG_LEVEL is set to DEBUG (default: False) 118 | * `MQTT_IGNORED_TOPICS`: Comma-separated lists of topics to ignore. Accepts wildcards. (default: None) 119 | * `MQTT_ADDRESS`: IP or hostname of MQTT broker (default: 127.0.0.1) 120 | * `MQTT_PORT`: TCP port of MQTT broker (default: 1883) 121 | * `MQTT_TOPIC`: Comma-separated lists of topics to subscribe to (default: #) 122 | * `MQTT_KEEPALIVE`: Keep alive interval to maintain connection with MQTT broker (default: 60) 123 | * `MQTT_USERNAME`: Username which should be used to authenticate against the MQTT broker (default: None) 124 | * `MQTT_PASSWORD`: Password which should be used to authenticate against the MQTT broker (default: None) 125 | * `MQTT_V5_PROTOCOL`: Force to use MQTT protocol v5 instead of 3.1.1 126 | * `MQTT_CLIENT_ID`: Set client ID manually for MQTT connection 127 | * `MQTT_EXPOSE_CLIENT_ID`: Expose the client ID as a label in Prometheus metrics 128 | * `PROMETHEUS_ADDRESS`: HTTP server address to expose Prometheus metrics on (default: 0.0.0.0) 129 | * `PROMETHEUS_PORT`: HTTP server PORT to expose Prometheus metrics (default: 9000) 130 | * `PROMETHEUS_PREFIX`: Prefix added to the metric name, example: mqtt_temperature (default: mqtt_) 131 | * `TOPIC_LABEL`: Define the Prometheus label for the topic, example temperature{topic="device1"} (default: topic) 132 | * `ZIGBEE2MQTT_AVAILABILITY`: Normalize sensor name for device availability metric added by Zigbee2MQTT (default: False) 133 | * `ZWAVE_TOPIC_PREFIX`: MQTT topic used for Zwavejs2Mqtt messages (default: zwave/) 134 | * `ESPHOME_TOPIC_PREFIXES`: MQTT topic used for ESPHome messages (default: "") 135 | * `HUBITAT_TOPIC_PREFIXES`: MQTT topic used for Hubitat messages (default: "hubitat/") 136 | * `EXPOSE_LAST_SEEN`: Enable additional gauges exposing last seen timestamp for each metrics 137 | * `PARSE_MSG_PAYLOAD`: Enable parsing and metrics of the payload. (default: true) 138 | 139 | ### Deployment 140 | 141 | #### Using Docker 142 | 143 | With an interactive shell: 144 | 145 | ```shell 146 | docker run -it -p 9000:9000 -e "MQTT_ADDRESS=192.168.0.1" kpetrem/mqtt-exporter 147 | ``` 148 | 149 | If you need the container to start on system boot (e.g. on your server/Raspberry Pi): 150 | 151 | ```shell 152 | docker run -d -p 9000:9000 --restart unless-stopped --name mqtt-exporter -e "MQTT_ADDRESS=192.168.0.1" kpetrem/mqtt-exporter 153 | ``` 154 | 155 | #### Using Docker Compose 156 | 157 | ```yaml 158 | version: "3" 159 | services: 160 | mqtt-exporter: 161 | image: kpetrem/mqtt-exporter 162 | ports: 163 | - 9000:9000 164 | environment: 165 | - MQTT_ADDRESS=192.168.0.1 166 | restart: unless-stopped 167 | ``` 168 | 169 | #### Using Python 170 | 171 | ``` 172 | pip install -r requirements/base.txt 173 | MQTT_ADDRESS=192.168.0.1 python exporter.py 174 | ``` 175 | 176 | #### Get the metrics on Prometheus 177 | 178 | See below an example of Prometheus configuration to scrape the metrics: 179 | 180 | ``` 181 | scrape_configs: 182 | - job_name: mqtt-exporter 183 | static_configs: 184 | - targets: ["mqtt-exporter:9000"] 185 | ``` 186 | 187 | #### Nicer metrics 188 | 189 | If you want nicer metrics, you can configure mqtt-exporter in your `docker-compose.yml` as followed: 190 | ``` 191 | version: "3" 192 | services: 193 | mqtt-exporter: 194 | image: kpetrem/mqtt-exporter 195 | ports: 196 | - 9000:9000 197 | environment: 198 | - MQTT_ADDRESS=192.168.0.1 199 | - PROMETHEUS_PREFIX=sensor_ 200 | - TOPIC_LABEL=sensor 201 | restart: unless-stopped 202 | ``` 203 | 204 | Result: 205 | ``` 206 | sensor_temperature{sensor="zigbee2mqtt_bedroom"} 22.3 207 | ``` 208 | 209 | And then remove `zigbee2mqtt_` prefix from `sensor` label via Prometheus configuration: 210 | 211 | ``` 212 | scrape_configs: 213 | - job_name: mqtt-exporter 214 | static_configs: 215 | - targets: ["mqtt-exporter:9000"] 216 | metric_relabel_configs: 217 | - source_labels: [sensor] 218 | regex: 'zigbee2mqtt_(.*)' 219 | replacement: '$1' 220 | target_label: sensor 221 | ``` 222 | 223 | Result: 224 | ``` 225 | sensor_temperature{sensor=bedroom"} 22.3 226 | ``` 227 | 228 | ## Docker-compose full stack example 229 | 230 | This docker-compose aims to share a typical monitoring stack. 231 | 232 | If you need persistent metrics, I would advise using VictoriaMetrics. Of course there are other suitable persistent storage solutions for Prometheus. 233 | 234 | [docker-compose.yaml](https://github.com/kpetremann/mqtt-exporter/blob/master/doc/example/docker-compose.yml) 235 | 236 | You can also add other cool software such as Home-Assistant. 237 | 238 | ## CLI interactive test 239 | 240 | You can use the test mode to preview the conversion of a topic and payload to Prometheus metrics. 241 | 242 | Usage example: 243 | 244 | ``` 245 | $ python ./exporter.py --test 246 | topic: zigbee2mqtt/0x00157d00032b1234 247 | payload: {"temperature":26.24,"humidity":45.37} 248 | 249 | ## Debug ## 250 | 251 | parsed to: zigbee2mqtt_0x00157d00032b1234 {'temperature': 26.24, 'humidity': 45.37} 252 | INFO:mqtt-exporter:creating prometheus metric: PromMetricId(name='mqtt_temperature', labels=()) 253 | INFO:mqtt-exporter:creating prometheus metric: PromMetricId(name='mqtt_humidity', labels=()) 254 | 255 | ## Result ## 256 | 257 | # HELP mqtt_temperature metric generated from MQTT message. 258 | # TYPE mqtt_temperature gauge 259 | mqtt_temperature{topic="zigbee2mqtt_0x00157d00032b1234"} 26.24 260 | # HELP mqtt_humidity metric generated from MQTT message. 261 | # TYPE mqtt_humidity gauge 262 | mqtt_humidity{topic="zigbee2mqtt_0x00157d00032b1234"} 45.37 263 | ``` 264 | 265 | ## Contribute 266 | 267 | See [CONTRIBUTING.md](./CONTRIBUTING.md). 268 | 269 | ### Support 270 | 271 | If you like my work, don't hesitate to buy me a coffee :) 272 | 273 | Buy Me A Coffee 274 | 275 | [![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/kpetremann) 276 | -------------------------------------------------------------------------------- /charts/mqtt-exporter/.helmignore: -------------------------------------------------------------------------------- 1 | # Patterns to ignore when building packages. 2 | # This supports shell glob matching, relative path matching, and 3 | # negation (prefixed with !). Only one pattern per line. 4 | .DS_Store 5 | # Common VCS dirs 6 | .git/ 7 | .gitignore 8 | .bzr/ 9 | .bzrignore 10 | .hg/ 11 | .hgignore 12 | .svn/ 13 | # Common backup files 14 | *.swp 15 | *.bak 16 | *.tmp 17 | *.orig 18 | *~ 19 | # Various IDEs 20 | .project 21 | .idea/ 22 | *.tmproj 23 | .vscode/ 24 | -------------------------------------------------------------------------------- /charts/mqtt-exporter/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: mqtt-exporter 3 | description: A Helm chart for the converting and exporting MQTT topics to Prometheus metrics 4 | 5 | # A chart can be either an 'application' or a 'library' chart. 6 | # 7 | # Application charts are a collection of templates that can be packaged into versioned archives 8 | # to be deployed. 9 | # 10 | # Library charts provide useful utilities or functions for the chart developer. They're included as 11 | # a dependency of application charts to inject those utilities and functions into the rendering 12 | # pipeline. Library charts do not define any templates and therefore cannot be deployed. 13 | type: application 14 | 15 | # This is the chart version. This version number should be incremented each time you make changes 16 | # to the chart and its templates, including the app version. 17 | # Versions are expected to follow Semantic Versioning (https://semver.org/) 18 | version: 0.1.1 19 | 20 | # This is the version number of the application being deployed. This version number should be 21 | # incremented each time you make changes to the application. Versions are not expected to 22 | # follow Semantic Versioning. They should reflect the version the application is using. 23 | # It is recommended to use it with quotes. 24 | appVersion: "latest" 25 | -------------------------------------------------------------------------------- /charts/mqtt-exporter/templates/NOTES.txt: -------------------------------------------------------------------------------- 1 | 1. Get the application URL by running these commands: 2 | {{- if .Values.ingress.enabled }} 3 | {{- range $host := .Values.ingress.hosts }} 4 | {{- range .paths }} 5 | http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }} 6 | {{- end }} 7 | {{- end }} 8 | {{- else if contains "NodePort" .Values.service.type }} 9 | export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "mqtt-exporter.fullname" . }}) 10 | export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") 11 | echo http://$NODE_IP:$NODE_PORT 12 | {{- else if contains "LoadBalancer" .Values.service.type }} 13 | NOTE: It may take a few minutes for the LoadBalancer IP to be available. 14 | You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "mqtt-exporter.fullname" . }}' 15 | export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "mqtt-exporter.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}") 16 | echo http://$SERVICE_IP:{{ .Values.service.port }} 17 | {{- else if contains "ClusterIP" .Values.service.type }} 18 | export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "mqtt-exporter.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}") 19 | export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}") 20 | echo "Visit http://127.0.0.1:8080 to use your application" 21 | kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT 22 | {{- end }} 23 | -------------------------------------------------------------------------------- /charts/mqtt-exporter/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* 2 | Expand the name of the chart. 3 | */}} 4 | {{- define "mqtt-exporter.name" -}} 5 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} 6 | {{- end }} 7 | 8 | {{/* 9 | Create a default fully qualified app name. 10 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 11 | If release name contains chart name it will be used as a full name. 12 | */}} 13 | {{- define "mqtt-exporter.fullname" -}} 14 | {{- if .Values.fullnameOverride }} 15 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} 16 | {{- else }} 17 | {{- $name := default .Chart.Name .Values.nameOverride }} 18 | {{- if contains $name .Release.Name }} 19 | {{- .Release.Name | trunc 63 | trimSuffix "-" }} 20 | {{- else }} 21 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} 22 | {{- end }} 23 | {{- end }} 24 | {{- end }} 25 | 26 | {{/* 27 | Create chart name and version as used by the chart label. 28 | */}} 29 | {{- define "mqtt-exporter.chart" -}} 30 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} 31 | {{- end }} 32 | 33 | {{/* 34 | Common labels 35 | */}} 36 | {{- define "mqtt-exporter.labels" -}} 37 | helm.sh/chart: {{ include "mqtt-exporter.chart" . }} 38 | {{ include "mqtt-exporter.selectorLabels" . }} 39 | {{- if .Chart.AppVersion }} 40 | app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} 41 | {{- end }} 42 | app.kubernetes.io/managed-by: {{ .Release.Service }} 43 | {{- end }} 44 | 45 | {{/* 46 | Selector labels 47 | */}} 48 | {{- define "mqtt-exporter.selectorLabels" -}} 49 | app.kubernetes.io/name: {{ include "mqtt-exporter.name" . }} 50 | app.kubernetes.io/instance: {{ .Release.Name }} 51 | {{- end }} 52 | 53 | {{/* 54 | Create the name of the service account to use 55 | */}} 56 | {{- define "mqtt-exporter.serviceAccountName" -}} 57 | {{- if .Values.serviceAccount.create }} 58 | {{- default (include "mqtt-exporter.fullname" .) .Values.serviceAccount.name }} 59 | {{- else }} 60 | {{- default "default" .Values.serviceAccount.name }} 61 | {{- end }} 62 | {{- end }} 63 | -------------------------------------------------------------------------------- /charts/mqtt-exporter/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: {{ include "mqtt-exporter.fullname" . }} 5 | labels: 6 | {{- include "mqtt-exporter.labels" . | nindent 4 }} 7 | spec: 8 | replicas: {{ .Values.replicaCount }} 9 | selector: 10 | matchLabels: 11 | {{- include "mqtt-exporter.selectorLabels" . | nindent 6 }} 12 | template: 13 | metadata: 14 | {{- with .Values.podAnnotations }} 15 | annotations: 16 | {{- toYaml . | nindent 8 }} 17 | {{- end }} 18 | labels: 19 | {{- include "mqtt-exporter.selectorLabels" . | nindent 8 }} 20 | spec: 21 | {{- with .Values.imagePullSecrets }} 22 | imagePullSecrets: 23 | {{- toYaml . | nindent 8 }} 24 | {{- end }} 25 | serviceAccountName: {{ include "mqtt-exporter.serviceAccountName" . }} 26 | securityContext: 27 | {{- toYaml .Values.podSecurityContext | nindent 8 }} 28 | containers: 29 | - name: {{ .Chart.Name }} 30 | env: 31 | - name: PROMETHEUS_PREFIX 32 | value: {{ .Values.mqttExporter.prometheus.prefix | quote}} 33 | - name: TOPIC_LABEL 34 | value: {{ .Values.mqttExporter.prometheus.topic_label | quote }} 35 | - name: MQTT_TOPIC 36 | value: {{ .Values.mqttExporter.mqtt.topic | quote }} 37 | - name: MQTT_ADDRESS 38 | value: {{ .Values.mqttExporter.mqtt.connection.address | quote }} 39 | - name: MQTT_PORT 40 | value: {{ .Values.mqttExporter.mqtt.connection.port | quote }} 41 | - name: MQTT_ADDRESS 42 | value: {{ .Values.mqttExporter.mqtt.connection.address | quote }} 43 | - name: MQTT_USERNAME 44 | value: {{ .Values.mqttExporter.mqtt.connection.authentication.username | quote }} 45 | - name: MQTT_PASSWORD 46 | value: {{ .Values.mqttExporter.mqtt.connection.authentication.password | quote }} 47 | - name: MQTT_TLS_NO_VERIFY 48 | value: {{ .Values.mqttExporter.mqtt.connection.tls.noVerify | quote }} 49 | - name: MQTT_ENABLE_TLS 50 | value: {{ .Values.mqttExporter.mqtt.connection.tls.enable | quote }} 51 | - name: LOG_LEVEL 52 | value: {{ .Values.mqttExporter.log.level | quote }} 53 | securityContext: 54 | {{- toYaml .Values.securityContext | nindent 12 }} 55 | image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" 56 | imagePullPolicy: {{ .Values.image.pullPolicy }} 57 | ports: 58 | - name: metrics 59 | containerPort: {{ .Values.service.port }} 60 | protocol: TCP 61 | livenessProbe: 62 | httpGet: 63 | path: /healthz 64 | port: metrics 65 | readinessProbe: 66 | httpGet: 67 | path: /healthz 68 | port: metrics 69 | resources: 70 | {{- toYaml .Values.resources | nindent 12 }} 71 | {{- with .Values.nodeSelector }} 72 | nodeSelector: 73 | {{- toYaml . | nindent 8 }} 74 | {{- end }} 75 | {{- with .Values.affinity }} 76 | affinity: 77 | {{- toYaml . | nindent 8 }} 78 | {{- end }} 79 | {{- with .Values.tolerations }} 80 | tolerations: 81 | {{- toYaml . | nindent 8 }} 82 | {{- end }} 83 | -------------------------------------------------------------------------------- /charts/mqtt-exporter/templates/ingress.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.ingress.enabled -}} 2 | {{- $fullName := include "mqtt-exporter.fullname" . -}} 3 | {{- $svcPort := .Values.service.port -}} 4 | {{- if and .Values.ingress.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }} 5 | {{- if not (hasKey .Values.ingress.annotations "kubernetes.io/ingress.class") }} 6 | {{- $_ := set .Values.ingress.annotations "kubernetes.io/ingress.class" .Values.ingress.className}} 7 | {{- end }} 8 | {{- end }} 9 | {{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}} 10 | apiVersion: networking.k8s.io/v1 11 | {{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}} 12 | apiVersion: networking.k8s.io/v1beta1 13 | {{- else -}} 14 | apiVersion: extensions/v1beta1 15 | {{- end }} 16 | kind: Ingress 17 | metadata: 18 | name: {{ $fullName }} 19 | labels: 20 | {{- include "mqtt-exporter.labels" . | nindent 4 }} 21 | {{- with .Values.ingress.annotations }} 22 | annotations: 23 | {{- toYaml . | nindent 4 }} 24 | {{- end }} 25 | spec: 26 | {{- if and .Values.ingress.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }} 27 | ingressClassName: {{ .Values.ingress.className }} 28 | {{- end }} 29 | {{- if .Values.ingress.tls }} 30 | tls: 31 | {{- range .Values.ingress.tls }} 32 | - hosts: 33 | {{- range .hosts }} 34 | - {{ . | quote }} 35 | {{- end }} 36 | secretName: {{ .secretName }} 37 | {{- end }} 38 | {{- end }} 39 | rules: 40 | {{- range .Values.ingress.hosts }} 41 | - host: {{ .host | quote }} 42 | http: 43 | paths: 44 | {{- range .paths }} 45 | - path: {{ .path }} 46 | {{- if and .pathType (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion) }} 47 | pathType: {{ .pathType }} 48 | {{- end }} 49 | backend: 50 | {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }} 51 | service: 52 | name: {{ $fullName }} 53 | port: 54 | number: {{ $svcPort }} 55 | {{- else }} 56 | serviceName: {{ $fullName }} 57 | servicePort: {{ $svcPort }} 58 | {{- end }} 59 | {{- end }} 60 | {{- end }} 61 | {{- end }} 62 | -------------------------------------------------------------------------------- /charts/mqtt-exporter/templates/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: {{ include "mqtt-exporter.fullname" . }} 5 | labels: 6 | {{- include "mqtt-exporter.labels" . | nindent 4 }} 7 | spec: 8 | type: {{ .Values.service.type }} 9 | ports: 10 | - port: {{ .Values.service.port }} 11 | targetPort: metrics 12 | protocol: TCP 13 | name: metrics 14 | selector: 15 | {{- include "mqtt-exporter.selectorLabels" . | nindent 4 }} 16 | -------------------------------------------------------------------------------- /charts/mqtt-exporter/templates/serviceaccount.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.serviceAccount.create -}} 2 | apiVersion: v1 3 | kind: ServiceAccount 4 | metadata: 5 | name: {{ include "mqtt-exporter.serviceAccountName" . }} 6 | labels: 7 | {{- include "mqtt-exporter.labels" . | nindent 4 }} 8 | {{- with .Values.serviceAccount.annotations }} 9 | annotations: 10 | {{- toYaml . | nindent 4 }} 11 | {{- end }} 12 | {{- end }} 13 | -------------------------------------------------------------------------------- /charts/mqtt-exporter/templates/servicemonitor.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.serviceMonitor.enabled -}} 2 | apiVersion: monitoring.coreos.com/v1 3 | kind: ServiceMonitor 4 | metadata: 5 | name: {{ include "mqtt-exporter.fullname" . }} 6 | labels: 7 | {{- include "mqtt-exporter.labels" . | nindent 4 }} 8 | spec: 9 | selector: 10 | matchLabels: 11 | app.kubernetes.io/name: mqtt-exporter 12 | endpoints: 13 | - port: metrics 14 | {{- end }} -------------------------------------------------------------------------------- /charts/mqtt-exporter/templates/tests/test-connection.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Pod 3 | metadata: 4 | name: "{{ include "mqtt-exporter.fullname" . }}-test-connection" 5 | labels: 6 | {{- include "mqtt-exporter.labels" . | nindent 4 }} 7 | annotations: 8 | "helm.sh/hook": test 9 | spec: 10 | containers: 11 | - name: wget 12 | image: busybox 13 | command: ['wget'] 14 | args: ['{{ include "mqtt-exporter.fullname" . }}:{{ .Values.service.port }}'] 15 | restartPolicy: Never 16 | -------------------------------------------------------------------------------- /charts/mqtt-exporter/values.yaml: -------------------------------------------------------------------------------- 1 | # Default values for mqtt-exporter. 2 | # This is a YAML-formatted file. 3 | # Declare variables to be passed into your templates. 4 | 5 | replicaCount: 1 6 | 7 | mqttExporter: 8 | log: 9 | level: INFO 10 | mqtt: 11 | messages: false 12 | mqtt: 13 | topic: "#" 14 | ignored_topics: [] 15 | connection: 16 | address: 127.0.0.1 17 | port: 1883 18 | authentication: 19 | username: username 20 | password: password 21 | tls: 22 | enable: false 23 | verify: true 24 | prometheus: 25 | prefix: mqtt_ 26 | topic_label: topic 27 | 28 | image: 29 | repository: kpetrem/mqtt-exporter 30 | pullPolicy: IfNotPresent 31 | # Overrides the image tag whose default is the chart appVersion. 32 | tag: latest 33 | 34 | imagePullSecrets: [] 35 | nameOverride: "" 36 | fullnameOverride: "" 37 | 38 | serviceAccount: 39 | # Specifies whether a service account should be created 40 | create: true 41 | # Annotations to add to the service account 42 | annotations: {} 43 | # The name of the service account to use. 44 | # If not set and create is true, a name is generated using the fullname template 45 | name: "" 46 | 47 | serviceMonitor: 48 | enabled: false 49 | 50 | podAnnotations: {} 51 | 52 | podSecurityContext: {} 53 | # fsGroup: 2000 54 | 55 | securityContext: {} 56 | # capabilities: 57 | # drop: 58 | # - ALL 59 | # readOnlyRootFilesystem: true 60 | # runAsNonRoot: true 61 | # runAsUser: 1000 62 | 63 | service: 64 | type: ClusterIP 65 | port: 9000 66 | 67 | ingress: 68 | enabled: false 69 | className: "" 70 | annotations: {} 71 | # kubernetes.io/ingress.class: nginx 72 | # kubernetes.io/tls-acme: "true" 73 | hosts: 74 | - host: chart-example.local 75 | paths: 76 | - path: / 77 | pathType: ImplementationSpecific 78 | tls: [] 79 | # - secretName: chart-example-tls 80 | # hosts: 81 | # - chart-example.local 82 | 83 | resources: {} 84 | # We usually recommend not to specify default resources and to leave this as a conscious 85 | # choice for the user. This also increases chances charts run on environments with little 86 | # resources, such as Minikube. If you do want to specify resources, uncomment the following 87 | # lines, adjust them as necessary, and remove the curly braces after 'resources:'. 88 | # limits: 89 | # cpu: 100m 90 | # memory: 128Mi 91 | # requests: 92 | # cpu: 100m 93 | # memory: 128Mi 94 | 95 | 96 | nodeSelector: {} 97 | 98 | tolerations: [] 99 | 100 | affinity: {} 101 | -------------------------------------------------------------------------------- /doc/example/config/alertmanager/alertmanager.yml: -------------------------------------------------------------------------------- 1 | route: 2 | receiver: 'slack' 3 | group_by: ['...'] 4 | 5 | receivers: 6 | - name: 'slack' 7 | slack_configs: 8 | - send_resolved: true 9 | text: "{{ .CommonAnnotations.description }}" 10 | username: 'CHANGEME' 11 | channel: 'CHANGEME' 12 | api_url: 'https://hooks.slack.com/services/CHANGEME' 13 | -------------------------------------------------------------------------------- /doc/example/config/mosquitto/mosquitto.conf: -------------------------------------------------------------------------------- 1 | listener 1883 2 | allow_anonymous false 3 | password_file /mosquitto/config/passwd.conf -------------------------------------------------------------------------------- /doc/example/config/mosquitto/passwd.conf: -------------------------------------------------------------------------------- 1 | user:hashed_password -------------------------------------------------------------------------------- /doc/example/config/prometheus/alert.rules: -------------------------------------------------------------------------------- 1 | groups: 2 | - name: iot 3 | rules: 4 | - alert: sensor_down 5 | expr: 'sensor_linkquality == 0' 6 | for: 12h 7 | labels: 8 | severity: notification-daily 9 | annotations: 10 | description: "Sensor {{ $labels.sensor }} is out or range" 11 | - alert: sensor_no_fresh_data 12 | expr: 'changes(sensor_temperature[6h]) == 0 and on(sensor) sensor_linkquality > 0' 13 | for: 12h 14 | labels: 15 | severity: notification-daily 16 | annotations: 17 | description: "Sensor data did not change for 6 hours: {{ $labels.sensor }}" 18 | -------------------------------------------------------------------------------- /doc/example/config/prometheus/prometheus.yml: -------------------------------------------------------------------------------- 1 | global: 2 | scrape_interval: 30s 3 | evaluation_interval: 30s 4 | 5 | rule_files: 6 | - "alert.rules" 7 | 8 | scrape_configs: 9 | - job_name: prometheus 10 | static_configs: 11 | - targets: ["localhost:9090"] 12 | 13 | - job_name: mqtt 14 | static_configs: 15 | - targets: ["mosquitto-exporter:9234"] 16 | 17 | - job_name: mqtt-exporter 18 | static_configs: 19 | - targets: ["mqtt-exporter:9000"] 20 | 21 | alerting: 22 | alertmanagers: 23 | - scheme: http 24 | static_configs: 25 | - targets: 26 | - 'alertmanager:9093' 27 | -------------------------------------------------------------------------------- /doc/example/config/zigbee2mqtt/configuration.yaml: -------------------------------------------------------------------------------- 1 | homeassistant: true 2 | permit_join: true 3 | mqtt: 4 | base_topic: zigbee2mqtt 5 | server: mqtt://mqtt 6 | user: CHANGEME 7 | password: CHANGEME 8 | keepalive: 60 9 | reject_unauthorized: true 10 | version: 4 11 | serial: 12 | port: /dev/ttyUSB0 13 | advanced: 14 | elapsed: true 15 | pan_id: 6760 16 | channel: 15 17 | ikea_ota_use_test_url: false 18 | log_syslog: 19 | app_name: Zigbee2MQTT 20 | eol: /n 21 | host: localhost 22 | localhost: localhost 23 | path: /dev/log 24 | pid: process.pid 25 | port: 123 26 | protocol: tcp4 27 | type: '5424' 28 | legacy_availability_payload: false 29 | frontend: 30 | port: 8080 31 | host: 0.0.0.0 32 | auth_token: CHANGEME 33 | availability: true 34 | -------------------------------------------------------------------------------- /doc/example/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | 5 | mqtt: 6 | image: eclipse-mosquitto 7 | ports: 8 | - 1883:1883 9 | - 9001:9001 10 | volumes: 11 | - ./config/mosquitto/mosquitto.conf:/mosquitto/config/mosquitto.conf 12 | - ./config/mosquitto/passwd.conf:/mosquitto/config/passwd.conf 13 | restart: unless-stopped 14 | 15 | zigbee2mqtt: 16 | image: koenkk/zigbee2mqtt 17 | volumes: 18 | - ./config/zigbee2mqtt/:/app/data/ 19 | devices: 20 | - /dev/ttyUSB0 21 | ports: 22 | - 8080:8080 23 | restart: unless-stopped 24 | 25 | mosquitto-exporter: 26 | image: sapcc/mosquitto-exporter 27 | environment: 28 | - "BROKER_ENDPOINT=tcp://mqtt:1883" 29 | - MQTT_USER=RANDOM_USER 30 | - MQTT_PASS=RANDOM_PASSWORD 31 | restart: unless-stopped 32 | 33 | mqtt-exporter: 34 | image: kpetrem/mqtt-exporter 35 | environment: 36 | - MQTT_ADDRESS=mqtt 37 | - PROMETHEUS_PREFIX=sensor_ 38 | - TOPIC_LABEL=sensor 39 | - MQTT_USERNAME=RANDOM_USER 40 | - MQTT_PASSWORD=RANDOM_PASSWORD 41 | - ZIGBEE2MQTT_AVAILABILITY=True 42 | ports: 43 | - 8999:9000 44 | restart: unless-stopped 45 | 46 | prometheus: 47 | image: prom/prometheus 48 | volumes: 49 | - ./config/prometheus/:/etc/prometheus/ 50 | - prometheus_data:/prometheus 51 | command: 52 | - "--config.file=/etc/prometheus/prometheus.yml" 53 | - "--storage.tsdb.path=/prometheus" 54 | restart: unless-stopped 55 | 56 | alertmanager: 57 | image: prom/alertmanager 58 | volumes: 59 | - ./config/alertmanager/:/etc/alertmanager/ 60 | command: 61 | - '--config.file=/etc/alertmanager/alertmanager.yml' 62 | - "--web.external-url=https://alertmanager.local" 63 | ports: 64 | - 9093:9093 65 | restart: unless-stopped 66 | 67 | grafana: 68 | image: grafana/grafana 69 | volumes: 70 | - ./config/grafana/datasources:/etc/grafana/datasources 71 | - ./config/grafana/dashboards:/etc/grafana/dashboards 72 | - grafana_data:/var/lib/grafana 73 | restart: unless-stopped 74 | 75 | volumes: 76 | prometheus_data: 77 | grafana_data: 78 | -------------------------------------------------------------------------------- /docker-compose.dev.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | mqtt-exporter: 4 | build: . 5 | ports: 6 | - 9000:9000 7 | environment: 8 | - MQTT_ADDRESS=192.168.0.1 9 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | mqtt-exporter: 4 | image: kpetrem/mqtt-exporter 5 | ports: 6 | - 9000:9000 7 | environment: 8 | - MQTT_ADDRESS=192.168.0.1 9 | -------------------------------------------------------------------------------- /exporter.py: -------------------------------------------------------------------------------- 1 | """Start script.""" 2 | 3 | from mqtt_exporter.main import main 4 | 5 | if __name__ == "__main__": 6 | main() 7 | -------------------------------------------------------------------------------- /mqtt_exporter/__init__.py: -------------------------------------------------------------------------------- 1 | """MQTT Prometheus exporter.""" 2 | -------------------------------------------------------------------------------- /mqtt_exporter/main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """MQTT exporter.""" 3 | 4 | import argparse 5 | import fnmatch 6 | import json 7 | import logging 8 | import re 9 | import signal 10 | import ssl 11 | import sys 12 | import time 13 | from collections import defaultdict 14 | from dataclasses import dataclass 15 | 16 | import paho.mqtt.client as mqtt 17 | from prometheus_client import ( 18 | REGISTRY, 19 | Counter, 20 | Gauge, 21 | generate_latest, 22 | start_http_server, 23 | validation, 24 | ) 25 | 26 | from mqtt_exporter import settings 27 | 28 | logging.basicConfig(level=settings.LOG_LEVEL) 29 | LOG = logging.getLogger("mqtt-exporter") 30 | 31 | ZIGBEE2MQTT_AVAILABILITY_SUFFIX = "/availability" 32 | STATE_VALUES = { 33 | "ON": 1, 34 | "OFF": 0, 35 | "TRUE": 1, 36 | "FALSE": 0, 37 | "ONLINE": 1, 38 | "OFFLINE": 0, 39 | } 40 | 41 | 42 | @dataclass(frozen=True) 43 | class PromMetricId: 44 | name: str 45 | labels: tuple = () 46 | 47 | 48 | # global variables 49 | metric_refs: dict[str, list[tuple]] = defaultdict(list) 50 | prom_metrics: dict[PromMetricId, Gauge] = {} 51 | prom_msg_counter = None 52 | 53 | 54 | def _create_msg_counter_metrics(): 55 | global prom_msg_counter # noqa: PLW0603 56 | if settings.MQTT_EXPOSE_CLIENT_ID: 57 | prom_msg_counter = Counter( # noqa: PLW0603 58 | f"{settings.PREFIX}message_total", 59 | "Counter of received messages", 60 | [settings.TOPIC_LABEL, "client_id"], 61 | ) 62 | else: 63 | prom_msg_counter = Counter( # noqa: PLW0603 64 | f"{settings.PREFIX}message_total", 65 | "Counter of received messages", 66 | [settings.TOPIC_LABEL], 67 | ) 68 | 69 | 70 | def subscribe(client, _, __, reason_code, properties): 71 | """Subscribe to mqtt events (callback).""" 72 | user_data = {"client_id": settings.MQTT_CLIENT_ID} 73 | if not settings.MQTT_CLIENT_ID and settings.MQTT_V5_PROTOCOL: 74 | user_data["client_id"] = properties.AssignedClientIdentifier 75 | 76 | client.user_data_set(user_data) 77 | 78 | for s in settings.TOPIC.split(","): 79 | LOG.info('subscribing to "%s"', s) 80 | client.subscribe(s) 81 | if reason_code != mqtt.CONNACK_ACCEPTED: 82 | LOG.error("MQTT %s", mqtt.connack_string(reason_code)) 83 | 84 | 85 | def _normalize_prometheus_metric_name(prom_metric_name): 86 | """Transform an invalid prometheus metric to a valid one. 87 | 88 | https://prometheus.io/docs/concepts/data_model/#metric-names-and-labels 89 | """ 90 | if validation.METRIC_NAME_RE.match(prom_metric_name): 91 | return prom_metric_name 92 | 93 | # clean invalid characted 94 | prom_metric_name = re.sub(r"[^a-zA-Z0-9_:]", "", prom_metric_name) 95 | 96 | # ensure to start with valid character 97 | if not re.match(r"^[a-zA-Z_:]", prom_metric_name): 98 | prom_metric_name = ":" + prom_metric_name 99 | 100 | return prom_metric_name 101 | 102 | 103 | def _normalize_prometheus_metric_label_name(prom_metric_label_name): 104 | """Transform an invalid prometheus metric to a valid one. 105 | 106 | https://prometheus.io/docs/concepts/data_model/#metric-names-and-labels 107 | """ 108 | # clean invalid characters 109 | prom_metric_label_name = re.sub(r"[^a-zA-Z0-9_]", "", prom_metric_label_name) 110 | 111 | # ensure to start with valid character 112 | if not re.match(r"^[a-zA-Z_]", prom_metric_label_name): 113 | prom_metric_label_name = "_" + prom_metric_label_name 114 | if prom_metric_label_name.startswith("__"): 115 | prom_metric_label_name = prom_metric_label_name[1:] 116 | 117 | return prom_metric_label_name 118 | 119 | 120 | def _create_prometheus_metric(prom_metric_id, original_topic): 121 | """Create Prometheus metric if does not exist.""" 122 | if not prom_metrics.get(prom_metric_id): 123 | labels = [settings.TOPIC_LABEL] 124 | if settings.MQTT_EXPOSE_CLIENT_ID: 125 | labels.append("client_id") 126 | labels.extend(prom_metric_id.labels) 127 | 128 | prom_metrics[prom_metric_id] = Gauge( 129 | prom_metric_id.name, "metric generated from MQTT message.", labels 130 | ) 131 | metric_refs[original_topic].append((prom_metric_id, labels)) 132 | 133 | if settings.EXPOSE_LAST_SEEN: 134 | ts_metric_id = PromMetricId(f"{prom_metric_id.name}_ts", prom_metric_id.labels) 135 | prom_metrics[ts_metric_id] = Gauge( 136 | ts_metric_id.name, "timestamp of metric generated from MQTT message.", labels 137 | ) 138 | metric_refs[original_topic].append((ts_metric_id, labels)) 139 | 140 | LOG.info("creating prometheus metric: %s", prom_metric_id) 141 | 142 | 143 | def _add_prometheus_sample( 144 | topic, original_topic, prom_metric_id, metric_value, client_id, additional_labels 145 | ): 146 | if prom_metric_id not in prom_metrics: 147 | return 148 | 149 | labels = {settings.TOPIC_LABEL: topic} 150 | if settings.MQTT_EXPOSE_CLIENT_ID: 151 | labels["client_id"] = client_id 152 | labels.update(additional_labels) 153 | 154 | prom_metrics[prom_metric_id].labels(**labels).set(metric_value) 155 | if not (prom_metric_id, labels) not in metric_refs[original_topic]: 156 | metric_refs[original_topic].append((prom_metric_id, labels)) 157 | 158 | if settings.EXPOSE_LAST_SEEN: 159 | ts_metric_id = PromMetricId(f"{prom_metric_id.name}_ts", prom_metric_id.labels) 160 | prom_metrics[ts_metric_id].labels(**labels).set(int(time.time())) 161 | if not (ts_metric_id, labels) not in metric_refs[original_topic]: 162 | metric_refs[original_topic].append((ts_metric_id, labels)) 163 | 164 | LOG.debug("new value for %s: %s", prom_metric_id, metric_value) 165 | 166 | 167 | def _parse_metric(data): 168 | """Attempt to parse the value and extract a number out of it. 169 | 170 | Note that `data` is untrusted input at this point. 171 | 172 | Raise ValueError is the data can't be parsed. 173 | """ 174 | if isinstance(data, (int, float)): 175 | return data 176 | 177 | if isinstance(data, bytes): 178 | data = data.decode() 179 | 180 | if isinstance(data, str): 181 | data = data.upper() 182 | 183 | # Handling of switch data where their state is reported as ON/OFF 184 | if data in STATE_VALUES: 185 | return STATE_VALUES[data] 186 | 187 | # Last ditch effort, we got a string, let's try to cast it 188 | return float(data) 189 | 190 | # We were not able to extract anything, let's bubble it up. 191 | raise ValueError(f"Can't parse '{data}' to a number.") 192 | 193 | 194 | def _parse_metrics(data, topic, original_topic, client_id, prefix="", labels=None): 195 | """Attempt to parse a set of metrics. 196 | 197 | Note when `data` contains nested metrics this function will be called recursively. 198 | """ 199 | if labels is None: 200 | labels = {} 201 | label_keys = tuple(sorted(labels.keys())) 202 | 203 | for metric, value in data.items(): 204 | # when value is a list recursively call _parse_metrics to handle these messages 205 | if isinstance(value, list): 206 | LOG.debug("parsing list %s: %s", metric, value) 207 | _parse_metrics( 208 | dict(enumerate(value)), 209 | topic, 210 | original_topic, 211 | client_id, 212 | f"{prefix}{metric}_", 213 | labels, 214 | ) 215 | continue 216 | 217 | # when value is a dict recursively call _parse_metrics to handle these messages 218 | if isinstance(value, dict): 219 | LOG.debug("parsing dict %s: %s", metric, value) 220 | _parse_metrics(value, topic, original_topic, client_id, f"{prefix}{metric}_", labels) 221 | continue 222 | 223 | try: 224 | metric_value = _parse_metric(value) 225 | except ValueError as err: 226 | LOG.debug("Failed to convert %s: %s", metric, err) 227 | continue 228 | 229 | # create metric if does not exist 230 | prom_metric_name = ( 231 | f"{settings.PREFIX}{prefix}{metric}".replace(".", "") 232 | .replace(" ", "_") 233 | .replace("-", "_") 234 | .replace("/", "_") 235 | ) 236 | prom_metric_name = re.sub(r"\((.*?)\)", "", prom_metric_name) 237 | prom_metric_name = _normalize_prometheus_metric_name(prom_metric_name) 238 | prom_metric_id = PromMetricId(prom_metric_name, label_keys) 239 | try: 240 | _create_prometheus_metric(prom_metric_id, original_topic) 241 | except ValueError as error: 242 | LOG.error("unable to create prometheus metric '%s': %s", prom_metric_id, error) 243 | return 244 | 245 | # expose the sample to prometheus 246 | _add_prometheus_sample( 247 | topic, original_topic, prom_metric_id, metric_value, client_id, labels 248 | ) 249 | 250 | 251 | def _normalize_name_in_topic_msg(topic, payload): 252 | """Normalize message to classic topic payload format. 253 | 254 | Used when payload is containing only the value, and the sensor metric name is in the topic. 255 | 256 | Used for: 257 | - Shelly sensors 258 | - Custom integration with single value 259 | 260 | Warning: only support when the last item in the topic is the actual metric name 261 | 262 | Example: 263 | Shelly integrated topic and payload differently than usually (Aqara) 264 | * topic: shellies/room/sensor/temperature 265 | * payload: 20.00 266 | """ 267 | info = topic.split("/") 268 | payload_dict = {} 269 | 270 | # Shellies format 271 | try: 272 | if settings.KEEP_FULL_TOPIC: # options instead of hardcoded length 273 | topic = "/".join(info[:-1]).lower() 274 | else: 275 | topic = f"{info[0]}/{info[1]}".lower() 276 | 277 | payload_dict = {info[-1]: payload} # usually the last element is the type of sensor 278 | except IndexError: 279 | pass 280 | 281 | return topic, payload_dict 282 | 283 | 284 | def _normalize_zwave2mqtt_format(topic, payload): 285 | """Normalize zwave2mqtt format. 286 | 287 | Example: 288 | zwave/BackRoom/Multisensor/sensor_multilevel/endpoint_0/Air_temperature 289 | zwave/Stereo/PowerStrip/status 290 | 291 | Only supports named topics or at least when endpoint_ is defined: 292 | ////// 293 | """ 294 | if "node_info" in topic or "endpoint_" not in topic: 295 | return topic, {} 296 | 297 | if not isinstance(payload, dict) or "value" not in payload: 298 | return topic, {} 299 | 300 | info = topic.split("/") 301 | 302 | # the endpoint location permits to differentiate the properties from the sensor ID 303 | properties_index = [i for i, k in enumerate(info) if k.startswith("endpoint_")][0] + 1 304 | 305 | topic = "/".join(info[:properties_index]).lower() 306 | properties = "_".join(info[properties_index:]) 307 | payload_dict = {properties.lower(): payload["value"]} 308 | 309 | return topic, payload_dict 310 | 311 | 312 | def _normalize_esphome_format(topic, payload): 313 | """Normalize esphome format. 314 | 315 | Example: 316 | esphome/sensor/temperature/state 317 | 318 | Only supports default state_topic: 319 | ///state 320 | """ 321 | info = topic.split("/") 322 | 323 | topic = f"{info[0].lower()}/{info[1].lower()}" 324 | payload_dict = {info[-2]: payload} 325 | return topic, payload_dict 326 | 327 | 328 | def _normalize_hubitat_format(topic, payload): 329 | """Normalize hubitat format. 330 | 331 | Example: 332 | hubitat/hub1/some room/temperature/value 333 | """ 334 | info = topic.split("/") 335 | 336 | if len(info) < 3: 337 | return topic, payload 338 | 339 | topic = f"{info[0].lower()}_{info[1].lower()}_{info[2].lower()}" 340 | payload_dict = {info[-2]: payload} 341 | return topic, payload_dict 342 | 343 | 344 | def _is_esphome_topic(topic): 345 | for prefix in settings.ESPHOME_TOPIC_PREFIXES: 346 | if prefix and topic.startswith(prefix): 347 | return True 348 | 349 | return False 350 | 351 | 352 | def _is_hubitat_topic(topic): 353 | for prefix in settings.HUBITAT_TOPIC_PREFIXES: 354 | if prefix and topic.startswith(prefix): 355 | return True 356 | 357 | return False 358 | 359 | 360 | def _parse_message(raw_topic, raw_payload): 361 | """Parse topic and payload to have exposable information.""" 362 | # parse MQTT payload 363 | try: 364 | if not isinstance(raw_payload, str): 365 | raw_payload = raw_payload.decode(json.detect_encoding(raw_payload)) 366 | except UnicodeDecodeError: 367 | LOG.debug('encountered undecodable payload: "%s"', raw_payload) 368 | return None, None 369 | 370 | if raw_payload in STATE_VALUES: 371 | payload = STATE_VALUES[raw_payload] 372 | else: 373 | try: 374 | payload = json.loads(raw_payload) 375 | except json.JSONDecodeError: 376 | LOG.debug('failed to parse payload as JSON: "%s"', raw_payload) 377 | return None, None 378 | 379 | if raw_topic.startswith(settings.ZWAVE_TOPIC_PREFIX): 380 | topic, payload = _normalize_zwave2mqtt_format(raw_topic, payload) 381 | elif _is_hubitat_topic(raw_topic): 382 | topic, payload = _normalize_hubitat_format(raw_topic, payload) 383 | elif _is_esphome_topic(raw_topic): 384 | topic, payload = _normalize_esphome_format(raw_topic, payload) 385 | elif not isinstance(payload, dict): 386 | topic, payload = _normalize_name_in_topic_msg(raw_topic, payload) 387 | else: 388 | topic = raw_topic 389 | 390 | # handle device availability (only support non-legacy mode) 391 | if settings.ZIGBEE2MQTT_AVAILABILITY: 392 | if topic.endswith(ZIGBEE2MQTT_AVAILABILITY_SUFFIX) and "state" in payload: 393 | # move availability suffix added by Zigbee2MQTT from topic to payload 394 | # the goal is to have this kind of metric: 395 | # mqtt_zigbee_availability{sensor="zigbee2mqtt_garage"} = 1.0 396 | topic = topic[: -len(ZIGBEE2MQTT_AVAILABILITY_SUFFIX)] 397 | payload = {"zigbee_availability": payload["state"]} 398 | 399 | # parse MQTT topic 400 | try: 401 | # handle nested topic 402 | topic = topic.replace("/", "_") 403 | except UnicodeDecodeError: 404 | LOG.debug('encountered undecodable topic: "%s"', raw_topic) 405 | return None, None 406 | 407 | # handle unconverted payload 408 | if not isinstance(payload, dict): 409 | LOG.debug('failed to parse: topic "%s" payload "%s"', raw_topic, payload) 410 | return None, None 411 | 412 | return topic, payload 413 | 414 | 415 | def _parse_properties(properties): 416 | """Convert MQTTv5 properties to a dict.""" 417 | if not hasattr(properties, "UserProperty"): 418 | return {} 419 | 420 | return { 421 | _normalize_prometheus_metric_label_name(key): value 422 | for key, value in properties.UserProperty 423 | } 424 | 425 | 426 | def _zigbee2mqtt_rename(msg): 427 | # Remove old metrics following renaming 428 | 429 | payload = json.loads(msg.payload) 430 | old_topic = f"zigbee2mqtt/{payload['data']['from']}" 431 | if old_topic not in metric_refs: 432 | return 433 | 434 | for sample in metric_refs[old_topic]: 435 | try: 436 | prom_metrics[sample[0]].remove(*sample[1].values()) 437 | except KeyError: 438 | pass 439 | 440 | del metric_refs[old_topic] 441 | 442 | # Remove old availability metrics following renaming 443 | 444 | if not settings.ZIGBEE2MQTT_AVAILABILITY: 445 | return 446 | 447 | old_topic_availability = f"{old_topic}{ZIGBEE2MQTT_AVAILABILITY_SUFFIX}" 448 | if old_topic_availability not in metric_refs: 449 | return 450 | 451 | for sample in metric_refs[old_topic_availability]: 452 | try: 453 | prom_metrics[sample[0]].remove(*sample[1].values()) 454 | except KeyError: 455 | pass 456 | 457 | del metric_refs[old_topic_availability] 458 | 459 | 460 | def expose_metrics(_, userdata, msg): 461 | """Expose metrics to prometheus when a message has been published (callback).""" 462 | 463 | if msg.topic.startswith("zigbee2mqtt/") and msg.topic.endswith("/rename"): 464 | _zigbee2mqtt_rename(msg) 465 | return 466 | 467 | for ignore in settings.IGNORED_TOPICS: 468 | if fnmatch.fnmatch(msg.topic, ignore): 469 | LOG.debug('Topic "%s" was ignored by entry "%s"', msg.topic, ignore) 470 | return 471 | 472 | if settings.LOG_MQTT_MESSAGE: 473 | LOG.debug("New message from MQTT: %s - %s", msg.topic, msg.payload) 474 | 475 | topic, payload = _parse_message(msg.topic, msg.payload) 476 | 477 | if not topic or not payload: 478 | return 479 | 480 | if settings.MQTT_V5_PROTOCOL: 481 | additional_labels = _parse_properties(msg.properties) 482 | else: 483 | additional_labels = {} 484 | 485 | if settings.PARSE_MSG_PAYLOAD: 486 | _parse_metrics(payload, topic, msg.topic, userdata["client_id"], labels=additional_labels) 487 | 488 | # increment received message counter 489 | labels = {settings.TOPIC_LABEL: topic} 490 | if settings.MQTT_EXPOSE_CLIENT_ID: 491 | labels["client_id"] = userdata["client_id"] 492 | 493 | prom_msg_counter.labels(**labels).inc() 494 | 495 | 496 | def run(): 497 | """Start the exporter.""" 498 | if settings.MQTT_V5_PROTOCOL: 499 | client = mqtt.Client( 500 | callback_api_version=mqtt.CallbackAPIVersion.VERSION2, 501 | client_id=settings.MQTT_CLIENT_ID, 502 | protocol=mqtt.MQTTv5, 503 | ) 504 | else: 505 | # if MQTT version 5 is not requested, we let MQTT lib choose the protocol version 506 | client = mqtt.Client( 507 | callback_api_version=mqtt.CallbackAPIVersion.VERSION2, client_id=settings.MQTT_CLIENT_ID 508 | ) 509 | 510 | client.enable_logger(LOG) 511 | 512 | if settings.MQTT_ENABLE_TLS: 513 | LOG.debug("Enabling TLS on MQTT client") 514 | ssl_context = ssl.create_default_context() 515 | ssl_context.load_default_certs() 516 | 517 | if settings.MQTT_TLS_NO_VERIFY: 518 | LOG.debug("Not verifying MQTT certificate authority is trusted") 519 | ssl_context.check_hostname = False 520 | ssl_context.verify_mode = ssl.CERT_NONE 521 | 522 | client.tls_set_context(ssl_context) 523 | 524 | def stop_request(signum, frame): 525 | """Stop handler for SIGTERM and SIGINT. 526 | 527 | Keyword arguments: 528 | signum -- signal number 529 | frame -- None or a frame object. Represents execution frames 530 | """ 531 | LOG.warning("Stopping MQTT exporter") 532 | LOG.debug("SIGNAL: %s, FRAME: %s", signum, frame) 533 | client.disconnect() 534 | sys.exit(0) 535 | 536 | _create_msg_counter_metrics() 537 | signal.signal(signal.SIGTERM, stop_request) 538 | signal.signal(signal.SIGINT, stop_request) 539 | 540 | # start prometheus server 541 | start_http_server(settings.PROMETHEUS_PORT, settings.PROMETHEUS_ADDRESS) 542 | 543 | # define mqtt client 544 | client.on_connect = subscribe 545 | client.on_message = expose_metrics 546 | 547 | # start the connection and the loop 548 | if settings.MQTT_USERNAME and settings.MQTT_PASSWORD: 549 | client.username_pw_set(settings.MQTT_USERNAME, settings.MQTT_PASSWORD) 550 | client.connect(settings.MQTT_ADDRESS, settings.MQTT_PORT, settings.MQTT_KEEPALIVE) 551 | client.loop_forever() 552 | 553 | 554 | def main(): 555 | parser = argparse.ArgumentParser( 556 | prog="MQTT-exporter", 557 | description="Simple generic MQTT Prometheus exporter for IoT working out of the box.", 558 | epilog="https://github.com/kpetremann/mqtt-exporter", 559 | ) 560 | parser.add_argument("--test", action="store_true") 561 | args = parser.parse_args() 562 | 563 | if args.test: 564 | topic = input("topic: ") 565 | payload = input("payload: ") 566 | print() 567 | 568 | # clear registry 569 | collectors = list(REGISTRY._collector_to_names.keys()) 570 | for collector in collectors: 571 | REGISTRY.unregister(collector) 572 | 573 | print("## Debug ##\n") 574 | original_topic = topic 575 | topic, payload = _parse_message(topic, payload) 576 | print(f"parsed to: {topic} {payload}") 577 | 578 | _parse_metrics(payload, topic, original_topic, "", labels=None) 579 | print("\n## Result ##\n") 580 | print(str(generate_latest().decode("utf-8"))) 581 | else: 582 | run() 583 | 584 | 585 | if __name__ == "__main__": 586 | main() 587 | -------------------------------------------------------------------------------- /mqtt_exporter/settings.py: -------------------------------------------------------------------------------- 1 | """Exporter configuration.""" 2 | 3 | import os 4 | 5 | PREFIX = os.getenv("PROMETHEUS_PREFIX", "mqtt_") 6 | TOPIC_LABEL = os.getenv("TOPIC_LABEL", "topic") 7 | TOPIC = os.getenv("MQTT_TOPIC", "#") 8 | IGNORED_TOPICS = os.getenv("MQTT_IGNORED_TOPICS", "").split(",") 9 | ZWAVE_TOPIC_PREFIX = os.getenv("ZWAVE_TOPIC_PREFIX", "zwave/") 10 | ESPHOME_TOPIC_PREFIXES = os.getenv("ESPHOME_TOPIC_PREFIXES", "").split(",") 11 | HUBITAT_TOPIC_PREFIXES = os.getenv("HUBITAT_TOPIC_PREFIXES", "hubitat/").split(",") 12 | EXPOSE_LAST_SEEN = os.getenv("EXPOSE_LAST_SEEN", "False") == "True" 13 | PARSE_MSG_PAYLOAD = os.getenv("PARSE_MSG_PAYLOAD", "True") == "True" 14 | 15 | 16 | ZIGBEE2MQTT_AVAILABILITY = os.getenv("ZIGBEE2MQTT_AVAILABILITY", "False") == "True" 17 | LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO") 18 | LOG_MQTT_MESSAGE = os.getenv("LOG_MQTT_MESSAGE", "False") == "True" 19 | MQTT_ADDRESS = os.getenv("MQTT_ADDRESS", "127.0.0.1") 20 | MQTT_PORT = int(os.getenv("MQTT_PORT", "1883")) 21 | MQTT_KEEPALIVE = int(os.getenv("MQTT_KEEPALIVE", "60")) 22 | MQTT_USERNAME = os.getenv("MQTT_USERNAME") 23 | MQTT_PASSWORD = os.getenv("MQTT_PASSWORD") 24 | MQTT_V5_PROTOCOL = os.getenv("MQTT_V5_PROTOCOL", "False") == "True" 25 | MQTT_CLIENT_ID = os.getenv("MQTT_CLIENT_ID", "") 26 | MQTT_EXPOSE_CLIENT_ID = os.getenv("MQTT_EXPOSE_CLIENT_ID", "False") == "True" 27 | MQTT_ENABLE_TLS = os.getenv("MQTT_ENABLE_TLS", "False") == "True" 28 | MQTT_TLS_NO_VERIFY = os.getenv("MQTT_TLS_NO_VERIFY", "False") == "True" 29 | PROMETHEUS_ADDRESS = os.getenv("PROMETHEUS_ADDRESS", "0.0.0.0") 30 | PROMETHEUS_PORT = int(os.getenv("PROMETHEUS_PORT", "9000")) 31 | 32 | KEEP_FULL_TOPIC = os.getenv("KEEP_FULL_TOPIC", "False") == "True" 33 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "mqtt-exporter" 3 | version = "1.8.1" 4 | description = "Simple generic MQTT Prometheus exporter for IoT working out of the box" 5 | readme = "README.md" 6 | license = { text = "MIT License" } 7 | authors = [ 8 | { name = "Kevin Petremann", email = "kpetrem@gmail.com" }, 9 | ] 10 | keywords = ["iot", "mqtt", "prometheus"] 11 | classifiers = [ 12 | "Development Status :: 5 - Production/Stable", 13 | "License :: OSI Approved :: MIT License", 14 | "Programming Language :: Python :: 3", 15 | "Programming Language :: Python :: 3.10", 16 | "Programming Language :: Python :: 3.11", 17 | "Programming Language :: Python :: 3.12", 18 | ] 19 | dynamic = ["dependencies"] 20 | 21 | [tool.setuptools.dynamic] 22 | dependencies = { file = ["requirements/base.txt"] } 23 | 24 | [project.urls] 25 | Homepage = "https://github.com/kpetremann/mqtt-exporter" 26 | 27 | [project.scripts] 28 | mqtt-exporter = "mqtt_exporter.main:main" 29 | 30 | [tool.setuptools] 31 | packages = ["mqtt_exporter"] 32 | 33 | [tool.black] 34 | line-length = 100 35 | exclude = "venv/" 36 | 37 | [tool.isort] 38 | profile = "black" 39 | multi_line_output = 3 40 | skip_gitignore = true 41 | skip = ".bzr,.direnv,.eggs,.git,.hg,.mypy_cache,.nox,.pants.d,.svn,.tox,.venv,_build,buck-out,build,dist,node_modules,venv,migrations,urls.py" 42 | 43 | [tool.ruff] 44 | line-length = 100 45 | 46 | [tool.ruff.lint] 47 | select = [ 48 | "E", # pycodestyle errors 49 | "W", # pycodestyle warnings 50 | "F", # pyflakes 51 | "C", # flake8-comprehensions 52 | "B", # flake8-bugbear 53 | "C4", # flake8-comprehensions 54 | "G", # flake8-logging-format 55 | "S", # bandit 56 | "PL" # pylint 57 | ] 58 | ignore = [ 59 | "E501", # line too long, handled by black 60 | "C901", # function is too complex 61 | "PLR2004", # magic value used in comparison 62 | "PLR1711", # useless `return` statement at end of function 63 | "PLC1901", # compare-to-empty-string 64 | "PLR0911", # too many return statements 65 | "PLR0912", # too many branches 66 | "PLR0915", # too many statements 67 | "B009", # do not call getattr with a constant attribute value 68 | "B904", # raise without from inside except 69 | "S104", # possible binding to all interfaces 70 | ] 71 | pylint.max-args = 10 72 | 73 | [tool.ruff.lint.per-file-ignores] 74 | "__init__.py" = ["F401"] 75 | "tests/*.py" = ["E402", "S", "PL"] 76 | 77 | [tool.mypy] 78 | # error whenever it encounters a function definition without type annotations 79 | disallow_untyped_defs = true 80 | # error whenever a function with type annotations calls a function defined without annotations 81 | disallow_untyped_calls = true 82 | # stop treating arguments with a None default value as having an implicit Optional type 83 | no_implicit_optional = true 84 | # error whenever your code uses an unnecessary cast that can safely be removed 85 | warn_redundant_casts = true 86 | 87 | [tool.pytest.ini_options] 88 | asyncio_mode = "strict" 89 | -------------------------------------------------------------------------------- /requirements/base.in: -------------------------------------------------------------------------------- 1 | paho-mqtt 2 | prometheus-client -------------------------------------------------------------------------------- /requirements/base.txt: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by uv via the following command: 2 | # uv pip compile requirements/base.in -o requirements/base.txt 3 | paho-mqtt==2.1.0 4 | # via -r requirements/base.in 5 | prometheus-client==0.22.1 6 | # via -r requirements/base.in 7 | -------------------------------------------------------------------------------- /requirements/dev.txt: -------------------------------------------------------------------------------- 1 | -r base.txt 2 | -r tests.txt 3 | invoke 4 | pdbpp 5 | pre-commit 6 | -------------------------------------------------------------------------------- /requirements/tests.in: -------------------------------------------------------------------------------- 1 | black 2 | isort 3 | pylint 4 | pytest 5 | pytest-mock 6 | ruff -------------------------------------------------------------------------------- /requirements/tests.txt: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by uv via the following command: 2 | # uv pip compile requirements/tests.in -o requirements/tests.txt 3 | astroid==3.2.2 4 | # via pylint 5 | black==24.4.2 6 | # via -r requirements/tests.in 7 | click==8.1.7 8 | # via black 9 | dill==0.3.8 10 | # via pylint 11 | exceptiongroup==1.2.1 12 | # via pytest 13 | iniconfig==2.0.0 14 | # via pytest 15 | isort==5.13.2 16 | # via 17 | # -r requirements/tests.in 18 | # pylint 19 | mccabe==0.7.0 20 | # via pylint 21 | mypy-extensions==1.0.0 22 | # via black 23 | packaging==24.0 24 | # via 25 | # black 26 | # pytest 27 | pathspec==0.12.1 28 | # via black 29 | platformdirs==4.2.2 30 | # via 31 | # black 32 | # pylint 33 | pluggy==1.5.0 34 | # via pytest 35 | pylint==3.2.2 36 | # via -r requirements/tests.in 37 | pytest==8.2.2 38 | # via 39 | # -r requirements/tests.in 40 | # pytest-mock 41 | pytest-mock==3.14.0 42 | # via -r requirements/tests.in 43 | ruff==0.4.7 44 | # via -r requirements/tests.in 45 | tomli==2.0.1 46 | # via 47 | # black 48 | # pylint 49 | # pytest 50 | tomlkit==0.12.5 51 | # via pylint 52 | typing-extensions==4.12.1 53 | # via 54 | # astroid 55 | # black 56 | -------------------------------------------------------------------------------- /tasks.py: -------------------------------------------------------------------------------- 1 | """Some tasks.""" 2 | 3 | import os 4 | 5 | from invoke import task # type: ignore 6 | 7 | PATH = os.path.dirname(os.path.realpath(__file__)) 8 | 9 | 10 | @task 11 | def install(cmd): 12 | """Install virtualenv.""" 13 | cmd.run(f"python -m venv {PATH}/.venv") 14 | with cmd.prefix(f"source {PATH}/.venv/bin/activate"): 15 | cmd.run(f"pip install -r {PATH}/requirements/dev.txt") 16 | 17 | 18 | @task 19 | def reformat(cmd): 20 | """Auto format using black and isort.""" 21 | with cmd.prefix(f"source {PATH}/.venv/bin/activate"): 22 | cmd.run(f"isort {PATH}/") 23 | cmd.run(f"black {PATH}/") 24 | 25 | 26 | @task 27 | def start(cmd): 28 | """Start the app.""" 29 | with cmd.prefix(f"source {PATH}/.venv/bin/activate"): 30 | cmd.run(f"python {PATH}/exporter.py") 31 | 32 | 33 | @task 34 | def test(cmd): 35 | """Run tests.""" 36 | with cmd.prefix(f"source {PATH}/.venv/bin/activate"): 37 | cmd.run(f"pytest {PATH}/tests/") 38 | cmd.run(f"pylama {PATH}") 39 | cmd.run(f"black {PATH} --check") 40 | cmd.run(f"isort {PATH} --check") 41 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests.""" 2 | -------------------------------------------------------------------------------- /tests/functional/__init__.py: -------------------------------------------------------------------------------- 1 | """Functional tests.""" 2 | -------------------------------------------------------------------------------- /tests/functional/test_expose_metrics.py: -------------------------------------------------------------------------------- 1 | """Functional tests of MQTT metrics.""" 2 | 3 | import prometheus_client 4 | from paho.mqtt.packettypes import PacketTypes 5 | from paho.mqtt.properties import Properties 6 | 7 | from mqtt_exporter import main, settings 8 | from mqtt_exporter.main import PromMetricId 9 | 10 | 11 | def _reset(): 12 | # pylama: ignore=W0212 13 | collectors = list(prometheus_client.REGISTRY._collector_to_names.keys()) 14 | for collector in collectors: 15 | prometheus_client.REGISTRY.unregister(collector) 16 | 17 | 18 | def _exec(client_id, mocker, added_labels): 19 | _reset() 20 | settings.MQTT_CLIENT_ID = client_id 21 | main._create_msg_counter_metrics() 22 | main.prom_metrics = {} 23 | userdata = {"client_id": client_id} 24 | msg = mocker.Mock() 25 | msg.topic = "zigbee2mqtt/garage" 26 | msg.payload = '{"temperature°C": "23.5", "humidity": "40.5"}' 27 | if added_labels: 28 | msg.properties = Properties(PacketTypes.PUBLISH) 29 | msg.properties.UserProperty = [ 30 | ("label_key", "label_value"), 31 | ("Weird#Label Key!", "Weird Label Value"), 32 | ] 33 | expected_label_keys = ("WeirdLabelKey", "label_key") 34 | else: 35 | expected_label_keys = () 36 | main.expose_metrics(None, userdata, msg) 37 | 38 | temperatures = main.prom_metrics[ 39 | PromMetricId("mqtt_temperatureC", expected_label_keys) 40 | ].collect() 41 | humidity = main.prom_metrics[PromMetricId("mqtt_humidity", expected_label_keys)].collect() 42 | 43 | return temperatures, humidity 44 | 45 | 46 | def test_expose_metrics__default(mocker): 47 | """Tests with no client ID, client ID not exposed.""" 48 | settings.MQTT_EXPOSE_CLIENT_ID = False 49 | temperatures, humidity = _exec("", mocker, False) 50 | 51 | assert len(temperatures) == 1 52 | assert len(temperatures[0].samples) == 1 53 | assert temperatures[0].samples[0].value == 23.5 54 | assert temperatures[0].samples[0].labels == {"topic": "zigbee2mqtt_garage"} 55 | 56 | assert len(humidity) == 1 57 | assert len(humidity[0].samples) == 1 58 | assert humidity[0].samples[0].value == 40.5 59 | assert humidity[0].samples[0].labels == {"topic": "zigbee2mqtt_garage"} 60 | 61 | 62 | def test_expose_metrics__default_client_set_exposed(mocker): 63 | """Tests with client ID, client ID exposed.""" 64 | settings.MQTT_EXPOSE_CLIENT_ID = True 65 | temperatures, humidity = _exec("clienttestid", mocker, False) 66 | 67 | assert len(temperatures) == 1 68 | assert len(temperatures[0].samples) == 1 69 | assert temperatures[0].samples[0].value == 23.5 70 | assert temperatures[0].samples[0].labels == { 71 | "client_id": "clienttestid", 72 | "topic": "zigbee2mqtt_garage", 73 | } 74 | 75 | assert len(humidity) == 1 76 | assert len(humidity[0].samples) == 1 77 | assert humidity[0].samples[0].value == 40.5 78 | assert humidity[0].samples[0].labels == { 79 | "client_id": "clienttestid", 80 | "topic": "zigbee2mqtt_garage", 81 | } 82 | 83 | 84 | def test_expose_metrics__labels_from_user_properties(mocker): 85 | """Test that labels in UserProperty are added to metrics.""" 86 | settings.MQTT_EXPOSE_CLIENT_ID = False 87 | settings.MQTT_V5_PROTOCOL = True 88 | temperatures, humidity = _exec("clienttestid", mocker, True) 89 | 90 | expected_labels = { 91 | "topic": "zigbee2mqtt_garage", 92 | "label_key": "label_value", 93 | "WeirdLabelKey": "Weird Label Value", 94 | } 95 | 96 | assert len(temperatures) == 1 97 | assert temperatures[0].samples[0].labels == expected_labels 98 | 99 | assert len(humidity) == 1 100 | assert humidity[0].samples[0].labels == expected_labels 101 | -------------------------------------------------------------------------------- /tests/functional/test_parse_message.py: -------------------------------------------------------------------------------- 1 | """Functional tests of MQTT message parsing.""" 2 | 3 | from mqtt_exporter import settings 4 | from mqtt_exporter.main import _parse_message 5 | 6 | 7 | def test__parse_message__aqara_style(): 8 | """Test message parsing with Aqara style. 9 | 10 | Same format for SONOFF sensors. 11 | """ 12 | topic = "zigbee2mqtt/0x00157d00032b1234" 13 | payload = '{"temperature":26.24,"humidity":45.37}' 14 | 15 | parsed_topic, parsed_payload = _parse_message(topic, payload) 16 | 17 | assert parsed_topic == "zigbee2mqtt_0x00157d00032b1234" 18 | assert parsed_payload == {"temperature": 26.24, "humidity": 45.37} 19 | 20 | 21 | def test__parse_message__shelly_style_ht(): 22 | """Test message parsing with shelly style H&T.""" 23 | topic = "shellies/room/sensor/temperature" 24 | payload = b"20.00" 25 | 26 | parsed_topic, parsed_payload = _parse_message(topic, payload) 27 | 28 | assert parsed_topic == "shellies_room" 29 | assert parsed_payload == {"temperature": 20.00} 30 | 31 | 32 | def test__parse_message__shelly_style_3em(): 33 | """Test message parsing with shelly style (3EM).""" 34 | topic = "shellies/room/emeter/0/power" 35 | payload = b"1" 36 | 37 | settings.KEEP_FULL_TOPIC = True 38 | parsed_topic, parsed_payload = _parse_message(topic, payload) 39 | settings.KEEP_FULL_TOPIC = False 40 | 41 | assert parsed_topic == "shellies_room_emeter_0" 42 | assert parsed_payload == {"power": 1} 43 | 44 | 45 | def test__parse_message__generic_single_value_style(): 46 | """Test message parsing when payload is a single value (same as Shelly but more custom). 47 | 48 | This is similar to Shelly's style. 49 | """ 50 | topic = "dht/livingroom/TEMPERATURE" 51 | payload = "20.0" 52 | 53 | parsed_topic, parsed_payload = _parse_message(topic, payload) 54 | 55 | assert parsed_topic == "dht_livingroom" 56 | assert parsed_payload == {"TEMPERATURE": 20.0} 57 | 58 | 59 | def test_parse_message__nested(): 60 | """Test message parsing when nested payload.""" 61 | topic = "sensor/room" 62 | payload = '{ \ 63 | "Time": "2021-10-03T11:05:21", \ 64 | "ENERGY": { \ 65 | "Total": 152.657, \ 66 | "Yesterday": 1.758, \ 67 | "Today": 0.178, \ 68 | "Power": 143, \ 69 | "ApparentPower": 184, \ 70 | "ReactivePower": 117, \ 71 | "Factor": 0.77, \ 72 | "Voltage": 235, \ 73 | "Current": 0.784 \ 74 | } \ 75 | }' 76 | 77 | parsed_topic, parsed_payload = _parse_message(topic, payload) 78 | 79 | assert parsed_topic == "sensor_room" 80 | assert parsed_payload == { 81 | "Time": "2021-10-03T11:05:21", 82 | "ENERGY": { 83 | "Total": 152.657, 84 | "Yesterday": 1.758, 85 | "Today": 0.178, 86 | "Power": 143, 87 | "ApparentPower": 184, 88 | "ReactivePower": 117, 89 | "Factor": 0.77, 90 | "Voltage": 235, 91 | "Current": 0.784, 92 | }, 93 | } 94 | 95 | 96 | def test_parse_message__nested_with_dash_in_metric_name(): 97 | """Test message parsing when dash in metric name. 98 | 99 | seen with tasmota + multiple DS18B20 sensors. 100 | """ 101 | topic = "tele/balcony/SENSOR" 102 | payload = '{ \ 103 | "Time": "2022-07-01T21:21:17", \ 104 | "DS18B20-1": { \ 105 | "Id": "022EDB070007", \ 106 | "Temperature": 15.9 \ 107 | }, \ 108 | "DS18B20-2": { \ 109 | "Id": "0316A279C254", \ 110 | "Temperature": 6.9 \ 111 | }, \ 112 | "TempUnit": "C" \ 113 | }' 114 | 115 | parsed_topic, parsed_payload = _parse_message(topic, payload) 116 | 117 | assert parsed_topic == "tele_balcony_SENSOR" 118 | assert parsed_payload == { 119 | "Time": "2022-07-01T21:21:17", 120 | "DS18B20-1": {"Id": "022EDB070007", "Temperature": 15.9}, 121 | "DS18B20-2": {"Id": "0316A279C254", "Temperature": 6.9}, 122 | "TempUnit": "C", 123 | } 124 | 125 | 126 | def test_parse_message__zwave_js(): 127 | """Test parsing of ZWavejs2Mqtt message.""" 128 | topic = "zwave/BackRoom/Multisensor/sensor_multilevel/endpoint_0/Air_temperature" 129 | payload = '{"time":1656470510619,"value":83.2}' 130 | 131 | parsed_topic, parsed_payload = _parse_message(topic, payload) 132 | assert parsed_topic == "zwave_backroom_multisensor_sensor_multilevel_endpoint_0" 133 | assert parsed_payload == {"air_temperature": 83.2} 134 | 135 | 136 | def test_parse_message__zwave_js__payload_not_dict(): 137 | """Test parsing of ZWavejs2Mqtt message.""" 138 | topic = "zwave/BackRoom/Multisensor/sensor_multilevel/endpoint_0/Air_temperature" 139 | payload = "83.2" 140 | 141 | parsed_topic, parsed_payload = _parse_message(topic, payload) 142 | assert parsed_topic == "zwave_BackRoom_Multisensor_sensor_multilevel_endpoint_0_Air_temperature" 143 | assert parsed_payload == {} 144 | 145 | 146 | def test__parse_message__esphome_style(): 147 | """Test message parsing with esphome style. 148 | 149 | Same format for SONOFF sensors. 150 | """ 151 | settings.ESPHOME_TOPIC_PREFIXES = ["esp", "ESP"] 152 | topic = "esphome/outdoor/sensor/temperature/state" 153 | payload = "20.0" 154 | 155 | parsed_topic, parsed_payload = _parse_message(topic, payload) 156 | 157 | assert parsed_topic == "esphome_outdoor" 158 | assert parsed_payload == {"temperature": 20.0} 159 | 160 | topic = "ESPHOME/indoor/sensor/temperature/state" 161 | payload = "22.0" 162 | 163 | parsed_topic, parsed_payload = _parse_message(topic, payload) 164 | 165 | assert parsed_topic == "esphome_indoor" 166 | assert parsed_payload == {"temperature": 22.0} 167 | 168 | 169 | def test__parse_message__esphome_style__binary_sensor(): 170 | """Test message parsing with esphome style for a binary sensor.""" 171 | settings.ESPHOME_TOPIC_PREFIXES = ["esp", "ESP"] 172 | topic = "esphome/outdoor/binary_sensor/sunlight/state" 173 | payload = "ON" 174 | 175 | parsed_topic, parsed_payload = _parse_message(topic, payload) 176 | 177 | assert parsed_topic == "esphome_outdoor" 178 | assert parsed_payload == {"sunlight": 1.0} 179 | 180 | topic = "ESPHOME/indoor/binary_sensor/sunlight/state" 181 | payload = "OFF" 182 | 183 | parsed_topic, parsed_payload = _parse_message(topic, payload) 184 | 185 | assert parsed_topic == "esphome_indoor" 186 | assert parsed_payload == {"sunlight": 0.0} 187 | 188 | 189 | def test__parse_message__hubitat_style(): 190 | """Test message parsing with Hubitat style. 191 | 192 | It looks like: hubitat/[hubname]/[device name]/attributes/[attribute name]/value 193 | """ 194 | topic = "hubitat/hub1/some_room/attributes/temperature/value" 195 | payload = "20.0" 196 | 197 | parsed_topic, parsed_payload = _parse_message(topic, payload) 198 | 199 | assert parsed_topic == "hubitat_hub1_some_room" 200 | assert parsed_payload == {"temperature": 20.0} 201 | -------------------------------------------------------------------------------- /tests/functional/test_parse_metrics.py: -------------------------------------------------------------------------------- 1 | """Functional tests of metrics parsing.""" 2 | 3 | from mqtt_exporter import main 4 | from mqtt_exporter.main import PromMetricId, _parse_metrics 5 | 6 | 7 | def test_parse_metrics__nested_with_dash_in_metric_name(): 8 | """Test metrics parsing when dash in metric name. 9 | 10 | seen with tasmota + multiple DS18B20 sensors. 11 | 12 | refers to test_parse_message__nested_with_dash_in_metric_name() 13 | """ 14 | original_topic = "tele/balcony/SENSOR" 15 | parsed_topic = "tele_balcony_SENSOR" 16 | parsed_payload = { 17 | "Time": "2022-07-01T21:21:17", 18 | "DS18B20-1": {"Id": "022EDB070007", "Temperature": 15.9}, 19 | "DS18B20-2": {"Id": "0316A279C254", "Temperature": 6.9}, 20 | "TempUnit": "C", 21 | } 22 | 23 | _parse_metrics(parsed_payload, parsed_topic, original_topic, "dummy_client_id") 24 | 25 | 26 | def test_metrics_escaping(): 27 | """Verify that all keys are escaped properly.""" 28 | main.prom_metrics = {} 29 | original_topic = "test/topic" 30 | parsed_topic = "test_topic" 31 | parsed_payload = { 32 | "test_value/a": 42, 33 | "test_value-b": 37, 34 | "test_value c": 13, 35 | } 36 | # pylama: ignore=W0212 37 | main._parse_metrics(parsed_payload, parsed_topic, original_topic, "dummy_client_id") 38 | 39 | assert PromMetricId("mqtt_test_value_a") in main.prom_metrics 40 | assert PromMetricId("mqtt_test_value_b") in main.prom_metrics 41 | assert PromMetricId("mqtt_test_value_c") in main.prom_metrics 42 | 43 | 44 | def test_parse_metrics__value_is_list(): 45 | """Verify if list recursion works properly.""" 46 | main.prom_metrics = {} 47 | original_topic = "test/topic" 48 | parsed_topic = "test_topic" 49 | parsed_payload = {"test_value": [1, 2]} 50 | main._parse_metrics(parsed_payload, parsed_topic, original_topic, "dummy_client_id") 51 | assert PromMetricId("mqtt_test_value_0") in main.prom_metrics 52 | assert PromMetricId("mqtt_test_value_1") in main.prom_metrics 53 | -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- 1 | """Unit tests.""" 2 | -------------------------------------------------------------------------------- /tests/unit/test_normalize_prometheus_name.py: -------------------------------------------------------------------------------- 1 | """Tests of Prometheus normalization metrics.""" 2 | 3 | import pytest 4 | 5 | from mqtt_exporter.main import ( 6 | _normalize_prometheus_metric_label_name, 7 | _normalize_prometheus_metric_name, 8 | ) 9 | 10 | 11 | def test_normalize_prometheus_metric_name(): 12 | """Test _normalize_prometheus_metric_name.""" 13 | tests = { 14 | "1234invalid": ":1234invalid", 15 | "valid1234": "valid1234", 16 | "_this_is_valid": "_this_is_valid", 17 | "not_so_valid%_name": "not_so_valid_name", 18 | } 19 | 20 | for candidate, wanted in tests.items(): 21 | assert _normalize_prometheus_metric_name(candidate) == wanted 22 | 23 | 24 | @pytest.mark.parametrize( 25 | "candidate, wanted", 26 | [ 27 | ("1234invalid", "_1234invalid"), 28 | ("valid1234", "valid1234"), 29 | ("_this_is_valid", "_this_is_valid"), 30 | ("not_so_valid%_name", "not_so_valid_name"), 31 | ("__using_reserved_prefix", "_using_reserved_prefix"), 32 | ("1_start_with_number", "_1_start_with_number"), 33 | ("%start_with_invalid_char", "start_with_invalid_char"), 34 | ("%__start_with_invalid_char", "_start_with_invalid_char"), 35 | ("_%_start_with_invalid_char", "_start_with_invalid_char"), 36 | ], 37 | ) 38 | def test_normalize_prometheus_metric_label_name(candidate, wanted): 39 | """Test _normalize_prometheus_metric_label_name.""" 40 | assert _normalize_prometheus_metric_label_name(candidate) == wanted 41 | --------------------------------------------------------------------------------