├── .dockerignore ├── requirements.txt ├── run ├── install ├── systemd └── knx2mqtt.service ├── supervisor └── knx2mqtt.conf ├── Dockerfile ├── .github └── workflows │ └── build.yml ├── .forgejo └── workflows │ └── docker-build.yaml ├── knx2mqtt.conf.example ├── LICENSE.md ├── .gitignore ├── README.md └── knx2mqtt /.dockerignore: -------------------------------------------------------------------------------- 1 | * 2 | !knx2mqtt 3 | !requirements.txt 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # Python requirements 2 | paho-mqtt 3 | xknx 4 | -------------------------------------------------------------------------------- /run: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # Run script for knx2mqtt 4 | # 5 | # (c) Gerrit Beine, 2019-2023 6 | # 7 | 8 | . venv/bin/activate 9 | 10 | exec /usr/bin/env python knx2mqtt 11 | -------------------------------------------------------------------------------- /install: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # Installation script for knx2mqtt and its dependencies 4 | # 5 | # (c) Gerrit Beine, 2019-2022 6 | # 7 | 8 | python3 -m venv venv 9 | 10 | . venv/bin/activate 11 | 12 | pip install --upgrade pip 13 | pip install -r requirements.txt 14 | 15 | -------------------------------------------------------------------------------- /systemd/knx2mqtt.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=knx2mqtt bridge daemon 3 | Documentation=https://github.com/gbeine/knx2mqtt 4 | After=network.target 5 | 6 | [Service] 7 | ExecStart=/usr/local/bin/knx2mqtt 8 | 9 | [Install] 10 | WantedBy=multi-user.target 11 | Alias=knx2mqtt.service 12 | -------------------------------------------------------------------------------- /supervisor/knx2mqtt.conf: -------------------------------------------------------------------------------- 1 | ; 2 | ; Copy or link this file to /etc/supervisor/conf.d 3 | ; 4 | [program:knx2mqtt] 5 | command=/opt/service/knx2mqtt/run 6 | process_name=%(program_name)s 7 | directory=/opt/service/knx2mqtt 8 | umask=022 9 | autostart=true 10 | redirect_stderr=true 11 | stdout_logfile=/var/log/knx2mqtt/main.log 12 | stdout_logfile_maxbytes=2MB 13 | stdout_logfile_backups=1 14 | stdout_capture_maxbytes=0 15 | stdout_events_enabled=false 16 | environment=LOGDIR=/var/log/knx2mqtt 17 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.12-alpine as builder 2 | 3 | RUN set -eux; \ 4 | apk add --no-cache \ 5 | gcc \ 6 | libc-dev \ 7 | libffi-dev \ 8 | openssl-dev \ 9 | cargo 10 | 11 | WORKDIR /app 12 | 13 | COPY requirements.txt ./ 14 | RUN pip install --no-cache-dir -r requirements.txt 15 | 16 | FROM python:3.12-alpine 17 | 18 | WORKDIR /app 19 | 20 | COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages 21 | COPY knx2mqtt . 22 | 23 | CMD [ "python", "./knx2mqtt" ] 24 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | 6 | jobs: 7 | docker: 8 | if: false 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Set up QEMU 12 | uses: docker/setup-qemu-action@v3 13 | 14 | - name: Set up Docker Buildx 15 | uses: docker/setup-buildx-action@v3 16 | 17 | - name: Login to Docker Hub 18 | uses: docker/login-action@v3 19 | with: 20 | username: ${{ vars.DOCKERHUB_USERNAME }} 21 | password: ${{ secrets.DOCKERHUB_TOKEN }} 22 | 23 | - name: Build and push 24 | uses: docker/build-push-action@v6 25 | with: 26 | platforms: linux/amd64,linux/arm64,linux/arm/v6,linux/arm/v7 27 | push: true 28 | tags: gbeine/knx2mqtt:latest 29 | -------------------------------------------------------------------------------- /.forgejo/workflows/docker-build.yaml: -------------------------------------------------------------------------------- 1 | name: docker-build 2 | 3 | on: 4 | push: 5 | 6 | jobs: 7 | build: 8 | runs-on: docker 9 | container: 10 | image: node:22-alpine 11 | env: 12 | DOCKER_HOST: ${{ vars.DOCKER_HOST }} 13 | steps: 14 | - name: Install docker 15 | run: | 16 | apk add --no-cache docker 17 | 18 | - name: Set up QEMU 19 | uses: docker/setup-qemu-action@v3 20 | 21 | - name: Set up Docker Buildx 22 | uses: docker/setup-buildx-action@v3 23 | 24 | - name: Login to Docker Hub 25 | uses: docker/login-action@v3 26 | with: 27 | registry: c0d3.sh 28 | username: ${{ vars.C0D3SH_USERNAME }} 29 | password: ${{ secrets.C0D3SH_TOKEN }} 30 | 31 | - name: Build and push 32 | uses: docker/build-push-action@v6 33 | with: 34 | platforms: linux/amd64,linux/arm64,linux/arm/v6,linux/arm/v7 35 | push: true 36 | tags: c0d3.sh/smarthome/knx2mqtt:latest 37 | secrets: | 38 | GIT_AUTH_TOKEN=${{ secrets.C0D3SH_TOKEN }} 39 | -------------------------------------------------------------------------------- /knx2mqtt.conf.example: -------------------------------------------------------------------------------- 1 | { 2 | "mqtt_host": "localhost", 3 | "mqtt_port": "1883", 4 | "mqtt_keepalive": "300", 5 | "mqtt_clientid": "knx2mqtt", 6 | "mqtt_user": "knx2mqtt", 7 | "mqtt_password": "t0p_s3cr3t", 8 | "mqtt_topic": "bus/knx", 9 | "mqtt_tls": "false", 10 | "mqtt_tls_version": "TLSv1.2", 11 | "mqtt_verify_mode": "CERT_NONE", 12 | "mqtt_ssl_ca_path": "/etc/ssl/myca.pem", 13 | "mqtt_tls_no_verify": "false", 14 | "knx_host": "10.0.0.11", 15 | "knx_local_ip": "10.0.7.12", 16 | "knx_individual_address": "15.15.250", 17 | "knx_no_queue": "true", 18 | "verbose": "false", 19 | "items": [ 20 | { 21 | "address": "4/0/11", 22 | "type": "DPTBinary", 23 | "mqtt_subscribe": "true" 24 | }, 25 | { 26 | "address": "4/0/21", 27 | "type": "DPTUpDown" 28 | }, 29 | { 30 | "address": "5/0/11", 31 | "type": "DPTTemperature" 32 | }, 33 | { 34 | "address": "5/0/17", 35 | "type": "DPTHumidity" 36 | }, 37 | { 38 | "address": "5/0/18", 39 | "type": "DPTPartsPerMillion" 40 | } 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016-2024, Gerrit Beine 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | * Neither the name of fronius2mqtt nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Python template 2 | # Byte-compiled / optimized / DLL files 3 | __pycache__/ 4 | *.py[cod] 5 | *$py.class 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | cover/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | .pybuilder/ 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # IPython 83 | profile_default/ 84 | ipython_config.py 85 | 86 | # pyenv 87 | # For a library or package, you might want to ignore these files since the code is 88 | # intended to run in multiple environments; otherwise, check them in: 89 | # .python-version 90 | 91 | # pipenv 92 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 93 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 94 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 95 | # install all needed dependencies. 96 | #Pipfile.lock 97 | 98 | # poetry 99 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 100 | # This is especially recommended for binary packages to ensure reproducibility, and is more 101 | # commonly ignored for libraries. 102 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 103 | #poetry.lock 104 | 105 | # pdm 106 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 107 | #pdm.lock 108 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 109 | # in version control. 110 | # https://pdm.fming.dev/#use-with-ide 111 | .pdm.toml 112 | 113 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 114 | __pypackages__/ 115 | 116 | # Celery stuff 117 | celerybeat-schedule 118 | celerybeat.pid 119 | 120 | # SageMath parsed files 121 | *.sage.py 122 | 123 | # Environments 124 | .env 125 | .venv 126 | env/ 127 | venv/ 128 | ENV/ 129 | env.bak/ 130 | venv.bak/ 131 | 132 | # Spyder project settings 133 | .spyderproject 134 | .spyproject 135 | 136 | # Rope project settings 137 | .ropeproject 138 | 139 | # mkdocs documentation 140 | /site 141 | 142 | # mypy 143 | .mypy_cache/ 144 | .dmypy.json 145 | dmypy.json 146 | 147 | # Pyre type checker 148 | .pyre/ 149 | 150 | # pytype static type analyzer 151 | .pytype/ 152 | 153 | # Cython debug symbols 154 | cython_debug/ 155 | 156 | # PyCharm 157 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 158 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 159 | # and can be added to the global gitignore or merged into this file. For a more nuclear 160 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 161 | .idea/ 162 | 163 | # Local configurations 164 | *.conf 165 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # knx2mqtt - A KNX2MQTT Bridge allowing bidirectional telegram transfer 2 | 3 | I've created this project as a replacement for the KNX integration of [HomeAssistant](https://home-assistant.io/) that worked not stable in my environment. 4 | 5 | It is quite simple and does what it's name says: It works as a bridge between KNX and MQTT translating messages between these in both directions. 6 | 7 | ## Installation 8 | 9 | ### Installation using Docker 10 | 11 | ``` 12 | docker run -it --rm --name knx2mqtt -v knx2mqtt.conf:/etc/knx2mqtt.conf docker.io/gbeine/knx2mqtt 13 | ``` 14 | 15 | ### Native installation with Python venv 16 | 17 | The installation requires at least Python 3.9. 18 | On Raspbian, it is required to install rustc because xknx depends on cryptography which cannot be built without rust. 19 | 20 | See [Install Rust](https://www.rust-lang.org/tools/install) for details. 21 | 22 | Philosophy is to install it under /usr/local/lib/knx2mqtt and control it via systemd. 23 | 24 | ``` 25 | cd /usr/local/lib 26 | git clone https://github.com/gbeine/knx2mqtt.git 27 | cd knx2mqtt 28 | ./install 29 | ``` 30 | 31 | The `install` script creates a virtual python environment using the `venv` module. 32 | All required libraries are installed automatically. 33 | Depending on your system this may take some time. 34 | 35 | ## Configuration 36 | 37 | The configuration is located in `/etc/knx2mqtt.conf`. 38 | 39 | Each configuration option is also available as command line argument. 40 | 41 | - copy `knx2mqtt.conf.example` 42 | - configure as you like 43 | 44 | | option | default | arguments | comment | 45 | |--------------------------|----------------------|----------------------------|----------------------------------------------------------------------------------------| 46 | | `mqtt_host` | 'localhost' | `-m`, `--mqtt_host` | The hostname of the MQTT server. | 47 | | `mqtt_port` | 1883 | `--mqtt_port` | The port of the MQTT server. | 48 | | `mqtt_keepalive` | 30 | `--mqtt_keepalive` | The keep alive interval for the MQTT server connection in seconds. | 49 | | `mqtt_clientid` | 'knx2mqtt' | `--mqtt_clientid` | The clientid to send to the MQTT server. | 50 | | `mqtt_user` | - | `-u`, `--mqtt_user` | The username for the MQTT server connection. | 51 | | `mqtt_password` | - | `-p`, `--mqtt_password` | The password for the MQTT server connection. | 52 | | `mqtt_topic` | 'bus/knx' | `-t`, `--mqtt_topic` | The topic to publish MQTT message. | 53 | | `mqtt_tls` | - | `--mqtt_tls` | Use SSL/TLS encryption for MQTT connection. | 54 | | `mqtt_tls_version` | 'TLSv1.2' | `--mqtt_tls_version` | The TLS version to use for MQTT. One of TLSv1, TLSv1.1, TLSv1.2. | 55 | | `mqtt_verify_mode` | 'CERT_REQUIRED' | `--mqtt_verify_mode` | The SSL certificate verification mode. One of CERT_NONE, CERT_OPTIONAL, CERT_REQUIRED. | 56 | | `mqtt_ssl_ca_path` | - | `--mqtt_ssl_ca_path` | The SSL certificate authority file to verify the MQTT server. | 57 | | `mqtt_tls_no_verify` | - | `--mqtt_tls_no_verify` | Do not verify SSL/TLS constraints like hostname. | 58 | | `knx_host` | 'localhost' | `--knx_host` | The address of the KNX tunnel device. | 59 | | `knx_port` | 3671 | `--knx_port` | The port of the KNX tunnel device. | 60 | | `knx_local_ip` | - | `--knx_local_ip` | The ip address of the system that connects to KNX. | 61 | | `knx_individual_address` | - | `--knx_individual_address` | The group address of the system that send telegrams to KNX. | 62 | | `knx_no_queue` | - | `--knx_no_queue` | Workaround for scheduling problems of XKNX telegram queue. | 63 | | `timestamp` | - | `-z`, `--timestamp` | Publish timestamps for all topics, e.g. for monitoring purposes. | 64 | | `verbose` | - | `-v`, `--verbose` | Be verbose while running. | 65 | | - | '/etc/knx2mqtt.conf' | `-c`, `--config` | The path to the config file. | 66 | | `items` | see below | - | The configuration for the items on the KNX bus. | 67 | 68 | ### KNX 69 | 70 | Currently, only KNX tunneling mode is supported. 71 | It may become more in the future, if I found testing environments with according setups. 72 | Feel free to add routing or other options and open a pull request for this. 73 | 74 | ### Items 75 | 76 | Then you can configure your bus topology as items. 77 | 78 | ``` 79 | ... 80 | "items": [ 81 | { 82 | "address": "5/0/10", 83 | "type": "DPTTemperature" 84 | }, 85 | { 86 | "address": "5/0/20", 87 | "type": "DPTHumidity" 88 | }, 89 | ... 90 | ] 91 | ... 92 | ``` 93 | 94 | Each item need an `address` (the group address) and a `type`. 95 | Unfortunately, the list of types is not part of the xknx documentation. 96 | But the examples in the file I provide with the project may fit for the most purposes. 97 | All supported types can be found in the [xknx sources](https://github.com/XKNX/xknx/blob/main/xknx/dpt/__init__.py). 98 | 99 | The default operating mode for an object is to listen on the KNX and publish the telegram values to MQTT. 100 | 101 | That may be changed using the following settings: 102 | 103 | * `mqtt_subscribe` (default: false): if set to `true`, changes on any related MQTT topic will be processed 104 | 105 | ### Publishing 106 | 107 | All values are published using the group address and the MQTT topic. 108 | 109 | So, the Date exposing sensor in the example is listening for `bus/knx/0/0/1` and the switch is listening on and publishing to `bus/knx/0/1/1`. 110 | 111 | ## Running knx2mqtt 112 | 113 | I use [systemd](https://systemd.io/) to manage my local services. 114 | 115 | ## Support 116 | 117 | I have not the time (yet) to provide professional support for this project. 118 | But feel free to submit issues and PRs, I'll check for it and honor your contributions. 119 | 120 | ## License 121 | 122 | The whole project is licensed under BSD-3-Clause license. Stay fair. 123 | -------------------------------------------------------------------------------- /knx2mqtt: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import argparse 4 | import asyncio 5 | import importlib 6 | import json 7 | import logging 8 | import os 9 | import signal 10 | import socket 11 | import ssl 12 | import sys 13 | import time 14 | import traceback 15 | 16 | import paho.mqtt.client as mqtt 17 | 18 | from xknx import XKNX 19 | from xknx.dpt.dpt import DPTArray, DPTBinary 20 | from xknx.io.knxip_interface import ConnectionType, ConnectionConfig 21 | from xknx.telegram.address import GroupAddress, IndividualAddress 22 | from xknx.telegram.apci import GroupValueWrite 23 | from xknx.telegram import Telegram, TelegramDirection 24 | 25 | 26 | XKNX_DPT_MODULE_STR = "xknx.dpt" 27 | 28 | verify_mode = { 29 | 'CERT_NONE': ssl.CERT_NONE, 30 | 'CERT_OPTIONAL': ssl.CERT_OPTIONAL, 31 | 'CERT_REQUIRED': ssl.CERT_REQUIRED 32 | } 33 | 34 | tls_versions = { 35 | 'TLSv1': ssl.PROTOCOL_TLSv1, 36 | 'TLSv1.1': ssl.PROTOCOL_TLSv1_1, 37 | 'TLSv1.2': ssl.PROTOCOL_TLSv1_2 38 | } 39 | 40 | 41 | knx_tunnel = None 42 | mqtt_client = None 43 | daemon_args = None 44 | item_states = None 45 | 46 | 47 | def eprint(*args, **kwargs): 48 | print(*args, file=sys.stderr, **kwargs) 49 | 50 | 51 | def get_dpt_type_for_address(group_address): 52 | global daemon_args 53 | 54 | dpt_type = None 55 | 56 | if group_address in daemon_args.dpt_types: 57 | dpt_type = daemon_args.dpt_types[group_address] 58 | 59 | return dpt_type 60 | 61 | 62 | def extract_payload_from_telegram(group_address, telegram): 63 | dpt_type = get_dpt_type_for_address(group_address) 64 | payload = telegram.payload 65 | 66 | logging.info("Address: {}, DPT Type: {}, Payload: {}".format(group_address, dpt_type, payload)) 67 | 68 | value = None 69 | 70 | try: 71 | if dpt_type == 'DPTBinary': 72 | value = int(bool(payload.value.value)) 73 | else: 74 | if dpt_type is None: 75 | dpt_type = payload.value.__class__.__name__ 76 | dpt_class = getattr(importlib.import_module(XKNX_DPT_MODULE_STR), dpt_type) 77 | value = dpt_class.from_knx(payload.value) 78 | except Exception as e: 79 | eprint(traceback.format_exc()) 80 | 81 | return value 82 | 83 | 84 | def create_payload_for_telegram(group_address, value): 85 | dpt_type = get_dpt_type_for_address(group_address) 86 | 87 | logging.info("Address: {}, DPT Type: {}, Value: {}".format(group_address, dpt_type, value)) 88 | 89 | payload = None 90 | 91 | try: 92 | if dpt_type == 'DPTBinary': 93 | payload = DPTBinary(int(str(value).lower() in ['true', '1', 'on', 'yes'])) 94 | else: 95 | dpt_class = getattr(importlib.import_module(XKNX_DPT_MODULE_STR), dpt_type) 96 | payload = dpt_class.to_knx(value) 97 | except Exception as e: 98 | eprint(traceback.format_exc()) 99 | 100 | return payload 101 | 102 | 103 | def publish_to_knx(address, payload): 104 | global daemon_args, knx_tunnel 105 | 106 | logging.info("Address: {}, Payload: {}".format(address, payload)) 107 | 108 | source_address = IndividualAddress(daemon_args.knx_individual_address) 109 | group_address = GroupAddress(address) 110 | group_value = GroupValueWrite(payload) 111 | 112 | telegram = Telegram( 113 | destination_address=group_address, 114 | direction=TelegramDirection.OUTGOING, 115 | payload=group_value, 116 | source_address=source_address 117 | ) 118 | 119 | if knx_tunnel.started: 120 | if daemon_args.knx_no_queue: 121 | asyncio.run(knx_tunnel.telegram_queue.process_telegram_outgoing(telegram)) 122 | else: 123 | asyncio.run(knx_tunnel.telegrams.put(telegram)) 124 | 125 | 126 | def publish_to_mqtt(address, value): 127 | global mqtt_client, daemon_args, item_states 128 | 129 | topic = "{}/{}".format(daemon_args.mqtt_topic, address) 130 | 131 | item_states[address] = str(value) 132 | 133 | logging.info("Topic: {}, Payload: {}".format(topic, value)) 134 | mqtt_client.publish(topic, str(value)) 135 | if daemon_args.timestamp: 136 | mqtt_client.publish("{}/timestamp".format(topic), time.time(), retain=True) 137 | 138 | 139 | def on_telegram_received(telegram): 140 | try: 141 | if telegram.direction != TelegramDirection.INCOMING: 142 | return 143 | group_address = str(telegram.destination_address) 144 | payload = extract_payload_from_telegram(group_address, telegram) 145 | 146 | publish_to_mqtt(group_address, payload) 147 | except Exception as e: 148 | eprint(traceback.format_exc()) 149 | 150 | 151 | def on_mqtt_connect(client, userdata, flags, reason_code, properties): 152 | global daemon_args 153 | 154 | for address in daemon_args.mqtt_subscribe: 155 | topic = "{}/{}".format(daemon_args.mqtt_topic, address) 156 | logging.info("Subscribe: {}".format(topic)) 157 | client.subscribe(topic) 158 | 159 | 160 | def on_mqtt_received(client, userdata, message): 161 | global daemon_args, item_states 162 | 163 | try: 164 | group_address = message.topic.replace("{}/".format(daemon_args.mqtt_topic), '') 165 | value = str(message.payload.decode()) 166 | 167 | if item_states[group_address] == value: 168 | logging.info("Received value for {} is last state sent: {}".format(group_address, value)) 169 | return 170 | 171 | payload = create_payload_for_telegram(group_address, value) 172 | 173 | publish_to_knx(group_address, payload) 174 | except Exception as e: 175 | eprint(traceback.format_exc()) 176 | 177 | 178 | def init_mqtt(): 179 | logging.debug('Starting MQTT') 180 | global daemon_args 181 | mqtt_client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, daemon_args.mqtt_clientid) 182 | if daemon_args.mqtt_tls: 183 | cert_reqs = verify_mode[daemon_args.mqtt_verify_mode] if daemon_args.mqtt_verify_mode in verify_mode else None 184 | tls_version = tls_versions[daemon_args.mqtt_tls_version] if daemon_args.mqtt_tls_version in tls_versions else None 185 | ca_certs = daemon_args.mqtt_ssl_ca_path if daemon_args.mqtt_ssl_ca_path else None 186 | mqtt_client.tls_set(ca_certs=ca_certs, cert_reqs=cert_reqs, tls_version=tls_version) 187 | mqtt_client.tls_insecure_set(daemon_args.mqtt_tls_no_verify) 188 | if daemon_args.verbose: 189 | mqtt_client.enable_logger() 190 | if daemon_args.mqtt_user is not None and daemon_args.mqtt_password is not None: 191 | mqtt_client.username_pw_set(daemon_args.mqtt_user, daemon_args.mqtt_password) 192 | mqtt_client.on_connect = on_mqtt_connect 193 | mqtt_client.on_message = on_mqtt_received 194 | mqtt_client.connect(daemon_args.mqtt_host, daemon_args.mqtt_port, daemon_args.mqtt_keepalive) 195 | return mqtt_client 196 | 197 | 198 | def start_knx(): 199 | global daemon_args 200 | 201 | config = ConnectionConfig( 202 | connection_type=ConnectionType.TUNNELING, 203 | local_ip=daemon_args.knx_local_ip, 204 | gateway_ip=socket.gethostbyname(daemon_args.knx_host), 205 | gateway_port=daemon_args.knx_port 206 | ) 207 | 208 | async def run(): 209 | global knx_tunnel 210 | knx_tunnel = XKNX(daemon_mode=True, connection_config=config) 211 | knx_tunnel.telegram_queue.register_telegram_received_cb( 212 | telegram_received_cb=on_telegram_received, 213 | group_addresses=daemon_args.knx_subscribe 214 | ) 215 | await knx_tunnel.start() 216 | await knx_tunnel.stop() 217 | 218 | asyncio.run(run()) 219 | 220 | 221 | def parse_args(): 222 | parser = argparse.ArgumentParser( 223 | prog='knx2mqtt', 224 | description='A KNX to MQTT bridge', 225 | epilog='Have a lot of fun!') 226 | parser.add_argument('-m', '--mqtt_host', type=str, 227 | default='localhost', 228 | help='The hostname of the MQTT server. Default is localhost') 229 | parser.add_argument('--mqtt_port', type=int, 230 | default=1883, 231 | help='The port of the MQTT server. Default is 1883') 232 | parser.add_argument('--mqtt_keepalive', type=int, 233 | default=30, 234 | help='The keep alive interval for the MQTT server connection in seconds. Default is 30') 235 | parser.add_argument('--mqtt_clientid', type=str, 236 | default='knx2mqtt', 237 | help='The clientid to send to the MQTT server. Default is knx2mqtt') 238 | parser.add_argument('-u', '--mqtt_user', type=str, 239 | help='The username for the MQTT server connection.') 240 | parser.add_argument('-p', '--mqtt_password', type=str, 241 | help='The password for the MQTT server connection.') 242 | parser.add_argument('-t', '--mqtt_topic', type=str, 243 | default='bus/knx', 244 | help='The topic to publish MQTT message. Default is bus/knx') 245 | parser.add_argument('--mqtt_tls', 246 | default=False, 247 | action='store_true', 248 | help='Use SSL/TLS encryption for MQTT connection.') 249 | parser.add_argument('--mqtt_tls_version', type=str, 250 | default='TLSv1.2', 251 | help='The TLS version to use for MQTT. One of TLSv1, TLSv1.1, TLSv1.2. Default is TLSv1.2') 252 | parser.add_argument('--mqtt_verify_mode', type=str, 253 | default='CERT_REQUIRED', 254 | help='The SSL certificate verification mode. One of CERT_NONE, CERT_OPTIONAL, CERT_REQUIRED. Default is CERT_REQUIRED') 255 | parser.add_argument('--mqtt_ssl_ca_path', type=str, 256 | help='The SSL certificate authority file to verify the MQTT server.') 257 | parser.add_argument('--mqtt_tls_no_verify', 258 | default=False, 259 | action='store_true', 260 | help='Do not verify SSL/TLS constraints like hostname.') 261 | parser.add_argument('-k', '--knx_host', type=str, 262 | default='localhost', 263 | help='The hostname of the KNX gateway. Default is localhost') 264 | parser.add_argument('--knx_port', type=int, 265 | default=3671, 266 | help='The port of the KNX gateway. Default is 3671') 267 | parser.add_argument('--knx_local_ip', type=str, 268 | help='The local ip address to connect the KNX gateway.') 269 | parser.add_argument('--knx_individual_address', type=str, 270 | default='15.15.248', 271 | help='The group address to send KNX telegrams from. Default is 15.15.248') 272 | parser.add_argument('--knx_no_queue', 273 | default=False, 274 | action='store_true', 275 | help='Disable XKNX telegram queuing.') 276 | parser.add_argument('-c', '--config', type=str, 277 | default='/etc/knx2mqtt.conf', 278 | help='The path to the config file. Default is /etc/knx2mqtt.conf') 279 | parser.add_argument('-z', '--timestamp', 280 | default=False, 281 | action='store_true', 282 | help='Publish timestamps for all topics, e.g. for monitoring purposes.') 283 | parser.add_argument('-v', '--verbose', 284 | default=False, 285 | action='store_true', 286 | help='Be verbose while running.') 287 | args = parser.parse_args() 288 | return args 289 | 290 | 291 | def parse_config(): 292 | global daemon_args 293 | 294 | daemon_args.items = [] 295 | 296 | if not os.path.isfile(daemon_args.config): 297 | return 298 | 299 | with open(daemon_args.config, "r") as config_file: 300 | data = json.load(config_file) 301 | if 'mqtt_host' in data: 302 | daemon_args.mqtt_host = data['mqtt_host'] 303 | if 'mqtt_port' in data: 304 | daemon_args.mqtt_port = int(data['mqtt_port']) 305 | if 'mqtt_keepalive' in data: 306 | daemon_args.mqtt_keepalive = int(data['mqtt_keepalive']) 307 | if 'mqtt_clientid' in data: 308 | daemon_args.mqtt_clientid = data['mqtt_clientid'] 309 | if 'mqtt_user' in data: 310 | daemon_args.mqtt_user = data['mqtt_user'] 311 | if 'mqtt_password' in data: 312 | daemon_args.mqtt_password = data['mqtt_password'] 313 | if 'mqtt_topic' in data: 314 | daemon_args.mqtt_topic = data['mqtt_topic'] 315 | if 'mqtt_tls' in data: 316 | daemon_args.mqtt_tls = data['mqtt_tls'].lower() == 'true' 317 | if 'mqtt_tls_version' in data: 318 | daemon_args.mqtt_tls_version = data['mqtt_tls_version'] 319 | if 'mqtt_verify_mode' in data: 320 | daemon_args.mqtt_verify_mode = data['mqtt_verify_mode'] 321 | if 'mqtt_ssl_ca_path' in data: 322 | daemon_args.mqtt_ssl_ca_path = data['mqtt_ssl_ca_path'] 323 | if 'mqtt_tls_no_verify' in data: 324 | daemon_args.mqtt_tls_no_verify = data['mqtt_tls_no_verify'].lower() == 'true' 325 | if 'knx_host' in data: 326 | daemon_args.knx_host = data['knx_host'] 327 | if 'knx_port' in data: 328 | daemon_args.knx_port = int(data['knx_port']) 329 | if 'knx_local_ip' in data: 330 | daemon_args.knx_local_ip = data['knx_local_ip'] 331 | if 'knx_individual_address' in data: 332 | daemon_args.knx_individual_address = data['knx_individual_address'] 333 | if 'knx_no_queue' in data: 334 | daemon_args.knx_no_queue = data['knx_no_queue'].lower() == 'true' 335 | if 'timestamp' in data: 336 | daemon_args.timestamp = data['timestamp'].lower() == 'true' 337 | if 'verbose' in data: 338 | daemon_args.verbose = data['verbose'].lower() == 'true' 339 | if 'items' in data: 340 | daemon_args.items = data['items'] 341 | 342 | 343 | def init_items(): 344 | global daemon_args, item_states 345 | 346 | daemon_args.dpt_types = {} 347 | daemon_args.mqtt_subscribe = [] 348 | daemon_args.knx_subscribe = [] 349 | item_states = {} 350 | 351 | for item in daemon_args.items: 352 | if not 'knx_subscribe' in item: 353 | item['knx_subscribe'] = True 354 | if not 'mqtt_subscribe' in item: 355 | item['mqtt_subscribe'] = False 356 | 357 | if 'address' in item: 358 | item_states[item['address']] = None 359 | 360 | if 'type' in item: 361 | daemon_args.dpt_types[item['address']] = item['type'] 362 | if item['mqtt_subscribe']: 363 | daemon_args.mqtt_subscribe.append(item['address']) 364 | if item['knx_subscribe']: 365 | daemon_args.knx_subscribe.append(GroupAddress(item['address'])) 366 | 367 | 368 | def shutdown(signum, frame): 369 | global mqtt_client, knx_tunnel 370 | logging.info('Shutdown...') 371 | if mqtt_client is not None: 372 | logging.info('Stopping MQTT') 373 | mqtt_client.loop_stop() 374 | mqtt_client.disconnect() 375 | logging.info('Bye!') 376 | exit(0) 377 | 378 | def main(): 379 | global daemon_args, mqtt_client 380 | 381 | signal.signal(signal.SIGINT, shutdown) 382 | signal.signal(signal.SIGTERM, shutdown) 383 | 384 | # Configuration 385 | daemon_args = parse_args() 386 | parse_config() 387 | init_items() 388 | 389 | # Verbosity 390 | if daemon_args.verbose: 391 | logging.basicConfig(level=logging.DEBUG) 392 | 393 | # MQTT connection 394 | mqtt_client = init_mqtt() 395 | mqtt_client.loop_start() 396 | 397 | # KNX connection 398 | start_knx() 399 | 400 | 401 | if __name__ == "__main__": 402 | main() 403 | --------------------------------------------------------------------------------