diff --git a/.dockerignore b/.dockerignore index ab86a0a..e02a684 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,27 +1,27 @@ -**/__pycache__ -**/.mypy_cache -**/.classpath -**/.dockerignore -**/.env -**/.git -**/.gitignore -**/.project -**/.settings -**/.toolstarget -**/.vs -**/.vscode -**/*.*proj.user -**/*.dbmdl -**/*.jfm -**/azds.yaml -**/bin -**/charts -**/docker-compose* -**/Dockerfile* -**/node_modules -**/npm-debug.log -**/obj -**/secrets.dev.yaml -**/values.dev.yaml -README.md +**/__pycache__ +**/.mypy_cache +**/.classpath +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +README.md .env \ No newline at end of file diff --git a/.env.sample b/.env.sample index 1b80a84..ea5a2e1 100644 --- a/.env.sample +++ b/.env.sample @@ -1,5 +1,5 @@ -ATAG_HOST= -MQTT_HOST= -MQTT_PORT=1883 -# MQTT_USERNAME= +ATAG_HOST= +MQTT_HOST= +MQTT_PORT=1883 +# MQTT_USERNAME= # MQTT_PASSWORD= \ No newline at end of file diff --git a/.github/workflows/dockerpublish.yml b/.github/workflows/dockerpublish.yml index 9e80e76..7a0476c 100644 --- a/.github/workflows/dockerpublish.yml +++ b/.github/workflows/dockerpublish.yml @@ -1,76 +1,76 @@ -name: Docker - -on: - push: - # Publish `master` as Docker `latest` image. - branches: - - master - - # Publish `v1.2.3` tags as releases. - tags: - - v* - - # Run tests for any PRs. - pull_request: - -env: - # TODO: Change variable to your image's name. - IMAGE_NAME: image - -jobs: - # Run tests. - # See also https://docs.docker.com/docker-hub/builds/automated-testing/ - test: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v2 - - - name: Run tests - run: | - if [ -f docker-compose.test.yml ]; then - docker-compose --file docker-compose.test.yml build - docker-compose --file docker-compose.test.yml run sut - else - docker build . --file Dockerfile - fi - - # Push image to GitHub Packages. - # See also https://docs.docker.com/docker-hub/builds/ - push: - # Ensure test job passes before pushing image. - needs: test - - runs-on: ubuntu-latest - if: github.event_name == 'push' - - steps: - - uses: actions/checkout@v2 - - - name: Build image - run: docker build . --file Dockerfile --tag $IMAGE_NAME - - - name: Log into registry - run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login docker.pkg.github.com -u ${{ github.actor }} --password-stdin - - - name: Push image - run: | - IMAGE_ID=docker.pkg.github.com/${{ github.repository }}/$IMAGE_NAME - - # Change all uppercase to lowercase - IMAGE_ID=$(echo $IMAGE_ID | tr '[A-Z]' '[a-z]') - - # Strip git ref prefix from version - VERSION=$(echo "${{ github.ref }}" | sed -e 's,.*/\(.*\),\1,') - - # Strip "v" prefix from tag name - [[ "${{ github.ref }}" == "refs/tags/"* ]] && VERSION=$(echo $VERSION | sed -e 's/^v//') - - # Use Docker `latest` tag convention - [ "$VERSION" == "master" ] && VERSION=latest - - echo IMAGE_ID=$IMAGE_ID - echo VERSION=$VERSION - - docker tag $IMAGE_NAME $IMAGE_ID:$VERSION - docker push $IMAGE_ID:$VERSION +name: Docker + +on: + push: + # Publish `master` as Docker `latest` image. + branches: + - master + + # Publish `v1.2.3` tags as releases. + tags: + - v* + + # Run tests for any PRs. + pull_request: + +env: + # TODO: Change variable to your image's name. + IMAGE_NAME: image + +jobs: + # Run tests. + # See also https://docs.docker.com/docker-hub/builds/automated-testing/ + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + + - name: Run tests + run: | + if [ -f docker-compose.test.yml ]; then + docker-compose --file docker-compose.test.yml build + docker-compose --file docker-compose.test.yml run sut + else + docker build . --file Dockerfile + fi + + # Push image to GitHub Packages. + # See also https://docs.docker.com/docker-hub/builds/ + push: + # Ensure test job passes before pushing image. + needs: test + + runs-on: ubuntu-latest + if: github.event_name == 'push' + + steps: + - uses: actions/checkout@v2 + + - name: Build image + run: docker build . --file Dockerfile --tag $IMAGE_NAME + + - name: Log into registry + run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login docker.pkg.github.com -u ${{ github.actor }} --password-stdin + + - name: Push image + run: | + IMAGE_ID=docker.pkg.github.com/${{ github.repository }}/$IMAGE_NAME + + # Change all uppercase to lowercase + IMAGE_ID=$(echo $IMAGE_ID | tr '[A-Z]' '[a-z]') + + # Strip git ref prefix from version + VERSION=$(echo "${{ github.ref }}" | sed -e 's,.*/\(.*\),\1,') + + # Strip "v" prefix from tag name + [[ "${{ github.ref }}" == "refs/tags/"* ]] && VERSION=$(echo $VERSION | sed -e 's/^v//') + + # Use Docker `latest` tag convention + [ "$VERSION" == "master" ] && VERSION=latest + + echo IMAGE_ID=$IMAGE_ID + echo VERSION=$VERSION + + docker tag $IMAGE_NAME $IMAGE_ID:$VERSION + docker push $IMAGE_ID:$VERSION diff --git a/.gitignore b/.gitignore index bec6235..e60d6ff 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,9 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -.mypy_cache/ - -# local files -.env -.vscode +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +.mypy_cache/ + +# local files +.env +.vscode +.vs \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index f1e57db..efd0ffa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,31 +1,31 @@ - -# For more information, please refer to https://aka.ms/vscode-docker-python -FROM python:3.9-slim as base - -FROM base as builder -RUN apt-get update \ - && apt-get install gcc=4:10.* git=1:2.* -y \ - && apt-get clean -COPY requirements.txt /app/requirements.txt -WORKDIR /app -ENV PATH=/root/.local/bin:$PATH -RUN pip install --user -r requirements.txt -COPY . /app - -FROM base as app -COPY . /app -COPY --from=builder /root/.local /root/.local - -# # Keeps Python from generating .pyc files in the container -# ENV PYTHONDONTWRITEBYTECODE 1 - -# Turns off buffering for easier container logging -ENV PYTHONUNBUFFERED 1 - -# Access local binaries -ENV PATH=/root/.local/bin:$PATH - -WORKDIR /app - -# During debugging, this entry point will be overridden. For more information, refer to https://aka.ms/vscode-docker-python-debug -CMD ["python", "app.py"] + +# For more information, please refer to https://aka.ms/vscode-docker-python +FROM python:3.9.18-slim-bullseye as base + +FROM base as builder +RUN apt-get update \ + && apt-get install gcc=4:10.* git=1:2.* -y \ + && apt-get clean +COPY requirements.txt /app/requirements.txt +WORKDIR /app +ENV PATH=/root/.local/bin:$PATH +RUN pip install --user -r requirements.txt +COPY . /app + +FROM base as app +COPY . /app +COPY --from=builder /root/.local /root/.local + +# # Keeps Python from generating .pyc files in the container +# ENV PYTHONDONTWRITEBYTECODE 1 + +# Turns off buffering for easier container logging +ENV PYTHONUNBUFFERED 1 + +# Access local binaries +ENV PATH=/root/.local/bin:$PATH + +WORKDIR /app + +# During debugging, this entry point will be overridden. For more information, refer to https://aka.ms/vscode-docker-python-debug +CMD ["python", "app.py"] diff --git a/README.md b/README.md index 934fe4f..34f5702 100644 --- a/README.md +++ b/README.md @@ -1,47 +1,47 @@ -# atagone-mqtt-bridge - -[![Codacy Badge](https://app.codacy.com/project/badge/Grade/fd572a99c73f429cb6aba7ac43776515)](https://www.codacy.com/gh/EtxeanNet/atagone-mqtt-bridge?utm_source=github.com&utm_medium=referral&utm_content=EtxeanNet/atagone-mqtt-bridge&utm_campaign=Badge_Grade) -![Docker Pulls](https://img.shields.io/docker/pulls/etxean/atagone-mqtt-bridge) -[![HitCount](https://hits.dwyl.com/EtxeanNet/atagone-mqtt-bridge.svg)](https://hits.dwyl.com/EtxeanNet/atagone-mqtt-bridge) - -An app to control and monitor an Atag One thermostat via Python/Docker - -## Introduction - -**atagone-mqtt-bridge** works as an bridge between the Atag One webapi and MQTT. It periodically polls the Atag One thermostat and publishes the sensor information to an MQTT broker. Reversely, it subscribes to control messages on a number of MQTT topics and controls the Atag One thermostat e.g. to change the central heating or water setpoints. - -The MQTT topics that the bridge follow the [Homie convention](https://homieiot.github.io/). By this means the Atag One can be integrated easily with home automation systems that recognize this convention such as [openHAB](https://www.openhab.org/) or [HomeAssistant](https://github.com/nerdfirefighter/HA_Homie/tree/dev). - -Under the hood, this bridge uses (a modified version of) the [pyatag](https://github.com/MatsNl/pyatag) library to interface with the Atag One Thermostat and the [homie v3](https://github.com/mjcumming/HomieV3) library to communicate with the MQTT broker. - -## Setup - -The configuration of atagone-mqtt-bridge is done with the following environment variables. - -`MQTT_HOST` -: The address of the MQTT broker. - -`MQTT_PORT` -: USe this if your MQTT broker uses a port different from 1883. - -`MQTT_USERNAME` -: Only use this if you need to use a username to connect to your MQTT broker. - -`MQTT_PASSWORD` -: Only use this if you need this to connect to your MQTT broker. - -`MQTT_CLIENT` -: The name used to identify this client to the MQTT broker. If not specified, the client will announce itself as '<atagmqtt-HOSTNAME>'. - -`ATAG_HOST` -: The address of your Atag One thermostat. If this is not specified then the Atag One themostat is discovered automatically on the local netwerk. Make sure that UDP port 11000 is not blocked by the firewall. - -## Build - -You can run the app directly from Python, after installing the modules from `requirements.txt`. Alternatatively, you can use the supplied Dockerfile to build a Docker container to run app. - -Building for docker hub can be done with: - -```bash -docker buildx build --push --platform linux/arm/v7,linux/arm64/v8,linux/amd64 --tag etxean/atagone-mqtt-bridge: --tag etxean/atagone-mqtt-bridge:latest . -``` +# atagone-mqtt-bridge + +[![Codacy Badge](https://app.codacy.com/project/badge/Grade/fd572a99c73f429cb6aba7ac43776515)](https://www.codacy.com/gh/EtxeanNet/atagone-mqtt-bridge?utm_source=github.com&utm_medium=referral&utm_content=EtxeanNet/atagone-mqtt-bridge&utm_campaign=Badge_Grade) +![Docker Pulls](https://img.shields.io/docker/pulls/etxean/atagone-mqtt-bridge) +[![HitCount](https://hits.dwyl.com/EtxeanNet/atagone-mqtt-bridge.svg)](https://hits.dwyl.com/EtxeanNet/atagone-mqtt-bridge) + +An app to control and monitor an Atag One thermostat via Python/Docker + +## Introduction + +**atagone-mqtt-bridge** works as an bridge between the Atag One webapi and MQTT. It periodically polls the Atag One thermostat and publishes the sensor information to an MQTT broker. Reversely, it subscribes to control messages on a number of MQTT topics and controls the Atag One thermostat e.g. to change the central heating or water setpoints. + +The MQTT topics that the bridge follow the [Homie convention](https://homieiot.github.io/). By this means the Atag One can be integrated easily with home automation systems that recognize this convention such as [openHAB](https://www.openhab.org/) or [HomeAssistant](https://github.com/nerdfirefighter/HA_Homie/tree/dev). + +Under the hood, this bridge uses (a modified version of) the [pyatag](https://github.com/MatsNl/pyatag) library to interface with the Atag One Thermostat and the [homie v3](https://github.com/mjcumming/HomieV3) library to communicate with the MQTT broker. + +## Setup + +The configuration of atagone-mqtt-bridge is done with the following environment variables. + +`MQTT_HOST` +: The address of the MQTT broker. + +`MQTT_PORT` +: USe this if your MQTT broker uses a port different from 1883. + +`MQTT_USERNAME` +: Only use this if you need to use a username to connect to your MQTT broker. + +`MQTT_PASSWORD` +: Only use this if you need this to connect to your MQTT broker. + +`MQTT_CLIENT` +: The name used to identify this client to the MQTT broker. If not specified, the client will announce itself as '<atagmqtt-HOSTNAME>'. + +`ATAG_HOST` +: The address of your Atag One thermostat. If this is not specified then the Atag One themostat is discovered automatically on the local netwerk. Make sure that UDP port 11000 is not blocked by the firewall. + +## Build + +You can run the app directly from Python, after installing the modules from `requirements.txt`. Alternatatively, you can use the supplied Dockerfile to build a Docker container to run app. + +Building for docker hub can be done with: + +```bash +docker buildx build --push --platform linux/arm/v7,linux/arm64/v8,linux/amd64 --tag etxean/atagone-mqtt-bridge: --tag etxean/atagone-mqtt-bridge:latest . +``` diff --git a/app.py b/app.py index c522367..630996b 100644 --- a/app.py +++ b/app.py @@ -1,15 +1,15 @@ -"""The main app.""" -import logging -import asyncio - -from atagmqtt.atag_interaction import main -from atagmqtt.configuration import Settings - -logging.basicConfig(level=Settings().loglevel, format='%(asctime)s [%(levelname)s] %(name)s - %(message)s') -logging.getLogger('pyatag').setLevel(Settings().loglevel) -logging.getLogger('homie').setLevel(Settings().loglevel) -logging.getLogger('homie.device_base').setLevel(logging.INFO) -logging.getLogger('homie.node').setLevel(logging.INFO) - -if __name__ == "__main__": - asyncio.run(main()) +"""The main app.""" +import logging +import asyncio + +from atagmqtt.atag_interaction import main +from atagmqtt.configuration import Settings + +logging.basicConfig(level=Settings().loglevel, format='%(asctime)s [%(levelname)s] %(name)s - %(message)s') +logging.getLogger('pyatag').setLevel(Settings().loglevel) +logging.getLogger('homie').setLevel(Settings().loglevel) +logging.getLogger('homie.device_base').setLevel(logging.INFO) +logging.getLogger('homie.node').setLevel(logging.INFO) + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/atagmqtt/__init__.py b/atagmqtt/__init__.py index dfe8a9d..15d3a9e 100644 --- a/atagmqtt/__init__.py +++ b/atagmqtt/__init__.py @@ -1,3 +1,3 @@ -"""atagmqtt module.""" -__version__ = "0.3.1" -NAME = "atagmqtt" +"""atagmqtt module.""" +__version__ = "0.3.1" +NAME = "atagmqtt" diff --git a/atagmqtt/atag_interaction.py b/atagmqtt/atag_interaction.py index 836905f..41dfeb0 100644 --- a/atagmqtt/atag_interaction.py +++ b/atagmqtt/atag_interaction.py @@ -1,65 +1,65 @@ -"""Interaction with ATAG ONE.""" -import asyncio -import logging -import aiohttp - -from pyatag import AtagException, AtagOne -from pyatag.discovery import async_discover_atag -from .device_atagone import DeviceAtagOne -from .configuration import Settings - -SETTINGS = Settings() -LOGGER = logging.getLogger(__name__) - -async def interact_with_atag(loop: asyncio.AbstractEventLoop, setup_timeout = 30): - """The main processing function.""" - async with aiohttp.ClientSession() as session: - LOGGER.info('Setup connection to ATAG ONE') - device = await asyncio.wait_for(setup(session, loop), timeout=SETTINGS.atag_setup_timeout) - LOGGER.info('Start processing ATAG ONE reports and commands') - while True: - await asyncio.sleep(SETTINGS.atag_update_interval) - await device.update() - LOGGER.info('Updated at: {}'.format(device.atag.report.report_time)) - -async def setup(session: aiohttp.ClientSession, loop: asyncio.AbstractEventLoop) -> DeviceAtagOne: - - """Setup the connection with the ATAG ONE device.""" - if SETTINGS.atag_host: - LOGGER.info(f"Using configured ATAG ONE @ {SETTINGS.atag_host}") - else: - LOGGER.info("Discovering ATAG ONE") - atag_ip, _ = await async_discover_atag() # for auto discovery, requires access to UDP broadcast (hostnet) - SETTINGS.atag_host = atag_ip - LOGGER.info(f"Using discovered ATAG ONE @ {SETTINGS.atag_host}") - - atag = AtagOne(SETTINGS.atag_host, session) - LOGGER.info("Authorizing...") - await atag.authorize() - LOGGER.info("Updating...") - await atag.update() - LOGGER.info("Creating Homie device...") - device = DeviceAtagOne(atag, loop) - LOGGER.info(f"Setup connection from Homie device '{SETTINGS.homie_topic}/{device.device_id}'" - f" to ATAG ONE @ {atag.host} succeeded") - return device - -def handle_exception(loop, context): - # context["message"] will always be there; but context["exception"] may not - msg = context.get("exception", context["message"]) - LOGGER.error(f"Caught exception: {msg}") - -async def main(): - LOGGER.info("ATAG MQTT bridge is starting") - loop = asyncio.get_event_loop() - loop.set_exception_handler(handle_exception) - try: - await interact_with_atag(loop) - except asyncio.TimeoutError: - LOGGER.error(f'Connection to ATAG ONE device could not be established within {SETTINGS.atag_setup_timeout} s') - except AtagException as atag_ex: - LOGGER.error(f"Caught ATAG exception: {atag_ex}") - except KeyboardInterrupt: - LOGGER.info('Closing connection to ATAG ONE due to keyboard interrupt') - finally: - LOGGER.info("ATAG MQTT bridge has stopped") +"""Interaction with ATAG ONE.""" +import asyncio +import logging +import aiohttp + +from pyatag import AtagException, AtagOne +from pyatag.discovery import async_discover_atag +from .device_atagone import DeviceAtagOne +from .configuration import Settings + +SETTINGS = Settings() +LOGGER = logging.getLogger(__name__) + +async def interact_with_atag(loop: asyncio.AbstractEventLoop, setup_timeout = 30): + """The main processing function.""" + async with aiohttp.ClientSession() as session: + LOGGER.info('Setup connection to ATAG ONE') + device = await asyncio.wait_for(setup(session, loop), timeout=SETTINGS.atag_setup_timeout) + LOGGER.info('Start processing ATAG ONE reports and commands') + while True: + await asyncio.sleep(SETTINGS.atag_update_interval) + await device.update() + LOGGER.info('Updated at: {}'.format(device.atag.report.report_time)) + +async def setup(session: aiohttp.ClientSession, loop: asyncio.AbstractEventLoop) -> DeviceAtagOne: + + """Setup the connection with the ATAG ONE device.""" + if SETTINGS.atag_host: + LOGGER.info(f"Using configured ATAG ONE @ {SETTINGS.atag_host}") + else: + LOGGER.info("Discovering ATAG ONE") + atag_ip, _ = await async_discover_atag() # for auto discovery, requires access to UDP broadcast (hostnet) + SETTINGS.atag_host = atag_ip + LOGGER.info(f"Using discovered ATAG ONE @ {SETTINGS.atag_host}") + + atag = AtagOne(SETTINGS.atag_host, session) + LOGGER.info("Authorizing...") + await atag.authorize() + LOGGER.info("Updating...") + await atag.update() + LOGGER.info("Creating Homie device...") + device = DeviceAtagOne(atag, loop) + LOGGER.info(f"Setup connection from Homie device '{SETTINGS.homie_topic}/{device.device_id}'" + f" to ATAG ONE @ {atag.host} succeeded") + return device + +def handle_exception(loop, context): + # context["message"] will always be there; but context["exception"] may not + msg = context.get("exception", context["message"]) + LOGGER.error(f"Caught exception: {msg}") + +async def main(): + LOGGER.info("ATAG MQTT bridge is starting") + loop = asyncio.get_event_loop() + loop.set_exception_handler(handle_exception) + try: + await interact_with_atag(loop) + except asyncio.TimeoutError: + LOGGER.error(f'Connection to ATAG ONE device could not be established within {SETTINGS.atag_setup_timeout} s') + except AtagException as atag_ex: + LOGGER.error(f"Caught ATAG exception: {atag_ex}") + except KeyboardInterrupt: + LOGGER.info('Closing connection to ATAG ONE due to keyboard interrupt') + finally: + LOGGER.info("ATAG MQTT bridge has stopped") diff --git a/atagmqtt/configuration.py b/atagmqtt/configuration.py index f5258f2..08cc61e 100644 --- a/atagmqtt/configuration.py +++ b/atagmqtt/configuration.py @@ -1,39 +1,39 @@ -"""Configuration module.""" -import os - -from pydantic import BaseSettings, Field -import homie -import atagmqtt -from .__init__ import __version__, NAME - -HOSTNAME = os.getenv("HOSTNAME") - -class Settings(BaseSettings): - """Application settings for the ATAG ONE MQTT bridge.""" - - hostname: str = Field('atagmqtt', env='HOSTNAME') - loglevel: str = Field('INFO', env='LOGLEVEL') - - atag_setup_timeout: int = Field(30, env='ATAG_SETUP_TIMEOUT') - - atag_update_interval: int = Field(30, env='ATAG_UPDATE_INTERVAL') - atag_host: str = Field(None, env='ATAG_HOST') - atag_paired: bool = Field(False, env='ATAG_PAIRED') - - mqtt_host: str = Field(None, env='MQTT_HOST') - mqtt_port: int = Field(1883, env='MQTT_PORT') - mqtt_username: str = Field(None, env='MQTT_USERNAME') - mqtt_password: str = Field(None, env='MQTT_PASSWORD') - mqtt_client: str = Field(f"{NAME}-{HOSTNAME}", env='MQTT_CLIENT') - - homie_update_interval: int = 60 - homie_topic: str = Field('homie', env='HOMIE_TOPIC') - homie_implementation: str \ - = f"Atag One Homie {atagmqtt.__version__} Homie 3 Version {homie.__version__}" - homie_fw_name: str = "AtagOne" - homie_fw_version: str = __version__ - - class Config: - """Where to find the environment file containing the settings.""" - - env_file = '.env' +"""Configuration module.""" +import os + +from pydantic import BaseSettings, Field +import homie +import atagmqtt +from .__init__ import __version__, NAME + +HOSTNAME = os.getenv("HOSTNAME") + +class Settings(BaseSettings): + """Application settings for the ATAG ONE MQTT bridge.""" + + hostname: str = Field('atagmqtt', env='HOSTNAME') + loglevel: str = Field('INFO', env='LOGLEVEL') + + atag_setup_timeout: int = Field(30, env='ATAG_SETUP_TIMEOUT') + + atag_update_interval: int = Field(30, env='ATAG_UPDATE_INTERVAL') + atag_host: str = Field(None, env='ATAG_HOST') + atag_paired: bool = Field(False, env='ATAG_PAIRED') + + mqtt_host: str = Field(None, env='MQTT_HOST') + mqtt_port: int = Field(1883, env='MQTT_PORT') + mqtt_username: str = Field(None, env='MQTT_USERNAME') + mqtt_password: str = Field(None, env='MQTT_PASSWORD') + mqtt_client: str = Field(f"{NAME}-{HOSTNAME}", env='MQTT_CLIENT') + + homie_update_interval: int = 60 + homie_topic: str = Field('homie', env='HOMIE_TOPIC') + homie_implementation: str \ + = f"Atag One Homie {atagmqtt.__version__} Homie 3 Version {homie.__version__}" + homie_fw_name: str = "AtagOne" + homie_fw_version: str = __version__ + + class Config: + """Where to find the environment file containing the settings.""" + + env_file = '.env' diff --git a/atagmqtt/device_atagone.py b/atagmqtt/device_atagone.py index 0df8d59..30db943 100644 --- a/atagmqtt/device_atagone.py +++ b/atagmqtt/device_atagone.py @@ -1,266 +1,266 @@ -"""ATAG ONE device module.""" -import logging -import asyncio - -from homie.device_base import Device_Base -from homie.node.node_base import Node_Base -from homie.node.property.property_setpoint import Property_Setpoint -from homie.node.property.property_boolean import Property_Boolean -from homie.node.property.property_temperature import Property_Temperature -from homie.node.property.property_integer import Property_Integer -from homie.node.property.property_float import Property_Float -from homie.node.property.property_enum import Property_Enum -from homie.node.property.property_string import Property_String - -from pyatag import AtagOne -from pyatag.const import STATES -from .configuration import Settings - - -LOGGER = logging.getLogger(__name__) -SETTINGS = Settings() - -TRANSLATED_MQTT_SETTINGS = { - 'MQTT_BROKER': SETTINGS.mqtt_host, - 'MQTT_PORT': SETTINGS.mqtt_port, - 'MQTT_USERNAME' : SETTINGS.mqtt_username, - 'MQTT_PASSWORD' : SETTINGS.mqtt_password, - 'MQTT_CLIENT_ID' : SETTINGS.mqtt_client, - 'MQTT_SHARE_CLIENT': False, -} - -TRANSLATED_HOMIE_SETTINGS = { - 'topic' : SETTINGS.homie_topic, - 'fw_name' : SETTINGS.homie_fw_name, - 'fw_version' : SETTINGS.homie_fw_version, - 'update_interval' : SETTINGS.homie_update_interval, -} - -class DeviceAtagOne(Device_Base): - """The ATAG ONE device.""" - def __init__(self, atag: AtagOne, eventloop, device_id="atagone", name="Atag One"): - """Create an ATAG ONE Homie device.""" - super().__init__(device_id, name, TRANSLATED_HOMIE_SETTINGS, TRANSLATED_MQTT_SETTINGS) - self.atag: AtagOne = atag - self.temp_unit = atag.climate.temp_unit - self._eventloop = eventloop - - LOGGER.debug("Setting up Homie nodes") - node = (Node_Base(self, 'burner', 'Burner', 'status')) - self.add_node(node) - - self.burner_modulation = Property_Integer( - node, id="modulation", name="Burner modulation", settable=False) - node.add_property(self.burner_modulation) - - self.burner_target = Property_Enum( - node, id="target", name="Burner target", settable=False, data_format="none,ch,dhw") - node.add_property(self.burner_target) - - # Central heating status properties - node = (Node_Base(self, 'centralheating', 'Central heating', 'status')) - self.add_node(node) - - self.ch_status = Property_Boolean(node, id="status", name="CH status", settable=False) - node.add_property(self.ch_status) - - self.ch_temperature = Property_Temperature( - node, id="temperature", name="CH temperature", - settable=False, value=self.atag.climate.temperature, - unit=self.temp_unit) - node.add_property(self.ch_temperature) - - self.ch_water_temperature = Property_Temperature( - node, id="water-temperature", name="CH water temperature", - settable=False, value=self.atag.report["CH Water Temperature"].state, - unit=self.temp_unit) - node.add_property(self.ch_water_temperature) - - self.ch_target_water_temperature = Property_Temperature( - node, id="target-water-temperature", name="CH target water temperature", - settable=False, value=self.atag.climate.target_temperature, - unit=self.temp_unit) - node.add_property(self.ch_target_water_temperature) - - self.ch_return_water_temperature = Property_Temperature( - node, id="return-water-temperature", name="CH return water temperature", - settable=False, value=self.atag.report["CH Return Temperature"].state, - unit=self.temp_unit) - node.add_property(self.ch_return_water_temperature) - - self.ch_water_pressure = Property_Float( - node, id="water-pressure", name="CH water pressure", - settable=False, value=self.atag.report["CH Water Pressure"].state, - unit=self.temp_unit) - node.add_property(self.ch_water_pressure) - - ch_mode_values = ",".join(STATES["ch_mode"].values()) - self.ch_mode = Property_Enum( - node, id="mode", name="CH mode", - settable=False, value=self.atag.climate.preset_mode, - data_format=ch_mode_values - ) - node.add_property(self.ch_mode) - - self.ch_mode_duration = Property_String( - node, id="mode-duration", name="CH mode duration", - settable=False, value=self.atag.climate.preset_mode_duration, - ) - node.add_property(self.ch_mode_duration) - - # Domestic hot water status properties - node = (Node_Base(self, 'domestichotwater', 'Domestic hot water', 'status')) - self.add_node(node) - - self.dhw_status = Property_Boolean( - node, id="status", name="DHW status", settable=False) - node.add_property(self.dhw_status) - - self.dhw_temperature = Property_Temperature( - node, id="temperature", name="DHW temperature", - settable=False, value=self.atag.dhw.temperature, - unit=self.temp_unit) - node.add_property(self.dhw_temperature) - - dhw_mode_values = ",".join(STATES["dhw_mode"].values()) + ",off" - self.dhw_mode = Property_Enum( - node, id="mode", name="DHW mode", - settable=False, value=self.atag.dhw.current_operation, - data_format=dhw_mode_values - ) - node.add_property(self.dhw_mode) - - node = (Node_Base(self, 'weather', 'Weather', 'status')) - self.add_node(node) - - self.weather_temperature = Property_Temperature( - node, id="temperature", name="Weather temperature", - settable=False, value=self.atag.report["weather_temp"].state, - unit=self.temp_unit) - node.add_property(self.weather_temperature) - - # Control properties - node = (Node_Base(self, 'controls', 'Controls', 'controls')) - self.add_node(node) - - ch_min_temp = 12 - ch_max_temp = 25 - ch_target_temperature_limits = f'{ch_min_temp}:{ch_max_temp}' - self.ch_target_temperature = Property_Setpoint( - node, id='ch-target-temperature', name='CH Target temperature', - data_format=ch_target_temperature_limits, - unit=self.temp_unit, - value=self.atag.climate.target_temperature, - set_value=self.set_ch_target_temperature) - node.add_property(self.ch_target_temperature) - - dhw_min_temp = self.atag.dhw.min_temp - dhw_max_temp = self.atag.dhw.max_temp - dhw_target_temperature_limits = f'{dhw_min_temp}:{dhw_max_temp}' - self.dhw_target_temperature = Property_Setpoint( - node, id='dhw-target-temperature', name='DHW Target temperature', - data_format=dhw_target_temperature_limits, - unit=self.temp_unit, value=self.atag.dhw.target_temperature, - set_value=self.set_dhw_target_temperature) - node.add_property(self.dhw_target_temperature) - - hvac_values = ",".join(STATES["ch_control_mode"].values()) - self.hvac_mode = Property_Enum( - node, id='hvac-mode', name='HVAC mode', - data_format=hvac_values, - unit=self.temp_unit, - value=self.atag.climate.hvac_mode, - set_value=self.set_hvac_mode) - node.add_property(self.hvac_mode) - - ch_mode_values = ",".join(STATES["ch_mode"].values()) - self.ch_mode_control = Property_Enum( - node, id="ch-mode", name="CH mode", - data_format=ch_mode_values, - value=self.atag.climate.preset_mode, - set_value=self.set_ch_mode - ) - node.add_property(self.ch_mode_control) - - LOGGER.debug("Starting Homie device") - self.start() - - def set_ch_target_temperature(self, value): - """Set target central heating temperature.""" - oldvalue = self.atag.climate.target_temperature - LOGGER.info(f"Setting target CH temperature from {oldvalue} to {value} {self.temp_unit}") - self.ch_target_temperature.value = value - self._run_coroutine(self._async_set_ch_target_temperature(value)) - - async def _async_set_ch_target_temperature(self, value): - await self.atag.climate.set_temp(value) - LOGGER.info(f"Succeeded setting target CH temperature to {value} {self.temp_unit}") - - def set_dhw_target_temperature(self, value): - """Set target domestic hot water temperature.""" - oldvalue = self.atag.dhw.target_temperature - LOGGER.info(f"Setting target DHW temperature from {oldvalue} to {value} {self.temp_unit}") - self.dhw_target_temperature.value = value - self._run_coroutine(self._async_set_dhw_target_temperature(value)) - - async def _async_set_dhw_target_temperature(self, value): - await self.atag.dhw.set_temp(value) - LOGGER.info(f"Succeeded setting target DHW temperature to {value} {self.temp_unit}") - - def set_hvac_mode(self, value): - """Set HVAC mode.""" - oldvalue = self.atag.climate.hvac_mode - LOGGER.info(f"Setting HVAC mode from {oldvalue} to {value}") - self.hvac_mode.value = value - self._run_coroutine(self._async_set_hvac_mode(value)) - - async def _async_set_hvac_mode(self, value): - await self.atag.climate.set_hvac_mode(value) - LOGGER.info(f"Succeeded setting HVAC mode to {value}") - - def set_ch_mode(self, value): - """Set CH mode.""" - oldvalue = self.atag.climate.preset_mode - LOGGER.info(f"Setting CH mode from {oldvalue} to {value}") - self.ch_mode_control.value = value - self.ch_mode.value = value - self._run_coroutine(self._async_set_ch_mode(value)) - - async def _async_set_ch_mode(self, value): - await self.atag.climate.set_preset_mode(value) - LOGGER.info(f"Succeeded setting CH mode to {value}") - - async def update(self): - """Update device status from atag device.""" - await self.atag.update() - LOGGER.debug("Updating from latest device report") - self.burner_modulation.value = self.atag.climate.flame - self.hvac_mode.value = self.atag.climate.hvac_mode - self.ch_mode.value = self.atag.climate.preset_mode - self.ch_mode_duration.value = self.atag.climate.preset_mode_duration - self.ch_mode_control.value = self.atag.climate.preset_mode - - self.ch_target_temperature.value = self.atag.climate.target_temperature - self.ch_temperature.value = self.atag.climate.temperature - self.ch_target_water_temperature.value = self.atag.climate.target_temperature - self.ch_water_temperature.value = self.atag.report["CH Water Temperature"].state - self.ch_water_pressure.value = self.atag.report["CH Water Pressure"].state - self.ch_return_water_temperature.value = self.atag.report["CH Return Temperature"].state - self.ch_status.value = True if self.atag.climate.status else False - - self.dhw_target_temperature.value = self.atag.report["dhw_temp_setp"].state - self.dhw_temperature.value = self.atag.dhw.temperature - self.dhw_status.value = True if self.atag.dhw.status else False - self.dhw_mode.value = self.atag.dhw.current_operation - - self.weather_temperature.value = self.atag.report["weather_temp"].state - - if self.atag.dhw.status: - self.burner_target.value = "dhw" - elif self.atag.climate.status: - self.burner_target.value = "ch" - else: - self.burner_target.value = "none" - - def _run_coroutine(self, coroutine): - asyncio.run_coroutine_threadsafe(coroutine, self._eventloop) +"""ATAG ONE device module.""" +import logging +import asyncio + +from homie.device_base import Device_Base +from homie.node.node_base import Node_Base +from homie.node.property.property_setpoint import Property_Setpoint +from homie.node.property.property_boolean import Property_Boolean +from homie.node.property.property_temperature import Property_Temperature +from homie.node.property.property_integer import Property_Integer +from homie.node.property.property_float import Property_Float +from homie.node.property.property_enum import Property_Enum +from homie.node.property.property_string import Property_String + +from pyatag import AtagOne +from pyatag.const import STATES +from .configuration import Settings + + +LOGGER = logging.getLogger(__name__) +SETTINGS = Settings() + +TRANSLATED_MQTT_SETTINGS = { + 'MQTT_BROKER': SETTINGS.mqtt_host, + 'MQTT_PORT': SETTINGS.mqtt_port, + 'MQTT_USERNAME' : SETTINGS.mqtt_username, + 'MQTT_PASSWORD' : SETTINGS.mqtt_password, + 'MQTT_CLIENT_ID' : SETTINGS.mqtt_client, + 'MQTT_SHARE_CLIENT': False, +} + +TRANSLATED_HOMIE_SETTINGS = { + 'topic' : SETTINGS.homie_topic, + 'fw_name' : SETTINGS.homie_fw_name, + 'fw_version' : SETTINGS.homie_fw_version, + 'update_interval' : SETTINGS.homie_update_interval, +} + +class DeviceAtagOne(Device_Base): + """The ATAG ONE device.""" + def __init__(self, atag: AtagOne, eventloop, device_id="atagone", name="Atag One"): + """Create an ATAG ONE Homie device.""" + super().__init__(device_id, name, TRANSLATED_HOMIE_SETTINGS, TRANSLATED_MQTT_SETTINGS) + self.atag: AtagOne = atag + self.temp_unit = atag.climate.temp_unit + self._eventloop = eventloop + + LOGGER.debug("Setting up Homie nodes") + node = (Node_Base(self, 'burner', 'Burner', 'status')) + self.add_node(node) + + self.burner_modulation = Property_Integer( + node, id="modulation", name="Burner modulation", settable=False) + node.add_property(self.burner_modulation) + + self.burner_target = Property_Enum( + node, id="target", name="Burner target", settable=False, data_format="none,ch,dhw") + node.add_property(self.burner_target) + + # Central heating status properties + node = (Node_Base(self, 'centralheating', 'Central heating', 'status')) + self.add_node(node) + + self.ch_status = Property_Boolean(node, id="status", name="CH status", settable=False) + node.add_property(self.ch_status) + + self.ch_temperature = Property_Temperature( + node, id="temperature", name="CH temperature", + settable=False, value=self.atag.climate.temperature, + unit=self.temp_unit) + node.add_property(self.ch_temperature) + + self.ch_water_temperature = Property_Temperature( + node, id="water-temperature", name="CH water temperature", + settable=False, value=self.atag.report["CH Water Temperature"].state, + unit=self.temp_unit) + node.add_property(self.ch_water_temperature) + + self.ch_target_water_temperature = Property_Temperature( + node, id="target-water-temperature", name="CH target water temperature", + settable=False, value=self.atag.climate.target_temperature, + unit=self.temp_unit) + node.add_property(self.ch_target_water_temperature) + + self.ch_return_water_temperature = Property_Temperature( + node, id="return-water-temperature", name="CH return water temperature", + settable=False, value=self.atag.report["CH Return Temperature"].state, + unit=self.temp_unit) + node.add_property(self.ch_return_water_temperature) + + self.ch_water_pressure = Property_Float( + node, id="water-pressure", name="CH water pressure", + settable=False, value=self.atag.report["CH Water Pressure"].state, + unit=self.temp_unit) + node.add_property(self.ch_water_pressure) + + ch_mode_values = ",".join(STATES["ch_mode"].values()) + self.ch_mode = Property_Enum( + node, id="mode", name="CH mode", + settable=False, value=self.atag.climate.preset_mode, + data_format=ch_mode_values + ) + node.add_property(self.ch_mode) + + self.ch_mode_duration = Property_String( + node, id="mode-duration", name="CH mode duration", + settable=False, value=self.atag.climate.preset_mode_duration, + ) + node.add_property(self.ch_mode_duration) + + # Domestic hot water status properties + node = (Node_Base(self, 'domestichotwater', 'Domestic hot water', 'status')) + self.add_node(node) + + self.dhw_status = Property_Boolean( + node, id="status", name="DHW status", settable=False) + node.add_property(self.dhw_status) + + self.dhw_temperature = Property_Temperature( + node, id="temperature", name="DHW temperature", + settable=False, value=self.atag.dhw.temperature, + unit=self.temp_unit) + node.add_property(self.dhw_temperature) + + dhw_mode_values = ",".join(STATES["dhw_mode"].values()) + ",off" + self.dhw_mode = Property_Enum( + node, id="mode", name="DHW mode", + settable=False, value=self.atag.dhw.current_operation, + data_format=dhw_mode_values + ) + node.add_property(self.dhw_mode) + + node = (Node_Base(self, 'weather', 'Weather', 'status')) + self.add_node(node) + + self.weather_temperature = Property_Temperature( + node, id="temperature", name="Weather temperature", + settable=False, value=self.atag.report["weather_temp"].state, + unit=self.temp_unit) + node.add_property(self.weather_temperature) + + # Control properties + node = (Node_Base(self, 'controls', 'Controls', 'controls')) + self.add_node(node) + + ch_min_temp = 12 + ch_max_temp = 25 + ch_target_temperature_limits = f'{ch_min_temp}:{ch_max_temp}' + self.ch_target_temperature = Property_Setpoint( + node, id='ch-target-temperature', name='CH Target temperature', + data_format=ch_target_temperature_limits, + unit=self.temp_unit, + value=self.atag.climate.target_temperature, + set_value=self.set_ch_target_temperature) + node.add_property(self.ch_target_temperature) + + dhw_min_temp = self.atag.dhw.min_temp + dhw_max_temp = self.atag.dhw.max_temp + dhw_target_temperature_limits = f'{dhw_min_temp}:{dhw_max_temp}' + self.dhw_target_temperature = Property_Setpoint( + node, id='dhw-target-temperature', name='DHW Target temperature', + data_format=dhw_target_temperature_limits, + unit=self.temp_unit, value=self.atag.dhw.target_temperature, + set_value=self.set_dhw_target_temperature) + node.add_property(self.dhw_target_temperature) + + hvac_values = ",".join(STATES["ch_control_mode"].values()) + self.hvac_mode = Property_Enum( + node, id='hvac-mode', name='HVAC mode', + data_format=hvac_values, + unit=self.temp_unit, + value=self.atag.climate.hvac_mode, + set_value=self.set_hvac_mode) + node.add_property(self.hvac_mode) + + ch_mode_values = ",".join(STATES["ch_mode"].values()) + self.ch_mode_control = Property_Enum( + node, id="ch-mode", name="CH mode", + data_format=ch_mode_values, + value=self.atag.climate.preset_mode, + set_value=self.set_ch_mode + ) + node.add_property(self.ch_mode_control) + + LOGGER.debug("Starting Homie device") + self.start() + + def set_ch_target_temperature(self, value): + """Set target central heating temperature.""" + oldvalue = self.atag.climate.target_temperature + LOGGER.info(f"Setting target CH temperature from {oldvalue} to {value} {self.temp_unit}") + self.ch_target_temperature.value = value + self._run_coroutine(self._async_set_ch_target_temperature(value)) + + async def _async_set_ch_target_temperature(self, value): + await self.atag.climate.set_temp(value) + LOGGER.info(f"Succeeded setting target CH temperature to {value} {self.temp_unit}") + + def set_dhw_target_temperature(self, value): + """Set target domestic hot water temperature.""" + oldvalue = self.atag.dhw.target_temperature + LOGGER.info(f"Setting target DHW temperature from {oldvalue} to {value} {self.temp_unit}") + self.dhw_target_temperature.value = value + self._run_coroutine(self._async_set_dhw_target_temperature(value)) + + async def _async_set_dhw_target_temperature(self, value): + await self.atag.dhw.set_temp(value) + LOGGER.info(f"Succeeded setting target DHW temperature to {value} {self.temp_unit}") + + def set_hvac_mode(self, value): + """Set HVAC mode.""" + oldvalue = self.atag.climate.hvac_mode + LOGGER.info(f"Setting HVAC mode from {oldvalue} to {value}") + self.hvac_mode.value = value + self._run_coroutine(self._async_set_hvac_mode(value)) + + async def _async_set_hvac_mode(self, value): + await self.atag.climate.set_hvac_mode(value) + LOGGER.info(f"Succeeded setting HVAC mode to {value}") + + def set_ch_mode(self, value): + """Set CH mode.""" + oldvalue = self.atag.climate.preset_mode + LOGGER.info(f"Setting CH mode from {oldvalue} to {value}") + self.ch_mode_control.value = value + self.ch_mode.value = value + self._run_coroutine(self._async_set_ch_mode(value)) + + async def _async_set_ch_mode(self, value): + await self.atag.climate.set_preset_mode(value) + LOGGER.info(f"Succeeded setting CH mode to {value}") + + async def update(self): + """Update device status from atag device.""" + await self.atag.update() + LOGGER.debug("Updating from latest device report") + self.burner_modulation.value = self.atag.climate.flame + self.hvac_mode.value = self.atag.climate.hvac_mode + self.ch_mode.value = self.atag.climate.preset_mode + self.ch_mode_duration.value = self.atag.climate.preset_mode_duration + self.ch_mode_control.value = self.atag.climate.preset_mode + + self.ch_target_temperature.value = self.atag.climate.target_temperature + self.ch_temperature.value = self.atag.climate.temperature + self.ch_target_water_temperature.value = self.atag.climate.target_temperature + self.ch_water_temperature.value = self.atag.report["CH Water Temperature"].state + self.ch_water_pressure.value = self.atag.report["CH Water Pressure"].state + self.ch_return_water_temperature.value = self.atag.report["CH Return Temperature"].state + self.ch_status.value = True if self.atag.climate.status else False + + self.dhw_target_temperature.value = self.atag.report["dhw_temp_setp"].state + self.dhw_temperature.value = self.atag.dhw.temperature + self.dhw_status.value = True if self.atag.dhw.status else False + self.dhw_mode.value = self.atag.dhw.current_operation + + self.weather_temperature.value = self.atag.report["weather_temp"].state + + if self.atag.dhw.status: + self.burner_target.value = "dhw" + elif self.atag.climate.status: + self.burner_target.value = "ch" + else: + self.burner_target.value = "none" + + def _run_coroutine(self, coroutine): + asyncio.run_coroutine_threadsafe(coroutine, self._eventloop) diff --git a/docker-compose.yml b/docker-compose.yml index e9e8990..8062694 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,12 +1,12 @@ -version: '3.4' - -services: - atagonemqttbridge: - image: atagonemqttbridge - build: - context: . - dockerfile: Dockerfile - # restart: always - network_mode: host - env_file: +version: '3.4' + +services: + atagonemqttbridge: + image: atagonemqttbridge + build: + context: . + dockerfile: Dockerfile + # restart: always + network_mode: host + env_file: - '.env' \ No newline at end of file