From 4ee5afc1ec9bf0ec6ddd3522e9d2ed333f99e95f Mon Sep 17 00:00:00 2001 From: Arjen Bos <1064589+arjenbos@users.noreply.github.com> Date: Thu, 2 Nov 2023 09:15:50 +0100 Subject: [PATCH 1/4] Docker (#21) --- .gitignore | 1 + docker/docker-compose.yaml | 17 +++++++ docker/nginx.conf | 46 +++++++++++++++++++ .../controller_admin_login_check.json | 6 +++ ...ntroller_admin_systeminformation_get.json} | 0 tests/fixtures/controller_api_room_list.json | 2 +- .../controller_api_room_settemperature.json | 6 +++ .../controller_api_user_token_challenge.json | 1 + .../controller_api_user_token_response.json | 1 + tests/fixtures/gateway_api_allmodules.json | 2 +- tests/fixtures/gateway_api_dbmodules.json | 2 +- tests/test_config_flow.py | 2 +- 12 files changed, 82 insertions(+), 4 deletions(-) create mode 100644 docker/docker-compose.yaml create mode 100644 docker/nginx.conf create mode 100644 tests/fixtures/controller_admin_login_check.json rename tests/fixtures/{controller_api_systeminformation.json => controller_admin_systeminformation_get.json} (100%) create mode 100644 tests/fixtures/controller_api_room_settemperature.json create mode 100644 tests/fixtures/controller_api_user_token_challenge.json create mode 100644 tests/fixtures/controller_api_user_token_response.json diff --git a/.gitignore b/.gitignore index 09c756e..ceac1cd 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ env coverage.xml .secrets .pytest_cache +docker/config diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml new file mode 100644 index 0000000..e613748 --- /dev/null +++ b/docker/docker-compose.yaml @@ -0,0 +1,17 @@ +version: '3.9' +services: + homeassistant: + container_name: home-assistant + image: homeassistant/home-assistant + ports: + - 8123:8123 + volumes: + - ./config:/config + - ../custom_components/alpha_innotec:/config/custom_components/alpha_innotec + nginx: + image: nginx + ports: + - 80:80 + volumes: + - ./nginx.conf:/etc/nginx/conf.d/default.conf + - ../tests/fixtures:/var/www diff --git a/docker/nginx.conf b/docker/nginx.conf new file mode 100644 index 0000000..b6939f5 --- /dev/null +++ b/docker/nginx.conf @@ -0,0 +1,46 @@ +server { + listen 80 default_server; + listen [::]:80 default_server; + server_name _; + root /var/www; + error_page 405 =200 $uri; + location /api/room/list { + alias /var/www/; + try_files controller_api_room_list.json =404; + } + + location /admin/systeminformation/get { + alias /var/www/; + try_files controller_admin_systeminformation_get.json =404; + } + + location /api/room/settemperature { + alias /var/www/; + try_files controller_api_room_settemperature.json =404; + } + + location /admin/login/check { + alias /var/www/; + try_files controller_admin_login_check.json =404; + } + + location /api/user/token/challenge { + alias /var/www/; + try_files controller_api_user_token_challenge.json =404; + } + + location /api/user/token/response { + alias /var/www/; + try_files controller_api_user_token_response.json =404; + } + + location /api/gateway/dbmodules { + alias /var/www/; + try_files gateway_api_dbmodules.json =404; + } + + location /api/gateway/allmodules { + alias /var/www/; + try_files gateway_api_allmodules.json =404; + } +} diff --git a/tests/fixtures/controller_admin_login_check.json b/tests/fixtures/controller_admin_login_check.json new file mode 100644 index 0000000..7068c43 --- /dev/null +++ b/tests/fixtures/controller_admin_login_check.json @@ -0,0 +1,6 @@ +{ + "success": true, + "message": "", + "language": "en", + "performance": 0.047 +} diff --git a/tests/fixtures/controller_api_systeminformation.json b/tests/fixtures/controller_admin_systeminformation_get.json similarity index 100% rename from tests/fixtures/controller_api_systeminformation.json rename to tests/fixtures/controller_admin_systeminformation_get.json diff --git a/tests/fixtures/controller_api_room_list.json b/tests/fixtures/controller_api_room_list.json index 53388e3..bb56a7d 100644 --- a/tests/fixtures/controller_api_room_list.json +++ b/tests/fixtures/controller_api_room_list.json @@ -156,4 +156,4 @@ ], "language": "en", "performance": 0.907 -} \ No newline at end of file +} diff --git a/tests/fixtures/controller_api_room_settemperature.json b/tests/fixtures/controller_api_room_settemperature.json new file mode 100644 index 0000000..7068c43 --- /dev/null +++ b/tests/fixtures/controller_api_room_settemperature.json @@ -0,0 +1,6 @@ +{ + "success": true, + "message": "", + "language": "en", + "performance": 0.047 +} diff --git a/tests/fixtures/controller_api_user_token_challenge.json b/tests/fixtures/controller_api_user_token_challenge.json new file mode 100644 index 0000000..de19b3b --- /dev/null +++ b/tests/fixtures/controller_api_user_token_challenge.json @@ -0,0 +1 @@ +{"success": true, "message": "", "loginRejected": false, "devicetoken": "7f594a2e44244d1f92df88d7dccaed48", "language": "en", "performance": 0.042} diff --git a/tests/fixtures/controller_api_user_token_response.json b/tests/fixtures/controller_api_user_token_response.json new file mode 100644 index 0000000..f335f56 --- /dev/null +++ b/tests/fixtures/controller_api_user_token_response.json @@ -0,0 +1 @@ +{"success": true, "message": "", "loginRejected": false, "devicetoken_encrypted": "W9UIefCF9T7jQGmagrhsJPEldxM5iher+CSAIvbas84=", "userid": 4, "language": "en", "performance": 0.202} diff --git a/tests/fixtures/gateway_api_allmodules.json b/tests/fixtures/gateway_api_allmodules.json index b32e41e..6665d04 100644 --- a/tests/fixtures/gateway_api_allmodules.json +++ b/tests/fixtures/gateway_api_allmodules.json @@ -219,4 +219,4 @@ }, "language": "en", "performance": 0.75 -} \ No newline at end of file +} diff --git a/tests/fixtures/gateway_api_dbmodules.json b/tests/fixtures/gateway_api_dbmodules.json index 2a52b43..7411a00 100644 --- a/tests/fixtures/gateway_api_dbmodules.json +++ b/tests/fixtures/gateway_api_dbmodules.json @@ -277,4 +277,4 @@ }, "language": "en", "performance": 0.138 -} \ No newline at end of file +} diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 578a3c4..bb7a24b 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -22,7 +22,7 @@ async def test_setup_config(hass: HomeAssistant): with patch( target=f"{MODULE}.config_flow.validate_input", - return_value=json.loads(load_fixture("controller_api_systeminformation.json")), + return_value=json.loads(load_fixture("controller_admin_systeminformation_get.json")), ) as mock_setup_entry: result = await hass.config_entries.flow.async_configure( result["flow_id"], From 00637bba9ce3a696729474d1ba6a509bca5c838c Mon Sep 17 00:00:00 2001 From: Arjen Bos <1064589+arjenbos@users.noreply.github.com> Date: Thu, 2 Nov 2023 09:16:17 +0100 Subject: [PATCH 2/4] Disable Valve binary sensor when it's not used (#18) * Update Battery Sensor and Binary Sensor * Renamed Alpha Home to Alpha Innotec * Add support for valve usage and improved debug logging --- .../alpha_innotec/binary_sensor.py | 18 +++++++++++++++--- custom_components/alpha_innotec/climate.py | 2 ++ .../alpha_innotec/controller_api.py | 14 +++++++++----- custom_components/alpha_innotec/gateway_api.py | 13 +++++-------- custom_components/alpha_innotec/sensor.py | 2 ++ .../alpha_innotec/structs/Valve.py | 3 ++- 6 files changed, 35 insertions(+), 17 deletions(-) diff --git a/custom_components/alpha_innotec/binary_sensor.py b/custom_components/alpha_innotec/binary_sensor.py index 9c29817..0900d96 100644 --- a/custom_components/alpha_innotec/binary_sensor.py +++ b/custom_components/alpha_innotec/binary_sensor.py @@ -38,7 +38,7 @@ async def async_setup_entry(hass, entry, async_add_entities): entities.append(AlphaHomeBinarySensor( coordinator=coordinator, name=valve.name, - description=BinarySensorEntityDescription(""), + description=BinarySensorEntityDescription("", entity_registry_enabled_default=valve.used), valve=valve )) @@ -48,6 +48,8 @@ async def async_setup_entry(hass, entry, async_add_entities): class AlphaInnotecBinarySensorCoordinator(DataUpdateCoordinator): """My custom coordinator.""" + data: list[Valve] + def __init__(self, hass: HomeAssistant, gateway_api: GatewayAPI): """Initialize my coordinator.""" super().__init__( @@ -63,6 +65,7 @@ async def _async_update_data(self) -> list[Valve]: """Fetch data from API endpoint.""" db_modules: dict = await self.hass.async_add_executor_job(self.gateway_api.db_modules) + all_modules: dict = await self.hass.async_add_executor_job(self.gateway_api.all_modules) valves: list[Valve] = [] @@ -73,13 +76,22 @@ async def _async_update_data(self) -> list[Valve]: continue for instance in module["instances"]: + valve_id = '0' + instance['instance'] + module['deviceid'][2:] + + used = False + + for room_id in all_modules: + if used is not True: + used = valve_id in all_modules[room_id]["modules"] + valve = Valve( - identifier=module["deviceid"] + '-' + instance['instance'], + identifier=valve_id, name=module["name"] + '-' + instance['instance'], instance=instance["instance"], device_id=module["deviceid"], device_name=module["name"], - status=instance["status"] + status=instance["status"], + used=used ) valves.append(valve) diff --git a/custom_components/alpha_innotec/climate.py b/custom_components/alpha_innotec/climate.py index 608f183..d4ffe4b 100644 --- a/custom_components/alpha_innotec/climate.py +++ b/custom_components/alpha_innotec/climate.py @@ -52,6 +52,8 @@ async def async_setup_entry(hass, entry, async_add_entities): class AlphaInnotecClimateCoordinator(DataUpdateCoordinator, BaseCoordinator): """My custom coordinator.""" + data: list[Thermostat] + def __init__(self, hass: HomeAssistant, controller_api: ControllerAPI, gateway_api: GatewayAPI) -> None: """Initialize my coordinator.""" super().__init__( diff --git a/custom_components/alpha_innotec/controller_api.py b/custom_components/alpha_innotec/controller_api.py index 9e274b8..25ba967 100644 --- a/custom_components/alpha_innotec/controller_api.py +++ b/custom_components/alpha_innotec/controller_api.py @@ -17,7 +17,7 @@ def call(self, endpoint: str, data: dict = None) -> dict: if data is None: data = {} - _LOGGER.debug("Requesting: %s", endpoint) + _LOGGER.debug("[%s] - requesting", endpoint) json_response = None try: @@ -40,7 +40,7 @@ def call(self, endpoint: str, data: dict = None) -> dict: urlencoded_body = urlencoded_body + "&" + urllib.parse.urlencode({"request_signature": request_signature}, encoding='utf-8') - _LOGGER.debug("Encoded body: %s", urlencoded_body) + _LOGGER.debug("[%s] - body: %s", endpoint, urlencoded_body) response = requests.post("http://{hostname}/{endpoint}".format(hostname=self.api_host, endpoint=endpoint), data=urlencoded_body, @@ -49,17 +49,17 @@ def call(self, endpoint: str, data: dict = None) -> dict: self.request_count = self.request_count + 1 - _LOGGER.debug("Response: %s", response) + _LOGGER.debug("[%s] - response code: %s", endpoint, response.status_code) json_response = response.json() except Exception as exception: _LOGGER.exception("Unable to fetch data from API: %s", exception) - _LOGGER.debug("JSON Response: %s", json_response) + _LOGGER.debug("[%s] - response body: %s", endpoint, json_response) if not json_response['success']: raise Exception('Failed to get data') else: - _LOGGER.debug('Successfully fetched data from API') + _LOGGER.debug('[%s] - successfully fetched data from API', endpoint) return json_response @@ -68,6 +68,8 @@ def login(self): "udid": self.udid }) + _LOGGER.debug('[api/user/token/challenge] - response body: %s', response.json()) + device_token = response.json()['devicetoken'] response = requests.post("http://" + self.api_host + "/api/user/token/response", data={ @@ -77,6 +79,8 @@ def login(self): "hashed": base64.b64encode(self.encode_signature(self.password, device_token)).decode() }) + _LOGGER.debug('[api/user/token/response] - response body: %s', response.json()) + if "devicetoken_encrypted" not in response.json(): raise Exception("Unable to login.") diff --git a/custom_components/alpha_innotec/gateway_api.py b/custom_components/alpha_innotec/gateway_api.py index ce7268c..abce3c3 100644 --- a/custom_components/alpha_innotec/gateway_api.py +++ b/custom_components/alpha_innotec/gateway_api.py @@ -26,7 +26,7 @@ def call(self, endpoint: str, data: dict = None) -> dict: if data is None: data = {} - _LOGGER.debug("Requesting: %s", endpoint) + _LOGGER.debug("[%s] - requesting", endpoint) json_response = None try: @@ -49,7 +49,7 @@ def call(self, endpoint: str, data: dict = None) -> dict: urlencoded_body = urlencoded_body + "&" + urllib.parse.urlencode({"request_signature": request_signature}, encoding='utf-8') - _LOGGER.debug("Encoded body: %s", urlencoded_body) + _LOGGER.debug("[%s] - body: %s", endpoint, urlencoded_body) response = requests.post("http://{hostname}/{endpoint}".format(hostname=self.api_host, endpoint=endpoint), data=urlencoded_body, @@ -58,26 +58,24 @@ def call(self, endpoint: str, data: dict = None) -> dict: self.request_count = self.request_count + 1 - _LOGGER.debug("Response: %s", response) + _LOGGER.debug("[%s] - response code: %s", endpoint, response.status_code) json_response = response.json() except Exception as exception: _LOGGER.exception("Unable to fetch data from API: %s", exception) - _LOGGER.debug("JSON Response: %s", json_response) + _LOGGER.debug("[%s] - response body: %s", endpoint, json_response) if not json_response['success']: raise Exception('Failed to get data') else: - _LOGGER.debug('Successfully fetched data from API') + _LOGGER.debug('[%s] - successfully fetched data from API', endpoint) return json_response def login(self): response = self.call("admin/login/check") - _LOGGER.debug("Login check response: %s", response) - if not response['success']: raise Exception("Unable to login") @@ -85,7 +83,6 @@ def login(self): def all_modules(self) -> dict: response = self.call("api/gateway/allmodules") - _LOGGER.debug(response) return response['modules']['rooms'] diff --git a/custom_components/alpha_innotec/sensor.py b/custom_components/alpha_innotec/sensor.py index 07e630e..217f888 100644 --- a/custom_components/alpha_innotec/sensor.py +++ b/custom_components/alpha_innotec/sensor.py @@ -50,6 +50,8 @@ async def async_setup_entry(hass, entry, async_add_entities): class AlphaInnotecSensorCoordinator(DataUpdateCoordinator, BaseCoordinator): """My custom coordinator.""" + data: list[Thermostat] + def __init__(self, hass: HomeAssistant, controller_api: ControllerAPI, gateway_api: GatewayAPI): """Initialize my coordinator.""" super().__init__( diff --git a/custom_components/alpha_innotec/structs/Valve.py b/custom_components/alpha_innotec/structs/Valve.py index 5a23c6e..74d3c8e 100644 --- a/custom_components/alpha_innotec/structs/Valve.py +++ b/custom_components/alpha_innotec/structs/Valve.py @@ -1,8 +1,9 @@ class Valve: - def __init__(self, identifier: str, name: str, instance: str, device_id: str, device_name: str, status: bool): + def __init__(self, identifier: str, name: str, instance: str, device_id: str, device_name: str, status: bool, used: bool): self.identifier = identifier self.name = name self.instance = instance self.device_id = device_id self.device_name = device_name self.status = status + self.used = used From 4ed6284c9c3cc289c235b131ebff692b3ec18660 Mon Sep 17 00:00:00 2001 From: Arjen Bos <1064589+arjenbos@users.noreply.github.com> Date: Thu, 2 Nov 2023 11:28:04 +0100 Subject: [PATCH 3/4] Replaced all coordinator with only one (#22) * Replaced all coordinator with only one * Add isort in quality check --- .github/workflows/quality.yaml | 22 ++++ custom_components/alpha_innotec/api.py | 2 +- .../alpha_innotec/base_coordinator.py | 64 ----------- .../alpha_innotec/binary_sensor.py | 86 +++------------ custom_components/alpha_innotec/climate.py | 103 +++++------------- .../alpha_innotec/coordinator.py | 101 +++++++++++++++++ custom_components/alpha_innotec/sensor.py | 78 +++++-------- requirements-dev.txt | 3 +- tests/test_api.py | 1 + tests/test_config_flow.py | 5 +- 10 files changed, 196 insertions(+), 269 deletions(-) delete mode 100644 custom_components/alpha_innotec/base_coordinator.py create mode 100644 custom_components/alpha_innotec/coordinator.py diff --git a/.github/workflows/quality.yaml b/.github/workflows/quality.yaml index d013a9d..187bcb8 100644 --- a/.github/workflows/quality.yaml +++ b/.github/workflows/quality.yaml @@ -22,8 +22,30 @@ jobs: # - name: pytest # run: | # pylint $(git ls-files '*.py') + isort: + runs-on: "ubuntu-latest" + steps: + - uses: "actions/checkout@v3" + - name: Set up Python 3.11 + uses: actions/setup-python@v4 + with: + python-version: "3.11" + cache: 'pip' + cache-dependency-path: | + **/setup.cfg + **/requirements*.txt + - name: Install dependencies + run: | + pip install -r requirements-test.txt + + - name: run + run: | + isort custom_components/alpha_innotec tests --check-only + test: runs-on: "ubuntu-latest" + needs: + - isort steps: - uses: "actions/checkout@v3" - name: Set up Python 3.11 diff --git a/custom_components/alpha_innotec/api.py b/custom_components/alpha_innotec/api.py index 6848881..4b0db10 100644 --- a/custom_components/alpha_innotec/api.py +++ b/custom_components/alpha_innotec/api.py @@ -1,9 +1,9 @@ import base64 import logging +from backports.pbkdf2 import pbkdf2_hmac from Crypto.Cipher import AES from Crypto.Hash import SHA256 -from backports.pbkdf2 import pbkdf2_hmac _LOGGER = logging.getLogger(__name__) diff --git a/custom_components/alpha_innotec/base_coordinator.py b/custom_components/alpha_innotec/base_coordinator.py deleted file mode 100644 index f93dc8a..0000000 --- a/custom_components/alpha_innotec/base_coordinator.py +++ /dev/null @@ -1,64 +0,0 @@ -"""Platform for sensor integration.""" -from __future__ import annotations - -import logging - -from homeassistant.core import HomeAssistant - -from . import GatewayAPI -from .const import MODULE_TYPE_SENSOR -from .controller_api import ControllerAPI -from .structs.Thermostat import Thermostat - -_LOGGER = logging.getLogger(__name__) - - -class BaseCoordinator: - - @staticmethod - async def get_thermostats(hass: HomeAssistant, gateway_api: GatewayAPI, controller_api: ControllerAPI) -> list[Thermostat]: - try: - rooms: dict = await hass.async_add_executor_job(gateway_api.all_modules) - - thermostats: list[Thermostat] = [] - - db_modules: dict = await hass.async_add_executor_job(gateway_api.db_modules) - room_list: dict = await hass.async_add_executor_job(controller_api.room_list) - - try: - for room_id in rooms: - room_module = rooms[room_id] - room = await hass.async_add_executor_job(controller_api.room_details, room_id, room_list) - - current_temperature = None - battery_percentage = None - - for module_id in room_module['modules']: - if module_id not in db_modules['modules']: - continue - - module_details = db_modules['modules'][module_id] - - if module_details["type"] == MODULE_TYPE_SENSOR: - current_temperature = module_details["currentTemperature"] - battery_percentage = module_details["battery"] - - thermostat = Thermostat( - identifier=room_id, - name=room['name'], - current_temperature=current_temperature, - desired_temperature=room.get('desiredTemperature'), - minimum_temperature=room.get('minTemperature'), - maximum_temperature=room.get('maxTemperature'), - cooling=room.get('cooling'), - cooling_enabled=room.get('coolingEnabled'), - battery_percentage=battery_percentage - ) - - thermostats.append(thermostat) - except Exception as exception: - _LOGGER.exception("There is an exception: %s", exception) - - return thermostats - except Exception as exception: - raise exception diff --git a/custom_components/alpha_innotec/binary_sensor.py b/custom_components/alpha_innotec/binary_sensor.py index 0900d96..aabf853 100644 --- a/custom_components/alpha_innotec/binary_sensor.py +++ b/custom_components/alpha_innotec/binary_sensor.py @@ -4,40 +4,37 @@ import logging from datetime import timedelta -from homeassistant.components.binary_sensor import BinarySensorEntity, BinarySensorEntityDescription, \ - BinarySensorDeviceClass +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription) +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.typing import UndefinedType -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import (CoordinatorEntity, + DataUpdateCoordinator) from .const import DOMAIN, MANUFACTURER +from .coordinator import AlphaInnotecCoordinator from .gateway_api import GatewayAPI from .structs.Valve import Valve _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass, entry, async_add_entities): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities): """Set up the sensor platform.""" _LOGGER.debug("Setting up binary sensors") - gateway_api = hass.data[DOMAIN][entry.entry_id]['gateway_api'] - - coordinator = AlphaInnotecBinarySensorCoordinator(hass, gateway_api) + coordinator = AlphaInnotecCoordinator(hass) await coordinator.async_config_entry_first_refresh() entities = [] - for valve in coordinator.data: + for valve in coordinator.data['valves']: entities.append(AlphaHomeBinarySensor( coordinator=coordinator, - name=valve.name, description=BinarySensorEntityDescription("", entity_registry_enabled_default=valve.used), valve=valve )) @@ -45,72 +42,14 @@ async def async_setup_entry(hass, entry, async_add_entities): async_add_entities(entities) -class AlphaInnotecBinarySensorCoordinator(DataUpdateCoordinator): - """My custom coordinator.""" - - data: list[Valve] - - def __init__(self, hass: HomeAssistant, gateway_api: GatewayAPI): - """Initialize my coordinator.""" - super().__init__( - hass, - _LOGGER, - name="Alpha Innotec Binary Coordinator", - update_interval=timedelta(seconds=30), - ) - - self.gateway_api: GatewayAPI = gateway_api - - async def _async_update_data(self) -> list[Valve]: - """Fetch data from API endpoint.""" - - db_modules: dict = await self.hass.async_add_executor_job(self.gateway_api.db_modules) - all_modules: dict = await self.hass.async_add_executor_job(self.gateway_api.all_modules) - - valves: list[Valve] = [] - - for module_id in db_modules["modules"]: - module = db_modules["modules"][module_id] - - if module["productId"] != 3: - continue - - for instance in module["instances"]: - valve_id = '0' + instance['instance'] + module['deviceid'][2:] - - used = False - - for room_id in all_modules: - if used is not True: - used = valve_id in all_modules[room_id]["modules"] - - valve = Valve( - identifier=valve_id, - name=module["name"] + '-' + instance['instance'], - instance=instance["instance"], - device_id=module["deviceid"], - device_name=module["name"], - status=instance["status"], - used=used - ) - - valves.append(valve) - - _LOGGER.debug("Finished getting valves from API") - - return valves - - class AlphaHomeBinarySensor(CoordinatorEntity, BinarySensorEntity): """Representation of a Binary Sensor.""" - def __init__(self, coordinator: AlphaInnotecBinarySensorCoordinator, name: str, description: BinarySensorEntityDescription, valve: Valve) -> None: + def __init__(self, coordinator: AlphaInnotecCoordinator, description: BinarySensorEntityDescription, valve: Valve) -> None: """Pass coordinator to CoordinatorEntity.""" super().__init__(coordinator, context=valve.identifier) self.entity_description = description - self._attr_name = name self.valve = valve - self._attr_is_on = valve.status @property def device_info(self) -> DeviceInfo: @@ -125,7 +64,7 @@ def device_info(self) -> DeviceInfo: @property def name(self) -> str | UndefinedType | None: - return self._attr_name + return self.valve.name @property def unique_id(self) -> str: @@ -142,10 +81,11 @@ def device_class(self) -> BinarySensorDeviceClass | None: @callback def _handle_coordinator_update(self) -> None: - for valve in self.coordinator.data: + for valve in self.coordinator.data['valves']: if valve.identifier == self.valve.identifier: self.valve = valve break _LOGGER.debug("Updating binary sensor: %s", self.valve.identifier) + self.async_write_ha_state() diff --git a/custom_components/alpha_innotec/climate.py b/custom_components/alpha_innotec/climate.py index d4ffe4b..f237842 100644 --- a/custom_components/alpha_innotec/climate.py +++ b/custom_components/alpha_innotec/climate.py @@ -4,44 +4,38 @@ import logging from datetime import timedelta -from homeassistant.components.climate import ClimateEntity, ClimateEntityDescription, ClimateEntityFeature, HVACAction, \ - HVACMode -from homeassistant.const import ( - ATTR_TEMPERATURE, - UnitOfTemperature, -) -from homeassistant.core import callback, HomeAssistant +from homeassistant.components.climate import (ClimateEntity, + ClimateEntityDescription, + ClimateEntityFeature, HVACAction, + HVACMode) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.typing import UndefinedType +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .base_coordinator import BaseCoordinator from .const import DOMAIN, MANUFACTURER from .controller_api import ControllerAPI, Thermostat -from .gateway_api import GatewayAPI +from .coordinator import AlphaInnotecCoordinator _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass, entry, async_add_entities): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities): """Set up the sensor platform.""" - controller_api = hass.data[DOMAIN][entry.entry_id]['controller_api'] - gateway_api = hass.data[DOMAIN][entry.entry_id]['gateway_api'] + _LOGGER.debug("Setting up climate sensors") - coordinator = AlphaInnotecClimateCoordinator(hass, controller_api, gateway_api) + coordinator = AlphaInnotecCoordinator(hass) await coordinator.async_config_entry_first_refresh() entities = [] - for thermostat in coordinator.data: + for thermostat in coordinator.data['thermostats']: entities.append(AlphaInnotecClimateSensor( coordinator=coordinator, - api=controller_api, - name=thermostat.name, description=ClimateEntityDescription(""), thermostat=thermostat )) @@ -49,32 +43,6 @@ async def async_setup_entry(hass, entry, async_add_entities): async_add_entities(entities) -class AlphaInnotecClimateCoordinator(DataUpdateCoordinator, BaseCoordinator): - """My custom coordinator.""" - - data: list[Thermostat] - - def __init__(self, hass: HomeAssistant, controller_api: ControllerAPI, gateway_api: GatewayAPI) -> None: - """Initialize my coordinator.""" - super().__init__( - hass, - _LOGGER, - name="Alpha Sensor", - update_interval=timedelta(seconds=30), - ) - - self.controller_api: ControllerAPI = controller_api - self.gateway_api: GatewayAPI = gateway_api - - async def _async_update_data(self) -> list[Thermostat]: - """Fetch data from API endpoint. - - This is the place to pre-process the data to lookup tables - so entities can quickly look up their data. - """ - return await self.get_thermostats(self.hass, self.gateway_api, self.controller_api) - - class AlphaInnotecClimateSensor(CoordinatorEntity, ClimateEntity): """Representation of a Sensor.""" @@ -84,15 +52,11 @@ class AlphaInnotecClimateSensor(CoordinatorEntity, ClimateEntity): ClimateEntityFeature.TARGET_TEMPERATURE ) - def __init__(self, coordinator: AlphaInnotecClimateCoordinator, api: ControllerAPI, name: str, description: ClimateEntityDescription, thermostat: Thermostat) -> None: + def __init__(self, coordinator: AlphaInnotecCoordinator, description: ClimateEntityDescription, thermostat: Thermostat) -> None: """Pass coordinator to CoordinatorEntity.""" super().__init__(coordinator, context=thermostat.identifier) - self.api = api self.entity_description = description - self._attr_name = name self.thermostat = thermostat - self._target_temperature = self.thermostat.desired_temperature - self._current_temperature = self.thermostat.current_temperature @property def device_info(self) -> DeviceInfo: @@ -101,7 +65,7 @@ def device_info(self) -> DeviceInfo: identifiers={ (DOMAIN, self.thermostat.identifier) }, - name=self._attr_name, + name=self.thermostat.name, manufacturer=MANUFACTURER, ) @@ -110,27 +74,18 @@ def unique_id(self) -> str: """Return unique ID for this device.""" return self.thermostat.identifier + @property + def name(self) -> str | UndefinedType | None: + return self.thermostat.name + @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" - - current_thermostat = None - - for thermostat in self.coordinator.data: + for thermostat in self.coordinator.data['thermostats']: if thermostat.identifier == self.thermostat.identifier: - current_thermostat = thermostat + self.thermostat = thermostat - if not current_thermostat: - return - - if current_thermostat == "unknown": - _LOGGER.warning("Current temperature not available for %s", current_thermostat.name) - return - - self._current_temperature = current_thermostat.current_temperature - self._target_temperature = current_thermostat.desired_temperature - - self.thermostat = current_thermostat + _LOGGER.debug("Updating climate sensor: %s", self.thermostat.identifier) self.async_write_ha_state() @@ -138,22 +93,18 @@ def _handle_coordinator_update(self) -> None: @property def current_temperature(self) -> float | None: """Return the current temperature.""" - if self._current_temperature == "unknown": - _LOGGER.warning("Current temperature not available for %s", self.thermostat.name) - return - - return self._current_temperature + return self.thermostat.current_temperature if isinstance(self.thermostat.current_temperature, float) else None @property def target_temperature(self) -> float: """Return the temperature we try to reach.""" - return self._target_temperature + return self.thermostat.desired_temperature if isinstance(self.thermostat.desired_temperature, float) else None async def async_set_temperature(self, **kwargs) -> None: """Set new target temperature.""" if (temp := kwargs.get(ATTR_TEMPERATURE)) is not None: - await self.hass.async_add_executor_job(self.api.set_temperature, self.thermostat.identifier, temp) - self._target_temperature = temp + await self.hass.async_add_executor_job(self.coordinator.hass.data[DOMAIN][self.coordinator.config_entry.entry_id]['controller_api'].set_temperature, self.thermostat.identifier, temp) + self.thermostat.desired_temperature = temp @property def hvac_mode(self) -> HVACMode | None: diff --git a/custom_components/alpha_innotec/coordinator.py b/custom_components/alpha_innotec/coordinator.py new file mode 100644 index 0000000..bed0613 --- /dev/null +++ b/custom_components/alpha_innotec/coordinator.py @@ -0,0 +1,101 @@ +import logging +from datetime import timedelta + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from . import ControllerAPI, GatewayAPI +from .const import DOMAIN, MODULE_TYPE_SENSOR +from .structs.Thermostat import Thermostat +from .structs.Valve import Valve + +_LOGGER = logging.getLogger(__name__) + + +class AlphaInnotecCoordinator(DataUpdateCoordinator): + + data: dict[str, list[Valve | Thermostat]] + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize my coordinator.""" + super().__init__( + hass, + _LOGGER, + name="Alpha Innotec", + update_interval=timedelta(seconds=60), + ) + + self.controller_api = hass.data[DOMAIN][self.config_entry.entry_id]['controller_api'] + self.gateway_api = hass.data[DOMAIN][self.config_entry.entry_id]['gateway_api'] + + async def _async_update_data(self) -> dict[str, list[Valve | Thermostat]]: + db_modules: dict = await self.hass.async_add_executor_job(self.gateway_api.db_modules) + all_modules: dict = await self.hass.async_add_executor_job(self.gateway_api.all_modules) + room_list: dict = await self.hass.async_add_executor_job(self.controller_api.room_list) + + thermostats: list[Thermostat] = [] + valves: list[Valve] = [] + + for room_id in all_modules: + room_module = all_modules[room_id] + room = await self.hass.async_add_executor_job(self.controller_api.room_details, room_id, room_list) + + current_temperature = None + battery_percentage = None + + for module_id in room_module['modules']: + if module_id not in db_modules['modules']: + continue + + module_details = db_modules['modules'][module_id] + + if module_details["type"] == MODULE_TYPE_SENSOR: + current_temperature = module_details["currentTemperature"] + battery_percentage = module_details["battery"] + + thermostat = Thermostat( + identifier=room_id, + name=room['name'], + current_temperature=current_temperature, + desired_temperature=room.get('desiredTemperature'), + minimum_temperature=room.get('minTemperature'), + maximum_temperature=room.get('maxTemperature'), + cooling=room.get('cooling'), + cooling_enabled=room.get('coolingEnabled'), + battery_percentage=battery_percentage + ) + + thermostats.append(thermostat) + + for module_id in db_modules["modules"]: + module = db_modules["modules"][module_id] + + if module["productId"] != 3: + continue + + for instance in module["instances"]: + valve_id = '0' + instance['instance'] + module['deviceid'][2:] + + used = False + + for room_id in all_modules: + if used is not True: + used = valve_id in all_modules[room_id]["modules"] + + valve = Valve( + identifier=valve_id, + name=module["name"] + '-' + instance['instance'], + instance=instance["instance"], + device_id=module["deviceid"], + device_name=module["name"], + status=instance["status"], + used=used + ) + + valves.append(valve) + + return { + 'valves': valves, + 'thermostats': thermostats + } diff --git a/custom_components/alpha_innotec/sensor.py b/custom_components/alpha_innotec/sensor.py index 217f888..ca2677f 100644 --- a/custom_components/alpha_innotec/sensor.py +++ b/custom_components/alpha_innotec/sensor.py @@ -2,44 +2,38 @@ from __future__ import annotations import logging -from datetime import timedelta +from datetime import date, datetime, timedelta +from decimal import Decimal -from homeassistant.components.sensor import SensorEntity, SensorEntityDescription, SensorDeviceClass +from homeassistant.components.sensor import (SensorDeviceClass, SensorEntity, + SensorEntityDescription) +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.typing import StateType, UndefinedType +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .base_coordinator import BaseCoordinator from .const import DOMAIN, MANUFACTURER -from .controller_api import ControllerAPI, Thermostat -from .gateway_api import GatewayAPI +from .controller_api import Thermostat +from .coordinator import AlphaInnotecCoordinator _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass, entry, async_add_entities): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities): """Set up the sensor platform.""" - controller_api = hass.data[DOMAIN][entry.entry_id]['controller_api'] - gateway_api = hass.data[DOMAIN][entry.entry_id]['gateway_api'] + _LOGGER.debug("Setting up sensors") - coordinator = AlphaInnotecSensorCoordinator(hass, controller_api, gateway_api) + coordinator = AlphaInnotecCoordinator(hass) await coordinator.async_config_entry_first_refresh() entities = [] - for thermostat in coordinator.data: - if thermostat.battery_percentage == "unknown": - _LOGGER.warning("Skipping %s because battery status is unknown.", thermostat.name) - continue - + for thermostat in coordinator.data["thermostats"]: entities.append(AlphaInnotecBatterySensor( coordinator=coordinator, - name=thermostat.name, description=SensorEntityDescription(""), thermostat=thermostat )) @@ -47,45 +41,18 @@ async def async_setup_entry(hass, entry, async_add_entities): async_add_entities(entities) -class AlphaInnotecSensorCoordinator(DataUpdateCoordinator, BaseCoordinator): - """My custom coordinator.""" - - data: list[Thermostat] - - def __init__(self, hass: HomeAssistant, controller_api: ControllerAPI, gateway_api: GatewayAPI): - """Initialize my coordinator.""" - super().__init__( - hass, - _LOGGER, - name="Alpha Sensor", - update_interval=timedelta(seconds=30), - ) - - self.controller_api: ControllerAPI = controller_api - self.gateway_api: GatewayAPI = gateway_api - - async def _async_update_data(self) -> list[Thermostat]: - """Fetch data from API endpoint. - - This is the place to pre-process the data to lookup tables - so entities can quickly look up their data. - """ - return await self.get_thermostats(self.hass, self.gateway_api, self.controller_api) - - class AlphaInnotecBatterySensor(CoordinatorEntity, SensorEntity): """Representation of a Sensor.""" _attr_device_class = SensorDeviceClass.BATTERY _attr_native_unit_of_measurement = "%" - def __init__(self, coordinator: AlphaInnotecSensorCoordinator, name: str, description: SensorEntityDescription, thermostat: Thermostat) -> None: + def __init__(self, coordinator: AlphaInnotecCoordinator, description: SensorEntityDescription, + thermostat: Thermostat) -> None: """Pass coordinator to CoordinatorEntity.""" super().__init__(coordinator, context=thermostat.identifier) self.entity_description = description - self._attr_name = name self.thermostat = thermostat - self._attr_native_value = thermostat.battery_percentage @property def device_info(self) -> DeviceInfo: @@ -94,7 +61,7 @@ def device_info(self) -> DeviceInfo: identifiers={ (DOMAIN, self.thermostat.identifier) }, - name=self._attr_name, + name=self.thermostat.name, manufacturer=MANUFACTURER, ) @@ -103,14 +70,21 @@ def unique_id(self) -> str: """Return unique ID for this device.""" return self.thermostat.identifier + @property + def name(self) -> str | UndefinedType | None: + return self.thermostat.name + + @property + def native_value(self): + return self.thermostat.battery_percentage if self.thermostat.battery_percentage != "unknown" else None + @callback def _handle_coordinator_update(self) -> None: - for thermostat in self.coordinator.data: + for thermostat in self.coordinator.data['thermostats']: if thermostat.identifier == self.thermostat.identifier: self.thermostat = thermostat break - _LOGGER.debug("Updating sensor: %s", self.thermostat.identifier) + _LOGGER.debug("Updating battery sensor: %s", self.thermostat.identifier) - self._attr_native_value = self.thermostat.battery_percentage self.async_write_ha_state() diff --git a/requirements-dev.txt b/requirements-dev.txt index 98c8509..1dc555b 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,4 +1,5 @@ backports.pbkdf2==0.1 pycryptodome==3.17 homeassistant==2023.9.0 -pylint \ No newline at end of file +pylint +isort \ No newline at end of file diff --git a/tests/test_api.py b/tests/test_api.py index cb2bfed..81090b0 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,4 +1,5 @@ from custom_components.alpha_innotec.api import BaseAPI + from . import VALID_CONFIG diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index bb7a24b..5fd3759 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -1,13 +1,14 @@ """Test the Alpha Innotec config flow.""" import json from unittest.mock import patch + from homeassistant import config_entries, setup from homeassistant.core import HomeAssistant - -from custom_components.alpha_innotec.const import DOMAIN from homeassistant.data_entry_flow import FlowResultType from pytest_homeassistant_custom_component.common import load_fixture +from custom_components.alpha_innotec.const import DOMAIN + from . import MODULE, VALID_CONFIG From 1db304659d32202f6d007c68638bcfefaf6419f6 Mon Sep 17 00:00:00 2001 From: Arjen Bos <1064589+arjenbos@users.noreply.github.com> Date: Thu, 2 Nov 2023 14:38:17 +0100 Subject: [PATCH 4/4] Report error when room is not ok and small bug fix --- custom_components/alpha_innotec/climate.py | 4 ++-- custom_components/alpha_innotec/coordinator.py | 3 +++ custom_components/alpha_innotec/sensor.py | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/custom_components/alpha_innotec/climate.py b/custom_components/alpha_innotec/climate.py index f237842..2714109 100644 --- a/custom_components/alpha_innotec/climate.py +++ b/custom_components/alpha_innotec/climate.py @@ -93,12 +93,12 @@ def _handle_coordinator_update(self) -> None: @property def current_temperature(self) -> float | None: """Return the current temperature.""" - return self.thermostat.current_temperature if isinstance(self.thermostat.current_temperature, float) else None + return self.thermostat.current_temperature if isinstance(self.thermostat.current_temperature, (float, int)) else None @property def target_temperature(self) -> float: """Return the temperature we try to reach.""" - return self.thermostat.desired_temperature if isinstance(self.thermostat.desired_temperature, float) else None + return self.thermostat.desired_temperature if isinstance(self.thermostat.desired_temperature, (float, int)) else None async def async_set_temperature(self, **kwargs) -> None: """Set new target temperature.""" diff --git a/custom_components/alpha_innotec/coordinator.py b/custom_components/alpha_innotec/coordinator.py index bed0613..744662e 100644 --- a/custom_components/alpha_innotec/coordinator.py +++ b/custom_components/alpha_innotec/coordinator.py @@ -54,6 +54,9 @@ async def _async_update_data(self) -> dict[str, list[Valve | Thermostat]]: current_temperature = module_details["currentTemperature"] battery_percentage = module_details["battery"] + if room.get('status', 'problem') == 'problem': + _LOGGER.error("According to the API there is a problem with: %s", room['name']) + thermostat = Thermostat( identifier=room_id, name=room['name'], diff --git a/custom_components/alpha_innotec/sensor.py b/custom_components/alpha_innotec/sensor.py index ca2677f..4ef05f1 100644 --- a/custom_components/alpha_innotec/sensor.py +++ b/custom_components/alpha_innotec/sensor.py @@ -76,7 +76,7 @@ def name(self) -> str | UndefinedType | None: @property def native_value(self): - return self.thermostat.battery_percentage if self.thermostat.battery_percentage != "unknown" else None + return self.thermostat.battery_percentage if isinstance(self.thermostat.battery_percentage, (float, int)) else None @callback def _handle_coordinator_update(self) -> None: