diff --git a/.github/workflows/hassfest.yaml b/.github/workflows/hassfest.yaml new file mode 100644 index 0000000..d203eda --- /dev/null +++ b/.github/workflows/hassfest.yaml @@ -0,0 +1,14 @@ +name: Validate with hassfest + +on: + push: + pull_request: + schedule: + - cron: '0 0 * * *' + +jobs: + validate: + runs-on: "ubuntu-latest" + steps: + - uses: "actions/checkout@v4" + - uses: "home-assistant/actions/hassfest@master" diff --git a/.github/workflows/quality.yaml b/.github/workflows/quality.yaml new file mode 100644 index 0000000..d013a9d --- /dev/null +++ b/.github/workflows/quality.yaml @@ -0,0 +1,58 @@ +name: Quality + +on: [push] + +jobs: +# pylint: +# 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-dev.txt +# +# - name: pytest +# run: | +# pylint $(git ls-files '*.py') + test: + 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 flake8 pytest pytest-cov + pip install -r requirements-test.txt + + - name: pytest + run: | + pytest --junitxml=pytest.xml --cov-report="xml:coverage.xml" --cov=custom_components/alpha_innotec tests/ + + - name: Pytest coverage comment + uses: MishaKav/pytest-coverage-comment@main + with: + pytest-xml-coverage-path: ./coverage.xml + junitxml-path: ./pytest.xml + title: HA Alpha Innotec + badge-title: HA Alpha Innotec Coverage + hide-badge: false + hide-report: false + create-new-comment: false + hide-comment: false + report-only-changed-files: false + remove-link-from-badge: false diff --git a/.github/workflows/validate.yaml b/.github/workflows/validate.yaml new file mode 100644 index 0000000..c422ec3 --- /dev/null +++ b/.github/workflows/validate.yaml @@ -0,0 +1,18 @@ +name: Validate + +on: + push: + pull_request: + schedule: + - cron: "0 0 * * *" + workflow_dispatch: + +jobs: + validate-hacs: + runs-on: "ubuntu-latest" + steps: + - uses: "actions/checkout@v3" + - name: HACS validation + uses: "hacs/action@main" + with: + category: "integration" diff --git a/.gitignore b/.gitignore index c18dd8d..09c756e 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,12 @@ -__pycache__/ +venv +.vscode +*.code-workspace +*.pyc +*.swp +__pycache__ +env +.mypy_cache +.coverage +coverage.xml +.secrets +.pytest_cache diff --git a/README.md b/README.md new file mode 100644 index 0000000..1a990cd --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +# Alpha Innotec Home Assistant integration + +![Version](https://img.shields.io/github/v/release/arjenbos/ha_alpha_innotec) +[![hacs_badge](https://img.shields.io/badge/HACS-Custom-41BDF5.svg)](https://github.com/hacs/integration) + +A custom Home Assistant integration for Alpha Innotec heat pumps. + +## Pre-install +1. Create a user for Home Assistant in the control box; it's **not recommended** to use a shared user. +2. Ensure Home Assistant is able to reach the control box via the local network. +3. Ensure Home Assistant is able to reach the gateway via the local network. +4. You need to have the password for the gateway. + +## Install +1. Add this repository as a custom repository in HACS. +2. Install the integration. + +## Disclaimer +- I cannot say that i'm a python developer. So, if you see code that's bad practice. Please, feel free to contribute to this integration via a pull request. +- I do not own an Alpha Innotec heat pump. If you have an issue, please provide sample data and information about your installation because testing without sample data is impossible. diff --git a/const.py b/const.py deleted file mode 100644 index dff9622..0000000 --- a/const.py +++ /dev/null @@ -1,7 +0,0 @@ -from homeassistant.const import Platform - -DOMAIN = "alpha" - -PLATFORMS = [ - Platform.CLIMATE, -] diff --git a/custom_components/__init__.py b/custom_components/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/__init__.py b/custom_components/alpha_innotec/__init__.py similarity index 57% rename from __init__.py rename to custom_components/alpha_innotec/__init__.py index 4cafb22..b2abf96 100644 --- a/__init__.py +++ b/custom_components/alpha_innotec/__init__.py @@ -1,23 +1,28 @@ import logging -from .api import ControllerApi -from .const import DOMAIN, PLATFORMS - -from homeassistant.core import HomeAssistant from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN, PLATFORMS +from .controller_api import ControllerAPI +from .gateway_api import GatewayAPI _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> True: """Set up Alpha Home from config entry.""" - _LOGGER.info("Setting up Alpha Home component") - - controller_api = ControllerApi(entry.data['controller_ip'], entry.data['username'], entry.data['password']) + _LOGGER.debug("Setting up Alpha Home component") + controller_api = ControllerAPI(entry.data['controller_ip'], entry.data['controller_username'], entry.data['controller_password']) controller_api = await hass.async_add_executor_job(controller_api.login) + gateway_api = GatewayAPI(entry.data['gateway_ip'], entry.data['gateway_password']) + gateway_api = await hass.async_add_executor_job(gateway_api.login) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = controller_api + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { + "controller_api": controller_api, + "gateway_api": gateway_api, + } await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/custom_components/alpha_innotec/api.py b/custom_components/alpha_innotec/api.py new file mode 100644 index 0000000..6848881 --- /dev/null +++ b/custom_components/alpha_innotec/api.py @@ -0,0 +1,61 @@ +import base64 +import logging + +from Crypto.Cipher import AES +from Crypto.Hash import SHA256 +from backports.pbkdf2 import pbkdf2_hmac + +_LOGGER = logging.getLogger(__name__) + + +class BaseAPI: + + def __init__(self, hostname: str, username: str, password: str) -> None: + self.api_host: str = hostname + self.username: str = username + self.password: str = password + + self.user_id: int | None = None + self.device_token_encrypted: str | None = None + self.device_token_decrypted: str | None = None + self.request_count: int = 0 + self.last_request_signature: str | None = None + self.udid: str = "homeassistant" + + @staticmethod + def string_to_charcodes(data: str) -> str: + a = "" + if len(data) > 0: + for i in range(len(data)): + t = str(ord(data[i])) + while len(t) < 3: + t = "0" + t + a += t + + return a + + def encode_signature(self, value: str, salt: str) -> str: + value = self.string_to_charcodes(value) + salt = self.string_to_charcodes(salt) + + original = pbkdf2_hmac("sha512", value.encode(), salt.encode(), 1) + + return original + + @staticmethod + def _prepare_request_body_for_hash(urlencoded_string: str) -> str: + """Replace dots for comma in case of temperature being passed""" + urlencoded_string = urlencoded_string.replace('%2C', ',').replace('%5B', '[').replace('%5D', ']') + # if(urlencodedString.find("temperature") != -1): + # urlencodedString = urlencodedString.replace('.', ',') + return urlencoded_string + + @staticmethod + def decrypt2(encrypted_data: str, key: str): + static_iv = 'D3GC5NQEFH13is04KD2tOg==' + crypt_key = SHA256.new() + crypt_key.update(bytes(key, 'utf-8')) + crypt_key = crypt_key.digest() + cipher = AES.new(crypt_key, AES.MODE_CBC, base64.b64decode(static_iv)) + + return cipher.decrypt(base64.b64decode(encrypted_data)).decode('ascii').strip('\x10') diff --git a/custom_components/alpha_innotec/base_coordinator.py b/custom_components/alpha_innotec/base_coordinator.py new file mode 100644 index 0000000..88b2d28 --- /dev/null +++ b/custom_components/alpha_innotec/base_coordinator.py @@ -0,0 +1,67 @@ +"""Platform for sensor integration.""" +from __future__ import annotations + +import logging + +from homeassistant.core import HomeAssistant + +from . import GatewayAPI +from .const import MODULE_TYPE_SENSOR, MODULE_TYPE_SENSE_CONTROL +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"] + elif module_details["type"] == MODULE_TYPE_SENSE_CONTROL: + 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 new file mode 100644 index 0000000..47edc20 --- /dev/null +++ b/custom_components/alpha_innotec/binary_sensor.py @@ -0,0 +1,132 @@ +"""Platform for binary sensor integration.""" +from __future__ import annotations + +import logging +from datetime import timedelta + +from homeassistant.components.binary_sensor import BinarySensorEntity, BinarySensorEntityDescription, \ + BinarySensorDeviceClass +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.typing import UndefinedType +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import DOMAIN, MANUFACTURER +from .gateway_api import GatewayAPI +from .structs.Valve import Valve + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, entry, 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 = AlphaCoordinator(hass, gateway_api) + + await coordinator.async_config_entry_first_refresh() + + entities = [] + + for valve in coordinator.data: + entities.append(AlphaHomeBinarySensor( + coordinator=coordinator, + name=valve.name, + description=BinarySensorEntityDescription(""), + valve=valve + )) + + async_add_entities(entities) + + +class AlphaCoordinator(DataUpdateCoordinator): + """My custom coordinator.""" + + 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. + + This is the place to pre-process the data to lookup tables + so entities can quickly look up their data. + """ + + db_modules: dict = await self.hass.async_add_executor_job(self.gateway_api.db_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 = Valve( + identifier=module["deviceid"] + '-' + instance['instance'], + name=module["name"] + '-' + instance['instance'], + instance=instance["instance"], + device_id=module["deviceid"], + device_name=module["name"], + status=instance["status"] + ) + + valves.append(valve) + + _LOGGER.debug("Finished getting valves from API") + + return valves + + +class AlphaHomeBinarySensor(CoordinatorEntity, BinarySensorEntity): + """Representation of a Sensor.""" + + def __init__(self, coordinator: AlphaCoordinator, name: str, 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 + + @property + def device_info(self) -> DeviceInfo: + """Return the device info.""" + return DeviceInfo( + identifiers={ + (DOMAIN, self.valve.device_id) + }, + name=self.valve.device_name, + manufacturer=MANUFACTURER, + ) + + @property + def name(self) -> str | UndefinedType | None: + return self._attr_name + + @property + def unique_id(self) -> str: + """Return unique ID for this device.""" + return self.valve.identifier + + @property + def is_on(self) -> bool | None: + return self.valve.status + + @property + def device_class(self) -> BinarySensorDeviceClass | None: + return BinarySensorDeviceClass.OPENING diff --git a/climate.py b/custom_components/alpha_innotec/climate.py similarity index 71% rename from climate.py rename to custom_components/alpha_innotec/climate.py index 64b4863..3cd0c5d 100644 --- a/climate.py +++ b/custom_components/alpha_innotec/climate.py @@ -1,24 +1,26 @@ """Platform for sensor integration.""" from __future__ import annotations +import logging +from datetime import timedelta + from homeassistant.components.climate import ClimateEntity, ClimateEntityDescription, ClimateEntityFeature, HVACAction, \ HVACMode - -from .api import ControllerApi, Thermostat -from .const import DOMAIN - -from datetime import timedelta -from homeassistant.core import callback +from homeassistant.const import ( + ATTR_TEMPERATURE, + UnitOfTemperature, +) +from homeassistant.core import callback, HomeAssistant from homeassistant.helpers.entity import DeviceInfo -import logging from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, ) -from homeassistant.const import ( - ATTR_TEMPERATURE, - UnitOfTemperature, -) + +from .base_coordinator import BaseCoordinator +from .const import DOMAIN, MANUFACTURER +from .controller_api import ControllerAPI, Thermostat +from .gateway_api import GatewayAPI _LOGGER = logging.getLogger(__name__) @@ -26,9 +28,10 @@ async def async_setup_entry(hass, entry, async_add_entities): """Set up the sensor platform.""" - controller_api = hass.data[DOMAIN][entry.entry_id] + controller_api = hass.data[DOMAIN][entry.entry_id]['controller_api'] + gateway_api = hass.data[DOMAIN][entry.entry_id]['gateway_api'] - coordinator = AlphaCoordinator(hass, controller_api) + coordinator = AlphaCoordinator(hass, controller_api, gateway_api) await coordinator.async_config_entry_first_refresh() @@ -46,10 +49,10 @@ async def async_setup_entry(hass, entry, async_add_entities): async_add_entities(entities) -class AlphaCoordinator(DataUpdateCoordinator): +class AlphaCoordinator(DataUpdateCoordinator, BaseCoordinator): """My custom coordinator.""" - def __init__(self, hass, controller_api: ControllerApi): + def __init__(self, hass: HomeAssistant, controller_api: ControllerAPI, gateway_api: GatewayAPI) -> None: """Initialize my coordinator.""" super().__init__( hass, @@ -58,19 +61,16 @@ def __init__(self, hass, controller_api: ControllerApi): update_interval=timedelta(seconds=30), ) - self.controller_api = controller_api + self.controller_api: ControllerAPI = controller_api + self.gateway_api: GatewayAPI = gateway_api - async def _async_update_data(self): + 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. """ - try: - thermostats = await self.hass.async_add_executor_job(self.controller_api.thermostats) - return thermostats - except Exception as exception: - raise exception + return await self.get_thermostats(self.hass, self.gateway_api, self.controller_api) class AlphaHomeSensor(CoordinatorEntity, ClimateEntity): @@ -82,7 +82,7 @@ class AlphaHomeSensor(CoordinatorEntity, ClimateEntity): ClimateEntityFeature.TARGET_TEMPERATURE ) - def __init__(self, coordinator, api: ControllerApi, name, description, thermostat: Thermostat): + def __init__(self, coordinator: AlphaCoordinator, api: ControllerAPI, name: str, description: ClimateEntityDescription, thermostat: Thermostat) -> None: """Pass coordinator to CoordinatorEntity.""" super().__init__(coordinator, context=thermostat.identifier) self.api = api @@ -100,7 +100,7 @@ def device_info(self) -> DeviceInfo: (DOMAIN, self.thermostat.identifier) }, name=self._attr_name, - manufacturer="Alpha Home", + manufacturer=MANUFACTURER, ) @property @@ -121,6 +121,10 @@ def _handle_coordinator_update(self) -> None: 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 @@ -128,32 +132,27 @@ def _handle_coordinator_update(self) -> None: self.async_write_ha_state() + @property - def current_temperature(self): + 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 @property - def target_temperature(self): + def target_temperature(self) -> float: """Return the temperature we try to reach.""" return self._target_temperature - async def async_set_temperature(self, **kwargs): + 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 - @property - def hvac_action(self) -> HVACAction | None: - if not self.thermostat.cooling_enabled: - return None - - if self.thermostat.cooling: - return HVACAction.COOLING - - return HVACAction.HEATING - @property def hvac_mode(self) -> HVACMode | None: """Return current hvac mode.""" diff --git a/config_flow.py b/custom_components/alpha_innotec/config_flow.py similarity index 65% rename from config_flow.py rename to custom_components/alpha_innotec/config_flow.py index 08ce135..3efd3e6 100644 --- a/config_flow.py +++ b/custom_components/alpha_innotec/config_flow.py @@ -1,32 +1,38 @@ -from .const import DOMAIN - import logging from typing import Any -from . import ControllerApi import voluptuous as vol - from homeassistant import config_entries, exceptions from homeassistant.data_entry_flow import FlowResult +from .const import DOMAIN +from .controller_api import ControllerAPI +from .gateway_api import GatewayAPI + _LOGGER = logging.getLogger(__name__) DATA_SCHEMA = vol.Schema({ + vol.Required("gateway_ip", description="Please enter the IP address of your gateway."): str, + vol.Required("gateway_password", description="Please enter the password of your gateway."): str, vol.Required("controller_ip", description="Please enter the IP address of your controller."): str, - vol.Required("username", description="The username for your controller."): str, - vol.Required("password", description="The password for your controller."): str + vol.Required("controller_username", description="The username for your controller."): str, + vol.Required("controller_password", description="The password for your controller."): str }) -def validate_input(data: dict): - controller_api = ControllerApi(data["controller_ip"], data["username"], data["password"]) +def validate_input(data: dict) -> dict: + controller_api = ControllerAPI(data["controller_ip"], data["controller_username"], data["controller_password"]) + gateway_api = GatewayAPI(data['gateway_ip'], data['gateway_password']) + try: controller_api.login() + gateway_api.login() system_information = controller_api.system_information() - except Exception: - raise CannotConnect - return system_information + return system_information + except Exception as exception: + _LOGGER.debug("Exception: %s", exception) + raise CannotConnect class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): diff --git a/custom_components/alpha_innotec/const.py b/custom_components/alpha_innotec/const.py new file mode 100644 index 0000000..4c5a17d --- /dev/null +++ b/custom_components/alpha_innotec/const.py @@ -0,0 +1,21 @@ +from homeassistant.const import Platform + +DOMAIN = "alpha_innotec" + +MANUFACTURER = "Alpha Innotec" + +PLATFORMS = [ + Platform.SENSOR, + Platform.BINARY_SENSOR, + Platform.CLIMATE, +] + +MODULE_TYPE_FLOOR = "floor" +MODULE_TYPE_SENSOR = "sensor" +MODULE_TYPE_SENSE_CONTROL = "sense_control" + +MODULE_TYPES = [ + MODULE_TYPE_FLOOR, + MODULE_TYPE_SENSOR, + MODULE_TYPE_SENSE_CONTROL +] diff --git a/api.py b/custom_components/alpha_innotec/controller_api.py similarity index 60% rename from api.py rename to custom_components/alpha_innotec/controller_api.py index 4116e0a..9e274b8 100644 --- a/api.py +++ b/custom_components/alpha_innotec/controller_api.py @@ -1,96 +1,22 @@ import base64 +import logging import urllib +from urllib.parse import unquote import requests -import logging -from backports.pbkdf2 import pbkdf2_hmac -from urllib.parse import unquote -from Crypto.Hash import SHA256 -from Crypto.Cipher import AES +from .api import BaseAPI +from .structs.Thermostat import Thermostat _LOGGER = logging.getLogger(__name__) -class ControllerApi: - - def __init__(self, hostname, username, password) -> None: - self.username = username - self.password = password - self.api_host = hostname - - self.user_id = None - self.device_token_encrypted = None - self.device_token_decrypted = None - self.request_count = 0 - self.last_request_signature = None - self.udid = "homeassistant" - - @staticmethod - def string_to_charcodes(data: str): - a = "" - if len(data) > 0: - for i in range(len(data)): - t = str(ord(data[i])) - while len(t) < 3: - t = "0" + t - a += t - - return a - - def encode_signature(self, value: str, salt: str) -> str: - value = self.string_to_charcodes(value) - salt = self.string_to_charcodes(salt) - - original = pbkdf2_hmac("sha512", value.encode(), salt.encode(), 1) - - return original - - def login(self): - response = requests.post("http://" + self.api_host + "/api/user/token/challenge", data={ - "udid": self.udid - }) - - device_token = response.json()['devicetoken'] - - response = requests.post("http://" + self.api_host + "/api/user/token/response", data={ - "login": self.username, - "token": device_token, - "udid": self.udid, - "hashed": base64.b64encode(self.encode_signature(self.password, device_token)).decode() - }) - - self.device_token_encrypted = response.json()['devicetoken_encrypted'] - self.user_id = response.json()['userid'] - - self.device_token_decrypted = self.decrypt2(response.json()['devicetoken_encrypted'], self.password) - - response = self.call("admin/login/check") - - if not response['success']: - raise Exception("Unable to login") +class ControllerAPI(BaseAPI): - return self + def call(self, endpoint: str, data: dict = None) -> dict: + if data is None: + data = {} - @staticmethod - def _prepare_request_body_for_hash(urlencoded_string): - urlencoded_string = urlencoded_string.replace('%2C', ',').replace('%5B', '[').replace('%5D', ']') - """Replace dots for comma in case of temperature being passed""" - # if(urlencodedString.find("temperature") != -1): - # urlencodedString = urlencodedString.replace('.', ',') - return urlencoded_string - - @staticmethod - def decrypt2(encrypted_data, key): - static_iv = 'D3GC5NQEFH13is04KD2tOg==' - crypt_key = SHA256.new() - crypt_key.update(bytes(key, 'utf-8')) - crypt_key = crypt_key.digest() - cipher = AES.new(crypt_key, AES.MODE_CBC, base64.b64decode(static_iv)) - - return cipher.decrypt(base64.b64decode(encrypted_data)).decode('ascii').strip('\x10') - - def call(self, endpoint: str, data: dict = {}): _LOGGER.debug("Requesting: %s", endpoint) json_response = None @@ -122,11 +48,13 @@ def call(self, endpoint: str, data: dict = {}): ) self.request_count = self.request_count + 1 + + _LOGGER.debug("Response: %s", response) json_response = response.json() except Exception as exception: _LOGGER.exception("Unable to fetch data from API: %s", exception) - _LOGGER.debug("Response: %s", json_response) + _LOGGER.debug("JSON Response: %s", json_response) if not json_response['success']: raise Exception('Failed to get data') @@ -135,28 +63,60 @@ def call(self, endpoint: str, data: dict = {}): return json_response - def room_list(self): + def login(self): + response = requests.post("http://" + self.api_host + "/api/user/token/challenge", data={ + "udid": self.udid + }) + + device_token = response.json()['devicetoken'] + + response = requests.post("http://" + self.api_host + "/api/user/token/response", data={ + "login": self.username, + "token": device_token, + "udid": self.udid, + "hashed": base64.b64encode(self.encode_signature(self.password, device_token)).decode() + }) + + if "devicetoken_encrypted" not in response.json(): + raise Exception("Unable to login.") + + self.device_token_encrypted = response.json()['devicetoken_encrypted'] + + self.user_id = response.json()['userid'] + + self.device_token_decrypted = self.decrypt2(response.json()['devicetoken_encrypted'], self.password) + + response = self.call("admin/login/check") + + if not response['success']: + raise Exception("Unable to login") + + return self + + def room_list(self) -> dict: return self.call("api/room/list") - def room_details(self, identifier): - room_list = self.room_list() + def room_details(self, identifier, room_list: dict = None) -> dict | None: + if room_list is None: + room_list = self.room_list() + for group in room_list['groups']: for room in group['rooms']: - if room['id'] == identifier: + if room['id'] == int(identifier): return room return None - def system_information(self): + def system_information(self) -> dict: return self.call('admin/systeminformation/get') - def set_temperature(self, room_identifier, temperature: float): + def set_temperature(self, room_identifier, temperature: float) -> dict: return self.call('api/room/settemperature', { "roomid": room_identifier, "temperature": temperature }) - def thermostats(self): + def thermostats(self) -> list[Thermostat]: thermostats: list[Thermostat] = [] try: @@ -180,22 +140,3 @@ def thermostats(self): _LOGGER.exception("There is an exception: %s", exception) return thermostats - - -class Thermostat: - def __init__(self, identifier: str, name: str, module: str = None, current_temperature: float = None, - minimum_temperature: float = None, maximum_temperature: float = None, - desired_temperature: float = None, battery_percentage: int = None, - cooling: bool = None, - cooling_enabled: bool = False - ): - self.identifier = identifier - self.module = module - self.name = name - self.current_temperature = current_temperature - self.minimum_temperature = minimum_temperature - self.maximum_temperature = maximum_temperature - self.battery_percentage = battery_percentage - self.desired_temperature = desired_temperature - self.cooling = cooling - self.cooling_enabled = cooling_enabled diff --git a/custom_components/alpha_innotec/gateway_api.py b/custom_components/alpha_innotec/gateway_api.py new file mode 100644 index 0000000..ce7268c --- /dev/null +++ b/custom_components/alpha_innotec/gateway_api.py @@ -0,0 +1,100 @@ +import base64 +import logging +import urllib +from urllib.parse import unquote + +import requests + +from .api import BaseAPI + +_LOGGER = logging.getLogger(__name__) + + +class GatewayAPI(BaseAPI): + username: str = "gateway" + + def __init__(self, hostname: str, password: str) -> None: + super().__init__(hostname, self.username, password) + self.password: str = password + self.api_host: str = hostname + + self.request_count: int = 0 + self.last_request_signature: str | None = None + self.udid: str = "homeassistant" + + def call(self, endpoint: str, data: dict = None) -> dict: + if data is None: + data = {} + + _LOGGER.debug("Requesting: %s", endpoint) + json_response = None + + try: + data['userlogin'] = self.username + data['udid'] = self.udid + data['reqcount'] = self.request_count + + post_data_sorted = sorted(data.items(), key=lambda val: val[0]) + + urlencoded_body = urllib.parse.urlencode(post_data_sorted, encoding='utf-8') + urlencoded_body_prepared_for_hash = self._prepare_request_body_for_hash(urlencoded_body) + urlencoded_body_prepared_for_hash = urlencoded_body_prepared_for_hash.replace('&', '|') + urlencoded_body_prepared_for_hash = urlencoded_body_prepared_for_hash + "|" + + request_signature = base64.b64encode( + self.encode_signature(urlencoded_body_prepared_for_hash, self.password)).decode() + + self.last_request_signature = request_signature + + urlencoded_body = urlencoded_body + "&" + urllib.parse.urlencode({"request_signature": request_signature}, + encoding='utf-8') + + _LOGGER.debug("Encoded body: %s", urlencoded_body) + + response = requests.post("http://{hostname}/{endpoint}".format(hostname=self.api_host, endpoint=endpoint), + data=urlencoded_body, + headers={'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'} + ) + + self.request_count = self.request_count + 1 + + _LOGGER.debug("Response: %s", response) + + 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) + + if not json_response['success']: + raise Exception('Failed to get data') + else: + _LOGGER.debug('Successfully fetched data from API') + + 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") + + return self + + def all_modules(self) -> dict: + response = self.call("api/gateway/allmodules") + _LOGGER.debug(response) + + return response['modules']['rooms'] + + def db_modules(self) -> dict: + return self.call("api/gateway/dbmodules") + + def get_module_details(self, module_id) -> dict | None: + response = self.db_modules() + if module_id in response['modules']: + return response['modules'][module_id] + + return None diff --git a/custom_components/alpha_innotec/manifest.json b/custom_components/alpha_innotec/manifest.json new file mode 100644 index 0000000..38cdcbe --- /dev/null +++ b/custom_components/alpha_innotec/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "alpha_innotec", + "name": "Alpha Innotec", + "codeowners": ["@arjenbos"], + "config_flow": true, + "documentation": "https://github.com/arjenbos/ha_alpha_innotec", + "integration_type": "hub", + "iot_class": "local_polling", + "issue_tracker": "https://github.com/arjenbos/ha_alpha_innotec/issues", + "requirements": ["backports.pbkdf2==0.1", "pycryptodome==3.17"], + "version": "1.1.0" +} diff --git a/custom_components/alpha_innotec/sensor.py b/custom_components/alpha_innotec/sensor.py new file mode 100644 index 0000000..cb9d392 --- /dev/null +++ b/custom_components/alpha_innotec/sensor.py @@ -0,0 +1,102 @@ +"""Platform for sensor integration.""" +from __future__ import annotations + +import logging +from datetime import timedelta + +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription, SensorDeviceClass +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .base_coordinator import BaseCoordinator +from .const import DOMAIN, MANUFACTURER +from .controller_api import ControllerAPI, Thermostat +from .gateway_api import GatewayAPI + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, entry, 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'] + + coordinator = AlphaCoordinator(hass, controller_api, gateway_api) + + 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 + + entities.append(AlphaHomeBatterySensor( + coordinator=coordinator, + name=thermostat.name, + description=SensorEntityDescription(""), + thermostat=thermostat + )) + + async_add_entities(entities) + + +class AlphaCoordinator(DataUpdateCoordinator, BaseCoordinator): + """My custom coordinator.""" + + 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 AlphaHomeBatterySensor(CoordinatorEntity, SensorEntity): + """Representation of a Sensor.""" + + _attr_device_class = SensorDeviceClass.BATTERY + _attr_native_unit_of_measurement = "%" + + def __init__(self, coordinator: AlphaCoordinator, name: str, 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: + """Return the device info.""" + return DeviceInfo( + identifiers={ + (DOMAIN, self.thermostat.identifier) + }, + name=self._attr_name, + manufacturer=MANUFACTURER, + ) + + @property + def unique_id(self) -> str: + """Return unique ID for this device.""" + return self.thermostat.identifier diff --git a/strings.json b/custom_components/alpha_innotec/strings.json similarity index 56% rename from strings.json rename to custom_components/alpha_innotec/strings.json index da22ed9..cf974e3 100644 --- a/strings.json +++ b/custom_components/alpha_innotec/strings.json @@ -3,11 +3,13 @@ "flow_title": "{name} ({host})", "step": { "user": { - "description": "Set up Alpha Home integration.", + "description": "Setup Alpha Innotec.", "data": { + "gateway_ip": "[%key:common::config_flow::data::gateway_ip%]", + "gateway_password": "[%key:common::config_flow::data::gateway_password%]", "controller_ip": "[%key:common::config_flow::data::controller_ip%]", - "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]" + "controller_username": "[%key:common::config_flow::data::controller_username%]", + "controller_password": "[%key:common::config_flow::data::controller_password%]" } } }, diff --git a/custom_components/alpha_innotec/structs/Thermostat.py b/custom_components/alpha_innotec/structs/Thermostat.py new file mode 100644 index 0000000..c57d411 --- /dev/null +++ b/custom_components/alpha_innotec/structs/Thermostat.py @@ -0,0 +1,16 @@ +class Thermostat: + def __init__(self, identifier: str, name: str, current_temperature: float = None, + minimum_temperature: float = None, maximum_temperature: float = None, + desired_temperature: float = None, battery_percentage: int = None, + cooling: bool = None, + cooling_enabled: bool = False + ): + self.identifier = identifier + self.name = name + self.current_temperature = current_temperature + self.minimum_temperature = minimum_temperature + self.maximum_temperature = maximum_temperature + self.battery_percentage = battery_percentage + self.desired_temperature = desired_temperature + self.cooling = cooling + self.cooling_enabled = cooling_enabled diff --git a/custom_components/alpha_innotec/structs/Valve.py b/custom_components/alpha_innotec/structs/Valve.py new file mode 100644 index 0000000..5a23c6e --- /dev/null +++ b/custom_components/alpha_innotec/structs/Valve.py @@ -0,0 +1,8 @@ +class Valve: + def __init__(self, identifier: str, name: str, instance: str, device_id: str, device_name: str, status: bool): + self.identifier = identifier + self.name = name + self.instance = instance + self.device_id = device_id + self.device_name = device_name + self.status = status diff --git a/translations/en.json b/custom_components/alpha_innotec/translations/en.json similarity index 59% rename from translations/en.json rename to custom_components/alpha_innotec/translations/en.json index 640bfc9..e5a17c9 100644 --- a/translations/en.json +++ b/custom_components/alpha_innotec/translations/en.json @@ -10,9 +10,12 @@ "step": { "user": { "data": { + "gateway_ip": "Gateway IP", + "gateway_username": "Gateway username", + "gateway_password": "Gateway password", "controller_ip": "Controller IP", - "username": "Username", - "password": "Password" + "controller_username": "Controller username", + "controller_password": "Controller password" }, "description": "Set up Alpha Home." } diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..2c01a2c --- /dev/null +++ b/hacs.json @@ -0,0 +1,4 @@ +{ + "name": "Alpha Innotec", + "render_readme": true +} \ No newline at end of file diff --git a/manifest.json b/manifest.json deleted file mode 100644 index 96dfa9c..0000000 --- a/manifest.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "domain": "alpha", - "name": "Alpha Home custom integration", - "codeowners": [], - "documentation": "https://arjen.dev", - "integration_type": "hub", - "iot_class": "local_polling", - "requirements": ["backports.pbkdf2==0.1", "crypto==1.4.1", "pycryptodome==3.17"], - "version": "0.1.0", - "config_flow": true - } \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..98c8509 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,4 @@ +backports.pbkdf2==0.1 +pycryptodome==3.17 +homeassistant==2023.9.0 +pylint \ No newline at end of file diff --git a/requirements-test.txt b/requirements-test.txt new file mode 100644 index 0000000..89a3a43 --- /dev/null +++ b/requirements-test.txt @@ -0,0 +1,6 @@ +-r requirements-dev.txt + +aiodiscover +pytest +pytest-homeassistant-custom-component +pytest-asyncio diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..c3b2916 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,3 @@ +[tool:pytest] +testpaths = tests +asyncio_mode = auto \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..09b7797 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,11 @@ +"""Tests for the Alpa Innotec integration.""" + +MODULE = "custom_components.alpha_innotec" + +VALID_CONFIG = { + "gateway_ip": "127.0.0.1", + "gateway_password": "verysafe", + "controller_ip": "127.0.0.2", + "controller_username": "testing", + "controller_password": "alsoverysafe" +} \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..cc5071d --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,7 @@ +"""Fixtures for testing.""" +import pytest + + +@pytest.fixture(autouse=True) +def auto_enable_custom_integrations(enable_custom_integrations): + yield diff --git a/tests/fixtures/controller_api_room_list.json b/tests/fixtures/controller_api_room_list.json new file mode 100644 index 0000000..53388e3 --- /dev/null +++ b/tests/fixtures/controller_api_room_list.json @@ -0,0 +1,159 @@ +{ + "success": true, + "message": "", + "loginRejected": false, + "groups": [ + { + "groupid": 1, + "name": "rooms", + "rooms": [ + { + "id": 1, + "appid": "01010002", + "actualTemperature": 21, + "isComfortMode": false, + "desiredTemperature": 22, + "roomstatus": 51, + "desiredTempDay": 20, + "desiredTempDay2": 18, + "desiredTempNight": 18, + "scheduleTempMin": 15, + "scheduleTempMax": 28, + "minTemperature": 18, + "maxTemperature": 28, + "cooling": false, + "coolingEnabled": true, + "imagepath": "/assets/images/room/default.png", + "name": "Room 1", + "orderindex": 1, + "originalName": "Room 1", + "status": "ok", + "groupid": 1, + "windowPosition": 0 + }, + { + "id": 2, + "appid": "01020002", + "actualTemperature": 20.5, + "isComfortMode": false, + "desiredTemperature": 20, + "roomstatus": 41, + "desiredTempDay": 20, + "desiredTempDay2": 18, + "desiredTempNight": 18, + "scheduleTempMin": 15, + "scheduleTempMax": 28, + "minTemperature": 18, + "maxTemperature": 28, + "cooling": false, + "coolingEnabled": true, + "imagepath": "/assets/images/room/default.png", + "name": "Room 2", + "orderindex": 2, + "originalName": "Room 3", + "status": "ok", + "groupid": 1, + "windowPosition": 0 + }, + { + "id": 3, + "appid": "01030002", + "actualTemperature": 22, + "isComfortMode": false, + "desiredTemperature": 22.5, + "roomstatus": 51, + "desiredTempDay": 20, + "desiredTempDay2": 18, + "desiredTempNight": 18, + "scheduleTempMin": 15, + "scheduleTempMax": 28, + "minTemperature": 18, + "maxTemperature": 28, + "cooling": false, + "coolingEnabled": true, + "imagepath": "/assets/images/room/default.png", + "name": "Room 3", + "orderindex": 3, + "originalName": "Room 3", + "status": "ok", + "groupid": 1, + "windowPosition": 0 + }, + { + "id": 4, + "appid": "01040002", + "isComfortMode": false, + "desiredTemperature": 20, + "roomstatus": 99, + "desiredTempDay": 20, + "desiredTempDay2": 18, + "desiredTempNight": 18, + "scheduleTempMin": 15, + "scheduleTempMax": 28, + "minTemperature": 18, + "maxTemperature": 28, + "cooling": false, + "coolingEnabled": true, + "imagepath": "/assets/images/room/default.png", + "name": "Room 4", + "orderindex": 4, + "originalName": "Room 4", + "status": "problem", + "groupid": 1, + "windowPosition": 0 + }, + { + "id": 5, + "appid": "01050002", + "actualTemperature": 22, + "isComfortMode": false, + "desiredTemperature": 22.5, + "roomstatus": 51, + "desiredTempDay": 20, + "desiredTempDay2": 18, + "desiredTempNight": 18, + "scheduleTempMin": 15, + "scheduleTempMax": 28, + "minTemperature": 18, + "maxTemperature": 28, + "cooling": false, + "coolingEnabled": true, + "imagepath": "/assets/images/room/default.png", + "name": "Room 5", + "orderindex": 5, + "originalName": "Room 5", + "status": "ok", + "groupid": 1, + "windowPosition": 0 + }, + { + "id": 6, + "appid": "01060002", + "actualTemperature": 21.5, + "isComfortMode": false, + "desiredTemperature": 20, + "roomstatus": 41, + "desiredTempDay": 20, + "desiredTempDay2": 18, + "desiredTempNight": 18, + "scheduleTempMin": 15, + "scheduleTempMax": 28, + "minTemperature": 18, + "maxTemperature": 28, + "cooling": false, + "coolingEnabled": true, + "imagepath": "/assets/images/room/default.png", + "name": "Room 6", + "orderindex": 6, + "originalName": "Room 6", + "status": "ok", + "groupid": 1, + "windowPosition": 0 + } + ], + "orderindex": 1 + } + ], + "language": "en", + "performance": 0.907 +} \ No newline at end of file diff --git a/tests/fixtures/controller_api_systeminformation.json b/tests/fixtures/controller_api_systeminformation.json new file mode 100644 index 0000000..0b3f8b8 --- /dev/null +++ b/tests/fixtures/controller_api_systeminformation.json @@ -0,0 +1,8 @@ +{ + "success":true, + "message":"", + "name":"My Heatpump", + "location":"Example Street, 1337 AA Netherlands", + "language":"en", + "performance":1.337 +} diff --git a/tests/fixtures/gateway_api_allmodules.json b/tests/fixtures/gateway_api_allmodules.json new file mode 100644 index 0000000..b32e41e --- /dev/null +++ b/tests/fixtures/gateway_api_allmodules.json @@ -0,0 +1,222 @@ +{ + "success": true, + "message": "", + "loginRejected": false, + "modules": { + "count": "21", + "rooms": { + "1": { + "name": "Room 1", + "minTemperature": 18, + "maxTemperature": 28, + "modules": { + "00020DB7C9C5804": { + "id": "00020DB7C9C5804", + "status": 0, + "type": "sensor", + "number": 0, + "batteryStatus": 76, + "moduleInstance": "0" + }, + "03050DB7C9C5812": { + "id": "03050DB7C9C5812", + "status": 0, + "type": "floor", + "number": 2, + "batteryStatus": 127, + "moduleInstance": "3" + }, + "04050DB7C9C5812": { + "id": "04050DB7C9C5812", + "status": 0, + "type": "floor", + "number": 3, + "batteryStatus": 127, + "moduleInstance": "4" + } + } + }, + "2": { + "name": "Room 2", + "minTemperature": 18, + "maxTemperature": 28, + "modules": { + "00020DB7C9C5807": { + "id": "00020DB7C9C5807", + "status": 0, + "type": "sensor", + "number": 0, + "batteryStatus": 100, + "moduleInstance": "0" + }, + "01050DB7C9C580A": { + "id": "01050DB7C9C580A", + "status": 0, + "type": "floor", + "number": 1, + "batteryStatus": 127, + "moduleInstance": "1" + }, + "02050DB7C9C580A": { + "id": "02050DB7C9C580A", + "status": 0, + "type": "floor", + "number": 2, + "batteryStatus": 127, + "moduleInstance": "2" + }, + "03050DB7C9C580A": { + "id": "03050DB7C9C580A", + "status": 0, + "type": "floor", + "number": 3, + "batteryStatus": 127, + "moduleInstance": "3" + }, + "04050DB7C9C580A": { + "id": "04050DB7C9C580A", + "status": 0, + "type": "floor", + "number": 4, + "batteryStatus": 127, + "moduleInstance": "4" + } + } + }, + "3": { + "name": "Room 3", + "minTemperature": 18, + "maxTemperature": 28, + "modules": { + "00020DB7C9C5806": { + "id": "00020DB7C9C5806", + "status": 0, + "type": "sensor", + "number": 0, + "batteryStatus": 66, + "moduleInstance": "0" + }, + "01050DB7C9C580C": { + "id": "01050DB7C9C580C", + "status": 0, + "type": "floor", + "number": 1, + "batteryStatus": 127, + "moduleInstance": "1" + }, + "02050DB7C9C580C": { + "id": "02050DB7C9C580C", + "status": 0, + "type": "floor", + "number": 2, + "batteryStatus": 127, + "moduleInstance": "2" + } + } + }, + "4": { + "name": "Room 4", + "minTemperature": 18, + "maxTemperature": 28, + "modules": { + "00020DB7C9C580F": { + "id": "00020DB7C9C580F", + "status": 2, + "type": "sensor", + "number": 0, + "batteryStatus": 126, + "moduleInstance": "0" + }, + "04050DB7C9C580C": { + "id": "04050DB7C9C580C", + "status": 0, + "type": "floor", + "number": 1, + "batteryStatus": 127, + "moduleInstance": "4" + }, + "05050DB7C9C580C": { + "id": "05050DB7C9C580C", + "status": 0, + "type": "floor", + "number": 2, + "batteryStatus": 127, + "moduleInstance": "5" + } + } + }, + "5": { + "name": "Room 5", + "minTemperature": 18, + "maxTemperature": 28, + "modules": { + "00020DB7C9C5805": { + "id": "00020DB7C9C5805", + "status": 0, + "type": "sensor", + "number": 0, + "batteryStatus": 68, + "moduleInstance": "0" + }, + "03050DB7C9C580C": { + "id": "03050DB7C9C580C", + "status": 0, + "type": "floor", + "number": 1, + "batteryStatus": 127, + "moduleInstance": "3" + } + } + }, + "6": { + "name": "Room 6", + "minTemperature": 18, + "maxTemperature": 28, + "modules": { + "00020DB7C9C580D": { + "id": "00020DB7C9C580D", + "status": 0, + "type": "sensor", + "number": 0, + "batteryStatus": 74, + "moduleInstance": "0" + }, + "02050DB7C9C5812": { + "id": "02050DB7C9C5812", + "status": 0, + "type": "floor", + "number": 1, + "batteryStatus": 127, + "moduleInstance": "2" + }, + "05050DB7C9C5812": { + "id": "05050DB7C9C5812", + "status": 0, + "type": "floor", + "number": 2, + "batteryStatus": 127, + "moduleInstance": "5" + }, + "06050DB7C9C5812": { + "id": "06050DB7C9C5812", + "status": 0, + "type": "floor", + "number": 3, + "batteryStatus": 127, + "moduleInstance": "6" + }, + "01050DB7C9C5812": { + "id": "01050DB7C9C5812", + "status": 0, + "type": "floor", + "number": 4, + "batteryStatus": 127, + "moduleInstance": "1" + } + } + } + } + }, + "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 new file mode 100644 index 0000000..2a52b43 --- /dev/null +++ b/tests/fixtures/gateway_api_dbmodules.json @@ -0,0 +1,280 @@ +{ + "success": true, + "message": "", + "loginRejected": false, + "modules": { + "00020DB7C9C5804": { + "id": 3, + "deviceid": "00020DB7C9C5804", + "nodeid": "4", + "name": "Room 1", + "room": "Room 1", + "currentTemperature": 21.1, + "battery": 76, + "isFailed": false, + "lastResponse": 1698499773000, + "interviewDone": true, + "vendorId": 380, + "productId": 2, + "type": "sensor" + }, + "00020DB7C9C5805": { + "id": 4, + "deviceid": "00020DB7C9C5805", + "nodeid": "5", + "name": "Room 5", + "room": "Room 5", + "currentTemperature": 21.9, + "battery": 68, + "isFailed": false, + "lastResponse": 1698499513000, + "interviewDone": true, + "vendorId": 380, + "productId": 2, + "type": "sensor" + }, + "00020DB7C9C5806": { + "id": 5, + "deviceid": "00020DB7C9C5806", + "nodeid": "6", + "name": "Room 3", + "room": "Room 3", + "currentTemperature": 21.9, + "battery": 66, + "isFailed": false, + "lastResponse": 1698496669000, + "interviewDone": true, + "vendorId": 380, + "productId": 2, + "type": "sensor" + }, + "00020DB7C9C5807": { + "id": 6, + "deviceid": "00020DB7C9C5807", + "nodeid": "7", + "name": "Room 2", + "room": "Room 2", + "currentTemperature": 22.7, + "battery": 100, + "isFailed": false, + "lastResponse": 1698499739000, + "interviewDone": true, + "vendorId": 380, + "productId": 2, + "type": "sensor" + }, + "00050DB7C9C580A": { + "id": 9, + "deviceid": "00050DB7C9C580A", + "nodeid": "10", + "name": "Verdeler 1e", + "room": "", + "currentTemperature": "off", + "battery": "off", + "binarySwitch": false, + "isFailed": false, + "lastResponse": 1698499638000, + "interviewDone": true, + "vendorId": 380, + "productId": 3, + "type": "floor", + "instances": [ + { + "instance": "1", + "room": 0, + "status": false + }, + { + "instance": "2", + "room": 0, + "status": false + }, + { + "instance": "3", + "room": 0, + "status": false + }, + { + "instance": "4", + "room": 0, + "status": false + }, + { + "instance": "5", + "room": 0, + "status": false + }, + { + "instance": "6", + "room": 0, + "status": false + }, + { + "instance": "7", + "room": 0, + "status": false + }, + { + "instance": "8", + "room": 0, + "status": false + } + ] + }, + "00050DB7C9C580C": { + "id": 12, + "deviceid": "00050DB7C9C580C", + "nodeid": "12", + "name": "Verdeler 2e", + "room": "", + "currentTemperature": "off", + "battery": "off", + "binarySwitch": false, + "isFailed": false, + "lastResponse": 1698499592000, + "interviewDone": true, + "vendorId": 380, + "productId": 3, + "type": "floor", + "instances": [ + { + "instance": "1", + "room": 0, + "status": false + }, + { + "instance": "2", + "room": 0, + "status": false + }, + { + "instance": "3", + "room": 0, + "status": true + }, + { + "instance": "4", + "room": 0, + "status": true + }, + { + "instance": "5", + "room": 0, + "status": true + }, + { + "instance": "6", + "room": 0, + "status": false + }, + { + "instance": "7", + "room": 0, + "status": false + }, + { + "instance": "8", + "room": 0, + "status": false + } + ] + }, + "00020DB7C9C580D": { + "id": 14, + "deviceid": "00020DB7C9C580D", + "nodeid": "13", + "name": "Room 6", + "room": "Room 6", + "currentTemperature": 22, + "battery": 74, + "isFailed": false, + "lastResponse": 1698499411000, + "interviewDone": true, + "vendorId": 380, + "productId": 2, + "type": "sensor" + }, + "00020DB7C9C580F": { + "id": 16, + "deviceid": "00020DB7C9C580F", + "nodeid": "15", + "name": "Room 4", + "room": "Room 4", + "currentTemperature": "unknown", + "battery": "unknown", + "isFailed": false, + "lastResponse": 1698412444000, + "interviewDone": true, + "vendorId": 380, + "productId": 2, + "type": "sensor" + }, + "00050DB7C9C5812": { + "id": 19, + "deviceid": "00050DB7C9C5812", + "nodeid": "18", + "name": "Verdeler BG", + "room": "", + "currentTemperature": "off", + "battery": "off", + "binarySwitch": false, + "isFailed": false, + "lastResponse": 1698499603000, + "interviewDone": true, + "vendorId": 380, + "productId": 3, + "type": "floor", + "instances": [ + { + "instance": "1", + "room": 0, + "status": false + }, + { + "instance": "2", + "room": 0, + "status": false + }, + { + "instance": "3", + "room": 0, + "status": false + }, + { + "instance": "4", + "room": 0, + "status": false + }, + { + "instance": "5", + "room": 0, + "status": false + }, + { + "instance": "6", + "room": 0, + "status": false + }, + { + "instance": "7", + "room": 0, + "status": true + }, + { + "instance": "8", + "room": 0, + "status": true + } + ] + } + }, + "currentTime": 1698499793476, + "controllerState": "zway_controller_state_idle", + "reorganization": { + "startTime": 1698499755300, + "running": true, + "duration": 2700000 + }, + "language": "en", + "performance": 0.138 +} \ No newline at end of file diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..cb2bfed --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,24 @@ +from custom_components.alpha_innotec.api import BaseAPI +from . import VALID_CONFIG + + +def test_encode_signature(): + api = BaseAPI( + VALID_CONFIG["controller_ip"], + VALID_CONFIG["controller_username"], + VALID_CONFIG["controller_password"] + ) + + assert api.encode_signature("test", + "test3") == b'^\x0cm\xb4\xbf`E\xf5\xbd\x83\xf9q\xc9\x80\x80\xee\xf1O<\xab\x83\x04\xca\xd6\x9d\x92\xec\x1d\xb3\xea\x1a\x8b\x85\xb7x\xb51\xea\x86%\x021\xc4G\xba\x05y\xd0\xb9\n\xf6\xb9\xc3$y\x16Hn\xeeR\x16\xb1j\x9d' + + +def test_decrypt2(): + api = BaseAPI( + VALID_CONFIG["controller_ip"], + VALID_CONFIG["controller_username"], + VALID_CONFIG["controller_password"] + ) + + assert api.decrypt2("W9UIefCF9T7jQGmagrhsJPEldxM5iher+CSAIvbas84=", + "verysafepassword") == "df0f44b7163b034b91710724938af864" diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py new file mode 100644 index 0000000..60a504d --- /dev/null +++ b/tests/test_config_flow.py @@ -0,0 +1,37 @@ +"""Test the Alpha Home 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 . import MODULE, VALID_CONFIG + + +async def test_setup_config(hass: HomeAssistant): + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + with patch( + target=f"{MODULE}.config_flow.validate_input", + return_value=json.loads(load_fixture("controller_api_systeminformation.json")), + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + VALID_CONFIG, + ) + + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "My Heatpump" + + mock_setup_entry.assert_called_once()