From f729acce5c5afa5de2448164766b93bcf5f0f19d Mon Sep 17 00:00:00 2001 From: andrew-dddd Date: Thu, 18 Dec 2025 14:07:32 +0100 Subject: [PATCH 01/50] Added api calls and fixed startup problems --- custom_components/tech/config_flow.py | 11 +++++-- custom_components/tech/tech.py | 45 ++++++++++++++++++++++++++- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/custom_components/tech/config_flow.py b/custom_components/tech/config_flow.py index 94652b2..f71caeb 100644 --- a/custom_components/tech/config_flow.py +++ b/custom_components/tech/config_flow.py @@ -6,6 +6,7 @@ from homeassistant.config_entries import ConfigEntry from .const import DOMAIN # pylint:disable=unused-import from .tech import Tech +from types import MappingProxyType _LOGGER = logging.getLogger(__name__) @@ -76,13 +77,17 @@ async def async_step_user(self, user_input=None): def _create_config_entry(self, module: dict) -> ConfigEntry: return ConfigEntry( - data=module, - title=module["version"], + data=module, + title=module["version"], entry_id=uuid.uuid4().hex, + discovery_keys=MappingProxyType({}), domain=DOMAIN, version=ConfigFlow.VERSION, minor_version=ConfigFlow.MINOR_VERSION, - source=ConfigFlow.CONNECTION_CLASS) + source=ConfigFlow.CONNECTION_CLASS, + options={}, + unique_id=None, + subentries_data=[]) def _create_modules_array(self, validated_input: dict) -> [dict]: return [ diff --git a/custom_components/tech/tech.py b/custom_components/tech/tech.py index 2a22cfc..db5b7e8 100644 --- a/custom_components/tech/tech.py +++ b/custom_components/tech/tech.py @@ -143,6 +143,7 @@ async def set_const_temp(self, module_udid, zone_id, target_temp): _LOGGER.debug("Setting zone constant temperature...") if self.authenticated: path = "users/" + self.user_id + "/modules/" + module_udid + "/zones" + _LOGGER.debug("Path: " + path); data = { "mode" : { "id" : self.zones[zone_id]["mode"]["id"], @@ -187,6 +188,48 @@ async def set_zone(self, module_udid, zone_id, on = True): raise TechError(401, "Unauthorized") return result + async def get_module_menu(self, module_udid, menu_type): + """ Gets module menu options + + Parameters: + module_udid (string): The tech module udid + menu_type (string): Menu type, one of the following: "MU", "MI", "MS", "MP" + + Return: + JSON object with results + """ + + _LOGGER.debug("Getting module menu: %s", menu_type) + if self.authenticated: + path = "users/" + self.user_id + "/modules/" + module_udid + "/menu/" + menu_type + result = await self.get(path) + _LOGGER.debug(result) + else: + raise TechError(401, "Unauthorized") + return result + + async def set_module_menu(self, module_udid, menu_type, menu_id, menu_value): + """ Sets module menu value + + Parameters: + module_udid (string): The tech module udid + menu_type (string): Menu type, one of the following: "MU", "MI", "MS", "MP" + menu_id (integer): Menu option id, integer + menu_value (integer): Menu option value, positive integ + """ + + _LOGGER.debug("Setting menu %s id: %s value to: %s", menu_type, menu_id, menu_value) + if self.authenticated: + path = "users/" + self.user_id + "/modules/" + module_udid + "/menu/" + menu_type + "/ido/" + menu_id + data = { + "value": menu_value + } + result = await self.post(path, json.dumps(data)) + _LOGGER.debug(result) + else: + raise TechError(401, "Unauthorized") + return result + class TechError(Exception): """Raised when Tech APi request ended in error. Attributes: @@ -195,4 +238,4 @@ class TechError(Exception): """ def __init__(self, status_code, status): self.status_code = status_code - self.status = status \ No newline at end of file + self.status = status From 13be33ae18f8a3bc5213c0b66d57eea8b3c6b338 Mon Sep 17 00:00:00 2001 From: andrew-dddd Date: Thu, 18 Dec 2025 14:07:32 +0100 Subject: [PATCH 02/50] Added api calls and fixed startup problems --- custom_components/tech/climate.py | 52 +++++++++++++++++++++++---- custom_components/tech/config_flow.py | 11 ++++-- custom_components/tech/tech.py | 45 ++++++++++++++++++++++- 3 files changed, 97 insertions(+), 11 deletions(-) diff --git a/custom_components/tech/climate.py b/custom_components/tech/climate.py index da7faa2..acb6404 100644 --- a/custom_components/tech/climate.py +++ b/custom_components/tech/climate.py @@ -36,8 +36,12 @@ async def async_setup_entry( try: zones = await api.get_module_zones(udid) + menu_config = await api.get_module_menu(udid, "mu") + if(menu_config["status"] != "success"): + _LOGGER.warning("Failed to get menu config for Tech module %s, response: %s", udid, menu_config) + menu_config = None async_add_entities( - TechThermostat(zones[zone], api, udid) + TechThermostat(zones[zone], api, udid, menu_config["data"] if menu_config else None) for zone in zones ) return True @@ -52,8 +56,9 @@ class TechThermostat(ClimateEntity): _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_hvac_modes = SUPPORT_HVAC _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_preset_modes = ["Normal", "Holiday", "Eco", "Comfort"] - def __init__(self, device: dict[str, Any], api: Tech, udid: str) -> None: + def __init__(self, device: dict[str, Any], api: Tech, udid: str, menu_config: dict[str, Any] | None) -> None: """Initialize the Tech device.""" self._api = api self._id: int = device["zone"]["id"] @@ -74,10 +79,11 @@ def __init__(self, device: dict[str, Any], api: Tech, udid: str) -> None: self._attr_current_humidity: int | None = None self._attr_hvac_action: str | None = None self._attr_hvac_mode: str = HVACMode.OFF + self._attr_preset_mode: str | None = None - self.update_properties(device) + self.update_properties(device, menu_config) - def update_properties(self, device: dict[str, Any]) -> None: + def update_properties(self, device: dict[str, Any], device_menu_config: dict[str, Any] | None) -> None: """Update the properties from device data.""" self._attr_name = device["description"]["name"] @@ -97,16 +103,28 @@ def update_properties(self, device: dict[str, Any]) -> None: elif state == "off": self._attr_hvac_action = HVACAction.IDLE else: - self._attr_hvac_action = HVACAction.OFF - + self._attr_hvac_action = HVACAction.OFF + mode = zone["zoneState"] self._attr_hvac_mode = HVACMode.HEAT if mode in ["zoneOn", "noAlarm"] else HVACMode.OFF + + heating_mode = self.get_heating_mode_from_menu_config(device_menu_config) if device_menu_config else None + + if heating_mode is not None: + heating_mode_id = heating_mode["params"]["value"] + self._attr_preset_mode = self.map_heating_mode_id_to_name(heating_mode_id) async def async_update(self) -> None: """Update the entity.""" try: device = await self._api.get_zone(self._udid, self._id) - self.update_properties(device) + menu_config = await self._apy.get_module_menu(self._udid, "mu") + if(menu_config["status"] == "success"): + self.update_properties(device, menu_config["data"]) + else: + _LOGGER.warning("Failed to get menu config for Tech module %s, response: %s", self._udid, menu_config) + + self.update_properties(device, None) except Exception as ex: _LOGGER.error("Failed to update Tech zone %s: %s", self._attr_name, ex) @@ -139,3 +157,23 @@ async def async_set_hvac_mode(self, hvac_mode: str) -> None: hvac_mode, ex ) + + def get_heating_mode_from_menu_config(self, menu_config: dict[str, Any]) -> dict[str, Any] | None: + """Get current preset mode from menu config.""" + element = None + heating_mode_menu_id = 1000 + for e in menu_config["data"]["elements"]: + if e["id"] == heating_mode_menu_id: + element = e + break + return element + + def map_heating_mode_id_to_name(self, heating_mode_id) -> str: + """Map heating mode id to preset mode name.""" + mapping = { + 0: "Normal", + 1: "Holiday", + 2: "Eco", + 3: "Comfort" + } + return mapping.get(heating_mode_id, "Unknown") \ No newline at end of file diff --git a/custom_components/tech/config_flow.py b/custom_components/tech/config_flow.py index 94652b2..f71caeb 100644 --- a/custom_components/tech/config_flow.py +++ b/custom_components/tech/config_flow.py @@ -6,6 +6,7 @@ from homeassistant.config_entries import ConfigEntry from .const import DOMAIN # pylint:disable=unused-import from .tech import Tech +from types import MappingProxyType _LOGGER = logging.getLogger(__name__) @@ -76,13 +77,17 @@ async def async_step_user(self, user_input=None): def _create_config_entry(self, module: dict) -> ConfigEntry: return ConfigEntry( - data=module, - title=module["version"], + data=module, + title=module["version"], entry_id=uuid.uuid4().hex, + discovery_keys=MappingProxyType({}), domain=DOMAIN, version=ConfigFlow.VERSION, minor_version=ConfigFlow.MINOR_VERSION, - source=ConfigFlow.CONNECTION_CLASS) + source=ConfigFlow.CONNECTION_CLASS, + options={}, + unique_id=None, + subentries_data=[]) def _create_modules_array(self, validated_input: dict) -> [dict]: return [ diff --git a/custom_components/tech/tech.py b/custom_components/tech/tech.py index 2a22cfc..db5b7e8 100644 --- a/custom_components/tech/tech.py +++ b/custom_components/tech/tech.py @@ -143,6 +143,7 @@ async def set_const_temp(self, module_udid, zone_id, target_temp): _LOGGER.debug("Setting zone constant temperature...") if self.authenticated: path = "users/" + self.user_id + "/modules/" + module_udid + "/zones" + _LOGGER.debug("Path: " + path); data = { "mode" : { "id" : self.zones[zone_id]["mode"]["id"], @@ -187,6 +188,48 @@ async def set_zone(self, module_udid, zone_id, on = True): raise TechError(401, "Unauthorized") return result + async def get_module_menu(self, module_udid, menu_type): + """ Gets module menu options + + Parameters: + module_udid (string): The tech module udid + menu_type (string): Menu type, one of the following: "MU", "MI", "MS", "MP" + + Return: + JSON object with results + """ + + _LOGGER.debug("Getting module menu: %s", menu_type) + if self.authenticated: + path = "users/" + self.user_id + "/modules/" + module_udid + "/menu/" + menu_type + result = await self.get(path) + _LOGGER.debug(result) + else: + raise TechError(401, "Unauthorized") + return result + + async def set_module_menu(self, module_udid, menu_type, menu_id, menu_value): + """ Sets module menu value + + Parameters: + module_udid (string): The tech module udid + menu_type (string): Menu type, one of the following: "MU", "MI", "MS", "MP" + menu_id (integer): Menu option id, integer + menu_value (integer): Menu option value, positive integ + """ + + _LOGGER.debug("Setting menu %s id: %s value to: %s", menu_type, menu_id, menu_value) + if self.authenticated: + path = "users/" + self.user_id + "/modules/" + module_udid + "/menu/" + menu_type + "/ido/" + menu_id + data = { + "value": menu_value + } + result = await self.post(path, json.dumps(data)) + _LOGGER.debug(result) + else: + raise TechError(401, "Unauthorized") + return result + class TechError(Exception): """Raised when Tech APi request ended in error. Attributes: @@ -195,4 +238,4 @@ class TechError(Exception): """ def __init__(self, status_code, status): self.status_code = status_code - self.status = status \ No newline at end of file + self.status = status From 1c1e2b5ab1ae6d7e36173bf42783503efd98042c Mon Sep 17 00:00:00 2001 From: andrew-dddd Date: Thu, 18 Dec 2025 16:47:02 +0100 Subject: [PATCH 03/50] Fixed issue with second call as none --- custom_components/tech/climate.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/custom_components/tech/climate.py b/custom_components/tech/climate.py index afb6ce6..aa2fac8 100644 --- a/custom_components/tech/climate.py +++ b/custom_components/tech/climate.py @@ -123,11 +123,10 @@ async def async_update(self) -> None: device = await self._api.get_zone(self._udid, self._id) menu_config = await self._api.get_module_menu(self._udid, "mu") if(menu_config["status"] == "success"): - self.update_properties(device, menu_config["data"]) + self.update_properties(device, menu_config["data"]) else: - _LOGGER.warning("Failed to get menu config for Tech module %s, response: %s", self._udid, menu_config) - - self.update_properties(device, None) + _LOGGER.warning("Failed to get menu config for Tech module %s, response: %s", self._udid, menu_config) + self.update_properties(device, None) except Exception as ex: _LOGGER.error("Failed to update Tech zone %s: %s", self._attr_name, ex) From 369322fc38358523d1f9f33817173d19bf686d23 Mon Sep 17 00:00:00 2001 From: andrew-dddd Date: Thu, 18 Dec 2025 16:52:04 +0100 Subject: [PATCH 04/50] Remeved unnecesary logs --- custom_components/tech/tech.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/custom_components/tech/tech.py b/custom_components/tech/tech.py index 5ab2679..917711d 100644 --- a/custom_components/tech/tech.py +++ b/custom_components/tech/tech.py @@ -156,7 +156,6 @@ async def set_const_temp(self, module_udid, zone_id, target_temp): } _LOGGER.debug(data) result = await self.post(path, json.dumps(data)) - _LOGGER.debug(result) else: raise TechError(401, "Unauthorized") return result @@ -183,7 +182,6 @@ async def set_zone(self, module_udid, zone_id, on = True): } _LOGGER.debug(data) result = await self.post(path, json.dumps(data)) - _LOGGER.debug(result) else: raise TechError(401, "Unauthorized") return result @@ -202,8 +200,7 @@ async def get_module_menu(self, module_udid, menu_type): _LOGGER.debug("Getting module menu: %s", menu_type) if self.authenticated: path = "users/" + self.user_id + "/modules/" + module_udid + "/menu/" + menu_type - result = await self.get(path) - _LOGGER.debug(result) + result = await self.get(path) else: raise TechError(401, "Unauthorized") return result @@ -224,8 +221,8 @@ async def set_module_menu(self, module_udid, menu_type, menu_id, menu_value): data = { "value": menu_value } + _LOGGER.debug(data) result = await self.post(path, json.dumps(data)) - _LOGGER.debug(result) else: raise TechError(401, "Unauthorized") return result From 261045aef32fda775c7fd3997e3b26f603a38ce8 Mon Sep 17 00:00:00 2001 From: andrew-dddd Date: Thu, 18 Dec 2025 16:55:00 +0100 Subject: [PATCH 05/50] Added preset mode support --- custom_components/tech/climate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/tech/climate.py b/custom_components/tech/climate.py index aa2fac8..8b03799 100644 --- a/custom_components/tech/climate.py +++ b/custom_components/tech/climate.py @@ -56,7 +56,7 @@ class TechThermostat(ClimateEntity): _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_hvac_modes = SUPPORT_HVAC - _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE _attr_preset_modes = ["Normal", "Holiday", "Eco", "Comfort"] def __init__(self, device: dict[str, Any], api: Tech, udid: str, menu_config: dict[str, Any] | None) -> None: From 222988a8529f2a03de635e61ca780f3aaeeab0df Mon Sep 17 00:00:00 2001 From: andrew-dddd Date: Thu, 18 Dec 2025 16:59:41 +0100 Subject: [PATCH 06/50] Updated mapping --- custom_components/tech/climate.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/custom_components/tech/climate.py b/custom_components/tech/climate.py index 8b03799..201669f 100644 --- a/custom_components/tech/climate.py +++ b/custom_components/tech/climate.py @@ -57,7 +57,7 @@ class TechThermostat(ClimateEntity): _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_hvac_modes = SUPPORT_HVAC _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE - _attr_preset_modes = ["Normal", "Holiday", "Eco", "Comfort"] + _attr_preset_modes = ["Normalny", "Urlop", "Ekonomiczny", "Komfortowy"] def __init__(self, device: dict[str, Any], api: Tech, udid: str, menu_config: dict[str, Any] | None) -> None: """Initialize the Tech device.""" @@ -173,9 +173,9 @@ def get_heating_mode_from_menu_config(self, menu_config: dict[str, Any]) -> dict def map_heating_mode_id_to_name(self, heating_mode_id) -> str: """Map heating mode id to preset mode name.""" mapping = { - 0: "Normal", - 1: "Holiday", - 2: "Eco", - 3: "Comfort" + 0: "Normalny", + 1: "Urlop", + 2: "Ekonomiczny", + 3: "Komfortowy" } return mapping.get(heating_mode_id, "Unknown") \ No newline at end of file From 1e57092e0e8ce4834ee8ab94ef2ea80118d777c0 Mon Sep 17 00:00:00 2001 From: andrew-dddd Date: Thu, 18 Dec 2025 17:12:49 +0100 Subject: [PATCH 07/50] Added setting preset mode and changing state --- custom_components/tech/climate.py | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/custom_components/tech/climate.py b/custom_components/tech/climate.py index 201669f..10d26b1 100644 --- a/custom_components/tech/climate.py +++ b/custom_components/tech/climate.py @@ -24,6 +24,7 @@ _LOGGER = logging.getLogger(__name__) SUPPORT_HVAC: Final = [HVACMode.HEAT, HVACMode.OFF] +DEFAULT_PRESETS = ["Normalny", "Urlop", "Ekonomiczny", "Komfortowy"] async def async_setup_entry( hass: HomeAssistant, @@ -57,7 +58,7 @@ class TechThermostat(ClimateEntity): _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_hvac_modes = SUPPORT_HVAC _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE - _attr_preset_modes = ["Normalny", "Urlop", "Ekonomiczny", "Komfortowy"] + _attr_preset_modes = def __init__(self, device: dict[str, Any], api: Tech, udid: str, menu_config: dict[str, Any] | None) -> None: """Initialize the Tech device.""" @@ -111,9 +112,14 @@ def update_properties(self, device: dict[str, Any], device_menu_config: dict[str heating_mode = self.get_heating_mode_from_menu_config(device_menu_config) if device_menu_config else None - if heating_mode is not None: - heating_mode_id = heating_mode["params"]["value"] - self._attr_preset_mode = self.map_heating_mode_id_to_name(heating_mode_id) + if heating_mode is not None: + if heating_mode["duringChange"] == "t": + self._attr_preset_modes = ["Oczekiwanie na zmianę"] + self._attr_preset_mode = "Oczekiwanie na zmianę" + else: + self._attr_preset_modes = DEFAULT_PRESETS + heating_mode_id = heating_mode["params"]["value"] + self._attr_preset_mode = self.map_heating_mode_id_to_name(heating_mode_id) else: _LOGGER.warning("Heating mode menu not found for Tech zone %s", self._attr_name) @@ -143,6 +149,23 @@ async def async_set_temperature(self, **kwargs: Any) -> None: temperature, ex ) + + async def async_set_preset_mode(self, preset_mode: str) -> None: + try: + preset_mode_id = DEFAULT_PRESETS.index(preset_mode) + await self._api.set_module_menu( + self._udid, + "mu", + 1000, + preset_mode_id + ) + except Exception as ex: + _LOGGER.error( + "Failed to set preset mode for %s to %s: %s", + self._attr_name, + preset_mode, + ex + ) async def async_set_hvac_mode(self, hvac_mode: str) -> None: """Set new target hvac mode.""" From 4de966c4401fbff99b7bf9d525c762682b8e6c2b Mon Sep 17 00:00:00 2001 From: andrew-dddd Date: Thu, 18 Dec 2025 17:16:36 +0100 Subject: [PATCH 08/50] Fix --- custom_components/tech/climate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/tech/climate.py b/custom_components/tech/climate.py index 10d26b1..b3041ab 100644 --- a/custom_components/tech/climate.py +++ b/custom_components/tech/climate.py @@ -58,7 +58,7 @@ class TechThermostat(ClimateEntity): _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_hvac_modes = SUPPORT_HVAC _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE - _attr_preset_modes = + _attr_preset_modes = DEFAULT_PRESETS def __init__(self, device: dict[str, Any], api: Tech, udid: str, menu_config: dict[str, Any] | None) -> None: """Initialize the Tech device.""" From 429a2c61b4c9a5ddcb59633cd125200f9abc7d46 Mon Sep 17 00:00:00 2001 From: andrew-dddd Date: Thu, 18 Dec 2025 17:20:12 +0100 Subject: [PATCH 09/50] Used f-strings instead of concatenation --- custom_components/tech/tech.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/custom_components/tech/tech.py b/custom_components/tech/tech.py index 917711d..52659a6 100644 --- a/custom_components/tech/tech.py +++ b/custom_components/tech/tech.py @@ -142,8 +142,8 @@ async def set_const_temp(self, module_udid, zone_id, target_temp): """ _LOGGER.debug("Setting zone constant temperature...") if self.authenticated: - path = "users/" + self.user_id + "/modules/" + module_udid + "/zones" - _LOGGER.debug("Path: " + path); + path = f"users/{self.user_id}/modules/{module_udid}/zones" + _LOGGER.debug("Path: " + path) data = { "mode" : { "id" : self.zones[zone_id]["mode"]["id"], @@ -173,7 +173,7 @@ async def set_zone(self, module_udid, zone_id, on = True): """ _LOGGER.debug("Turing zone on/off: %s", on) if self.authenticated: - path = "users/" + self.user_id + "/modules/" + module_udid + "/zones" + path = f"users/{self.user_id}/modules/{module_udid}/zones" data = { "zone" : { "id" : zone_id, @@ -199,7 +199,7 @@ async def get_module_menu(self, module_udid, menu_type): _LOGGER.debug("Getting module menu: %s", menu_type) if self.authenticated: - path = "users/" + self.user_id + "/modules/" + module_udid + "/menu/" + menu_type + path = f"users/{self.user_id}/modules/{module_udid}/menu/{menu_type}" result = await self.get(path) else: raise TechError(401, "Unauthorized") @@ -217,7 +217,7 @@ async def set_module_menu(self, module_udid, menu_type, menu_id, menu_value): _LOGGER.debug("Setting menu %s id: %s value to: %s", menu_type, menu_id, menu_value) if self.authenticated: - path = "users/" + self.user_id + "/modules/" + module_udid + "/menu/" + menu_type + "/ido/" + menu_id + path = f"users/{self.user_id}/modules/{module_udid}/menu/{menu_type}/id/{menu_id}" data = { "value": menu_value } From cdd1b6b729d666dc0690a0c4716d31e4f59a2e16 Mon Sep 17 00:00:00 2001 From: andrew-dddd Date: Thu, 18 Dec 2025 17:22:31 +0100 Subject: [PATCH 10/50] Fixed typo in url --- custom_components/tech/tech.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/tech/tech.py b/custom_components/tech/tech.py index 52659a6..4b7bbd8 100644 --- a/custom_components/tech/tech.py +++ b/custom_components/tech/tech.py @@ -217,7 +217,7 @@ async def set_module_menu(self, module_udid, menu_type, menu_id, menu_value): _LOGGER.debug("Setting menu %s id: %s value to: %s", menu_type, menu_id, menu_value) if self.authenticated: - path = f"users/{self.user_id}/modules/{module_udid}/menu/{menu_type}/id/{menu_id}" + path = f"users/{self.user_id}/modules/{module_udid}/menu/{menu_type}/ido/{menu_id}" data = { "value": menu_value } From 9af6b6a3ea80f23f46ada456847986f5b2aaaf44 Mon Sep 17 00:00:00 2001 From: andrew-dddd Date: Thu, 18 Dec 2025 17:28:19 +0100 Subject: [PATCH 11/50] Cosmetics --- custom_components/tech/climate.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/custom_components/tech/climate.py b/custom_components/tech/climate.py index b3041ab..74b3b50 100644 --- a/custom_components/tech/climate.py +++ b/custom_components/tech/climate.py @@ -25,6 +25,7 @@ SUPPORT_HVAC: Final = [HVACMode.HEAT, HVACMode.OFF] DEFAULT_PRESETS = ["Normalny", "Urlop", "Ekonomiczny", "Komfortowy"] +CHANGE_PRESET = "Oczekiwanie na zmianę" async def async_setup_entry( hass: HomeAssistant, @@ -114,8 +115,8 @@ def update_properties(self, device: dict[str, Any], device_menu_config: dict[str if heating_mode is not None: if heating_mode["duringChange"] == "t": - self._attr_preset_modes = ["Oczekiwanie na zmianę"] - self._attr_preset_mode = "Oczekiwanie na zmianę" + self._attr_preset_modes = [CHANGE_PRESET] + self._attr_preset_mode = CHANGE_PRESET else: self._attr_preset_modes = DEFAULT_PRESETS heating_mode_id = heating_mode["params"]["value"] @@ -152,6 +153,10 @@ async def async_set_temperature(self, **kwargs: Any) -> None: async def async_set_preset_mode(self, preset_mode: str) -> None: try: + if self._attr.preset_mode == CHANGE_PRESET: + _LOGGER.debug("Preset mode change already in progress for %s", self._attr_name) + return + preset_mode_id = DEFAULT_PRESETS.index(preset_mode) await self._api.set_module_menu( self._udid, @@ -159,6 +164,8 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: 1000, preset_mode_id ) + self._attr_preset_modes = [CHANGE_PRESET] + self._attr_preset_mode = CHANGE_PRESET except Exception as ex: _LOGGER.error( "Failed to set preset mode for %s to %s: %s", From c85a00c26843120054ace8668526039f26e9b91d Mon Sep 17 00:00:00 2001 From: andrew-dddd Date: Thu, 18 Dec 2025 17:30:30 +0100 Subject: [PATCH 12/50] Fix --- custom_components/tech/climate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/tech/climate.py b/custom_components/tech/climate.py index 74b3b50..2d69f0a 100644 --- a/custom_components/tech/climate.py +++ b/custom_components/tech/climate.py @@ -153,7 +153,7 @@ async def async_set_temperature(self, **kwargs: Any) -> None: async def async_set_preset_mode(self, preset_mode: str) -> None: try: - if self._attr.preset_mode == CHANGE_PRESET: + if self._attr_preset_mode == CHANGE_PRESET: _LOGGER.debug("Preset mode change already in progress for %s", self._attr_name) return From 218d015beacc82617d47a6de15b93895ba5bb857 Mon Sep 17 00:00:00 2001 From: andrew-dddd Date: Fri, 19 Dec 2025 09:45:13 +0100 Subject: [PATCH 13/50] Added coordinator to avoid excessive api calls --- custom_components/tech/climate.py | 45 +++++++++---- .../tech/tech_update_coordinator.py | 67 +++++++++++++++++++ 2 files changed, 100 insertions(+), 12 deletions(-) create mode 100644 custom_components/tech/tech_update_coordinator.py diff --git a/custom_components/tech/climate.py b/custom_components/tech/climate.py index 2d69f0a..342ee55 100644 --- a/custom_components/tech/climate.py +++ b/custom_components/tech/climate.py @@ -4,6 +4,7 @@ import logging from typing import Any, Final +from custom_components.tech.tech_update_coordinator import TechUpdateCoordinator from homeassistant.components.climate import ( ClimateEntity, ClimateEntityFeature, @@ -14,8 +15,11 @@ ATTR_TEMPERATURE, UnitOfTemperature, ) + +from homeassistant.helpers.update_coordinator import CoordinatorEntity + from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import (HomeAssistant, callback) from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN @@ -34,7 +38,7 @@ async def async_setup_entry( ) -> bool: """Set up Tech climate based on config_entry.""" api: Tech = hass.data[DOMAIN][entry.entry_id] - udid: str = entry.data["module"]["udid"] + udid: str = entry.data["module"]["udid"] try: zones = await api.get_module_zones(udid) @@ -43,17 +47,20 @@ async def async_setup_entry( _LOGGER.warning("Failed to get menu config for Tech module %s, response: %s", udid, menu_config) menu_config = None + coordinator = TechUpdateCoordinator(hass, entry, api, udid) + await coordinator._async_update_data() + async_add_entities( - TechThermostat(zones[zone], api, udid, menu_config["data"] if menu_config else None) + TechThermostat(zones[zone], coordinator, api) for zone in zones ) return True except Exception as ex: _LOGGER.error("Failed to set up Tech climate: %s", ex) return False + - -class TechThermostat(ClimateEntity): +class TechThermostat(CoordinatorEntity, ClimateEntity): """Representation of a Tech climate.""" _attr_temperature_unit = UnitOfTemperature.CELSIUS @@ -61,19 +68,21 @@ class TechThermostat(ClimateEntity): _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE _attr_preset_modes = DEFAULT_PRESETS - def __init__(self, device: dict[str, Any], api: Tech, udid: str, menu_config: dict[str, Any] | None) -> None: + def __init__(self, device: dict[str, Any], coordinator, api: Tech) -> None: """Initialize the Tech device.""" self._api = api self._id: int = device["zone"]["id"] - self._udid = udid + self._udid = coordinator.udid # Set unique_id first as it's required for entity registry - self._attr_unique_id = f"{udid}_{device['zone']['id']}" + self._attr_unique_id = f"{self._udid}_{self._id}" self._attr_device_info = { "identifiers": {(DOMAIN, self._attr_unique_id)}, "name": device["description"]["name"], "manufacturer": "Tech", } + + super().__init__(coordinator, context=self._id) # Initialize attributes that will be updated self._attr_name: str | None = None @@ -84,7 +93,7 @@ def __init__(self, device: dict[str, Any], api: Tech, udid: str, menu_config: di self._attr_hvac_mode: str = HVACMode.OFF self._attr_preset_mode: str | None = None - self.update_properties(device, menu_config) + self.update_properties(coordinator.data["zones"][self._id], coordinator.data["menu"]) def update_properties(self, device: dict[str, Any], device_menu_config: dict[str, Any] | None) -> None: """Update the properties from device data.""" @@ -124,8 +133,17 @@ def update_properties(self, device: dict[str, Any], device_menu_config: dict[str else: _LOGGER.warning("Heating mode menu not found for Tech zone %s", self._attr_name) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + data = self.coordinator.data + self.update_properties(data["zones"][self._id], data["menu"]) + self.async_write_ha_state() + + """ async def async_update(self) -> None: - """Update the entity.""" + Update the entity. try: device = await self._api.get_zone(self._udid, self._id) menu_config = await self._api.get_module_menu(self._udid, "mu") @@ -136,6 +154,7 @@ async def async_update(self) -> None: self.update_properties(device, None) except Exception as ex: _LOGGER.error("Failed to update Tech zone %s: %s", self._attr_name, ex) + """ async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" @@ -143,6 +162,7 @@ async def async_set_temperature(self, **kwargs: Any) -> None: if temperature is not None: try: await self._api.set_const_temp(self._udid, self._id, temperature) + await self.coordinator.async_request_refresh() except Exception as ex: _LOGGER.error( "Failed to set temperature for %s to %s: %s", @@ -164,8 +184,8 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: 1000, preset_mode_id ) - self._attr_preset_modes = [CHANGE_PRESET] - self._attr_preset_mode = CHANGE_PRESET + + await self.coordinator.async_request_refresh() except Exception as ex: _LOGGER.error( "Failed to set preset mode for %s to %s: %s", @@ -182,6 +202,7 @@ async def async_set_hvac_mode(self, hvac_mode: str) -> None: self._id, hvac_mode == HVACMode.HEAT ) + await self.coordinator.async_request_refresh() except Exception as ex: _LOGGER.error( "Failed to set hvac mode for %s to %s: %s", diff --git a/custom_components/tech/tech_update_coordinator.py b/custom_components/tech/tech_update_coordinator.py new file mode 100644 index 0000000..9f5bc66 --- /dev/null +++ b/custom_components/tech/tech_update_coordinator.py @@ -0,0 +1,67 @@ +"""Example integration using DataUpdateCoordinator.""" + +from datetime import timedelta +import logging + +import async_timeout + +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import ( + DataUpdateCoordinator, + UpdateFailed, +) + +_LOGGER = logging.getLogger(__name__) + + +class TechUpdateCoordinator(DataUpdateCoordinator): + """My custom coordinator.""" + + def __init__(self, hass, config_entry, tech_api, udid): + """Initialize my coordinator.""" + super().__init__( + hass, + _LOGGER, + # Name of the data. For logging purposes. + name="My sensor", + config_entry=config_entry, + # Polling interval. Will only be polled if there are subscribers. + update_interval=timedelta(seconds=30), + # Set always_update to `False` if the data returned from the + # api can be compared via `__eq__` to avoid duplicate updates + # being dispatched to listeners + always_update=True + ) + self.tech_api = tech_api + self.udid: str = udid + + async def _async_update_data(self): + """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: + # Note: asyncio.TimeoutError and aiohttp.ClientError are already + # handled by the data update coordinator. + async with async_timeout.timeout(10): + # Grab active context variables to limit data required to be fetched from API + # Note: using context is not required if there is no need or ability to limit + # data retrieved from API. + zones = await self.tech_api.get_zones(self.udid) + menu = await self.tech_api.get_module_menu(self.udid, "mu") + + if menu["status"] != "success": + _LOGGER.warning("Failed to get menu config for Tech module %s, response: %s", self.udid, menu) + menu = None + + return {"zones": zones, "menu": menu} + except ApiAuthError as err: + # Raising ConfigEntryAuthFailed will cancel future updates + # and start a config flow with SOURCE_REAUTH (async_step_reauth) + raise ConfigEntryAuthFailed from err + except ApiError as err: + raise UpdateFailed(f"Error communicating with API: {err}") + except ApiRateLimited as err: + # If the API is providing backoff signals, these can be honored via the retry_after parameter + raise UpdateFailed(retry_after=60) \ No newline at end of file From 419f9a5b683dcff08fd1190ffeca196a892631de Mon Sep 17 00:00:00 2001 From: andrew-dddd Date: Fri, 19 Dec 2025 10:06:26 +0100 Subject: [PATCH 14/50] Coordinator fixes --- custom_components/tech/climate.py | 4 ++-- .../tech/tech_update_coordinator.py | 16 +++++++--------- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/custom_components/tech/climate.py b/custom_components/tech/climate.py index 342ee55..c40961a 100644 --- a/custom_components/tech/climate.py +++ b/custom_components/tech/climate.py @@ -40,8 +40,7 @@ async def async_setup_entry( api: Tech = hass.data[DOMAIN][entry.entry_id] udid: str = entry.data["module"]["udid"] - try: - zones = await api.get_module_zones(udid) + try: menu_config = await api.get_module_menu(udid, "mu") if menu_config["status"] != "success": _LOGGER.warning("Failed to get menu config for Tech module %s, response: %s", udid, menu_config) @@ -50,6 +49,7 @@ async def async_setup_entry( coordinator = TechUpdateCoordinator(hass, entry, api, udid) await coordinator._async_update_data() + zones = coordinator.data['zones'] async_add_entities( TechThermostat(zones[zone], coordinator, api) for zone in zones diff --git a/custom_components/tech/tech_update_coordinator.py b/custom_components/tech/tech_update_coordinator.py index 9f5bc66..b76895c 100644 --- a/custom_components/tech/tech_update_coordinator.py +++ b/custom_components/tech/tech_update_coordinator.py @@ -5,6 +5,7 @@ import async_timeout +from custom_components.tech.tech import (Tech, TechError) from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import ( DataUpdateCoordinator, @@ -17,7 +18,7 @@ class TechUpdateCoordinator(DataUpdateCoordinator): """My custom coordinator.""" - def __init__(self, hass, config_entry, tech_api, udid): + def __init__(self, hass, config_entry, tech_api : Tech, udid): """Initialize my coordinator.""" super().__init__( hass, @@ -56,12 +57,9 @@ async def _async_update_data(self): menu = None return {"zones": zones, "menu": menu} - except ApiAuthError as err: + except TechError as err: # Raising ConfigEntryAuthFailed will cancel future updates - # and start a config flow with SOURCE_REAUTH (async_step_reauth) - raise ConfigEntryAuthFailed from err - except ApiError as err: - raise UpdateFailed(f"Error communicating with API: {err}") - except ApiRateLimited as err: - # If the API is providing backoff signals, these can be honored via the retry_after parameter - raise UpdateFailed(retry_after=60) \ No newline at end of file + # and start a config flow with SOURCE_REAUTH (async_step_reauth) + raise UpdateFailed(f"Error communicating with API: {err}") + except Exception as err: + raise ConfigEntryAuthFailed from err \ No newline at end of file From 3512e1b08936d907ddd691767b6c390e4acb8a5e Mon Sep 17 00:00:00 2001 From: andrew-dddd Date: Fri, 19 Dec 2025 10:10:54 +0100 Subject: [PATCH 15/50] updated coordinator --- custom_components/tech/tech_update_coordinator.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/custom_components/tech/tech_update_coordinator.py b/custom_components/tech/tech_update_coordinator.py index b76895c..be00956 100644 --- a/custom_components/tech/tech_update_coordinator.py +++ b/custom_components/tech/tech_update_coordinator.py @@ -49,7 +49,8 @@ async def _async_update_data(self): # Grab active context variables to limit data required to be fetched from API # Note: using context is not required if there is no need or ability to limit # data retrieved from API. - zones = await self.tech_api.get_zones(self.udid) + _LOGGER.debug("getting data for module %s", self.udid) + zones = await self.tech_api.get_module_zones(self.udid) menu = await self.tech_api.get_module_menu(self.udid, "mu") if menu["status"] != "success": From 75694aa328709f5cdd4359967b2f5dc85af654c8 Mon Sep 17 00:00:00 2001 From: andrew-dddd Date: Fri, 19 Dec 2025 10:17:31 +0100 Subject: [PATCH 16/50] Another fix --- custom_components/tech/climate.py | 16 +++++----------- .../tech/tech_update_coordinator.py | 13 +++++++++++++ 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/custom_components/tech/climate.py b/custom_components/tech/climate.py index c40961a..f695070 100644 --- a/custom_components/tech/climate.py +++ b/custom_components/tech/climate.py @@ -40,16 +40,11 @@ async def async_setup_entry( api: Tech = hass.data[DOMAIN][entry.entry_id] udid: str = entry.data["module"]["udid"] - try: - menu_config = await api.get_module_menu(udid, "mu") - if menu_config["status"] != "success": - _LOGGER.warning("Failed to get menu config for Tech module %s, response: %s", udid, menu_config) - menu_config = None - + try: coordinator = TechUpdateCoordinator(hass, entry, api, udid) await coordinator._async_update_data() - zones = coordinator.data['zones'] + zones = coordinator.get_zones() async_add_entities( TechThermostat(zones[zone], coordinator, api) for zone in zones @@ -93,7 +88,7 @@ def __init__(self, device: dict[str, Any], coordinator, api: Tech) -> None: self._attr_hvac_mode: str = HVACMode.OFF self._attr_preset_mode: str | None = None - self.update_properties(coordinator.data["zones"][self._id], coordinator.data["menu"]) + self.update_properties(coordinator.get_zones()[self._id], coordinator.get_menu()) def update_properties(self, device: dict[str, Any], device_menu_config: dict[str, Any] | None) -> None: """Update the properties from device data.""" @@ -136,9 +131,8 @@ def update_properties(self, device: dict[str, Any], device_menu_config: dict[str @callback def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - data = self.coordinator.data - self.update_properties(data["zones"][self._id], data["menu"]) + """Handle updated data from the coordinator.""" + self.update_properties(self.coordinator.get_zones()[self._id], self.coordinator.get_menu()) self.async_write_ha_state() """ diff --git a/custom_components/tech/tech_update_coordinator.py b/custom_components/tech/tech_update_coordinator.py index be00956..eacefa1 100644 --- a/custom_components/tech/tech_update_coordinator.py +++ b/custom_components/tech/tech_update_coordinator.py @@ -2,6 +2,7 @@ from datetime import timedelta import logging +from typing import Any import async_timeout @@ -36,6 +37,18 @@ def __init__(self, hass, config_entry, tech_api : Tech, udid): self.tech_api = tech_api self.udid: str = udid + def get_data(self) -> dict[str, Any]: + """Return the latest data.""" + return self.data + + def get_zones(self) -> dict[str, Any]: + """Return the latest zones data.""" + return self.data["zones"] + + def get_menu(self) -> dict[str, Any]: + """Return the latest menu data.""" + return self.data["menu"] + async def _async_update_data(self): """Fetch data from API endpoint. From 7f0127f7a618596139722f4524528f5537b3b7c7 Mon Sep 17 00:00:00 2001 From: andrew-dddd Date: Fri, 19 Dec 2025 10:20:11 +0100 Subject: [PATCH 17/50] Another fix --- custom_components/tech/tech_update_coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/tech/tech_update_coordinator.py b/custom_components/tech/tech_update_coordinator.py index eacefa1..9ef4f98 100644 --- a/custom_components/tech/tech_update_coordinator.py +++ b/custom_components/tech/tech_update_coordinator.py @@ -70,7 +70,7 @@ async def _async_update_data(self): _LOGGER.warning("Failed to get menu config for Tech module %s, response: %s", self.udid, menu) menu = None - return {"zones": zones, "menu": menu} + self.data = {"zones": zones, "menu": menu} except TechError as err: # Raising ConfigEntryAuthFailed will cancel future updates # and start a config flow with SOURCE_REAUTH (async_step_reauth) From 67ce854019ee86a916e91b0b6ee1e376e8ed1c2f Mon Sep 17 00:00:00 2001 From: andrew-dddd Date: Fri, 19 Dec 2025 10:23:01 +0100 Subject: [PATCH 18/50] Another fix --- custom_components/tech/tech_update_coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/tech/tech_update_coordinator.py b/custom_components/tech/tech_update_coordinator.py index 9ef4f98..3d893d0 100644 --- a/custom_components/tech/tech_update_coordinator.py +++ b/custom_components/tech/tech_update_coordinator.py @@ -70,7 +70,7 @@ async def _async_update_data(self): _LOGGER.warning("Failed to get menu config for Tech module %s, response: %s", self.udid, menu) menu = None - self.data = {"zones": zones, "menu": menu} + self.data = {"zones": zones, "menu": menu["data"] if menu else None} except TechError as err: # Raising ConfigEntryAuthFailed will cancel future updates # and start a config flow with SOURCE_REAUTH (async_step_reauth) From caf91b20d45e413769230cb60c872d5bc33f9d29 Mon Sep 17 00:00:00 2001 From: andrew-dddd Date: Fri, 19 Dec 2025 10:32:06 +0100 Subject: [PATCH 19/50] Another fix --- custom_components/tech/climate.py | 15 --------------- custom_components/tech/tech_update_coordinator.py | 5 +++-- 2 files changed, 3 insertions(+), 17 deletions(-) diff --git a/custom_components/tech/climate.py b/custom_components/tech/climate.py index f695070..55f31b6 100644 --- a/custom_components/tech/climate.py +++ b/custom_components/tech/climate.py @@ -135,21 +135,6 @@ def _handle_coordinator_update(self) -> None: self.update_properties(self.coordinator.get_zones()[self._id], self.coordinator.get_menu()) self.async_write_ha_state() - """ - async def async_update(self) -> None: - Update the entity. - try: - device = await self._api.get_zone(self._udid, self._id) - menu_config = await self._api.get_module_menu(self._udid, "mu") - if(menu_config["status"] == "success"): - self.update_properties(device, menu_config["data"]) - else: - _LOGGER.warning("Failed to get menu config for Tech module %s, response: %s", self._udid, menu_config) - self.update_properties(device, None) - except Exception as ex: - _LOGGER.error("Failed to update Tech zone %s: %s", self._attr_name, ex) - """ - async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" temperature = kwargs.get(ATTR_TEMPERATURE) diff --git a/custom_components/tech/tech_update_coordinator.py b/custom_components/tech/tech_update_coordinator.py index 3d893d0..116dc73 100644 --- a/custom_components/tech/tech_update_coordinator.py +++ b/custom_components/tech/tech_update_coordinator.py @@ -45,7 +45,7 @@ def get_zones(self) -> dict[str, Any]: """Return the latest zones data.""" return self.data["zones"] - def get_menu(self) -> dict[str, Any]: + def get_menu(self) -> dict[str, Any] | None: """Return the latest menu data.""" return self.data["menu"] @@ -70,7 +70,8 @@ async def _async_update_data(self): _LOGGER.warning("Failed to get menu config for Tech module %s, response: %s", self.udid, menu) menu = None - self.data = {"zones": zones, "menu": menu["data"] if menu else None} + self.data = {"zones": zones, "menu": menu["data"] if menu else None} + return self.data except TechError as err: # Raising ConfigEntryAuthFailed will cancel future updates # and start a config flow with SOURCE_REAUTH (async_step_reauth) From 78f6abe109cb502a589cd24e34e98bb0e4966862 Mon Sep 17 00:00:00 2001 From: andrew-dddd Date: Fri, 19 Dec 2025 11:14:52 +0100 Subject: [PATCH 20/50] Added caching with decorator --- custom_components/tech/tech.py | 30 ++++++++----------- .../tech/tech_update_coordinator.py | 8 ++--- 2 files changed, 14 insertions(+), 24 deletions(-) diff --git a/custom_components/tech/tech.py b/custom_components/tech/tech.py index 4b7bbd8..38367bc 100644 --- a/custom_components/tech/tech.py +++ b/custom_components/tech/tech.py @@ -6,6 +6,7 @@ import json import time import asyncio +from aiocache import Cache, cached logging.basicConfig(level=logging.DEBUG) _LOGGER = logging.getLogger(__name__) @@ -15,14 +16,13 @@ class Tech: TECH_API_URL = "https://emodul.eu/api/v1/" - def __init__(self, session: aiohttp.ClientSession, user_id = None, token = None, base_url = TECH_API_URL, update_interval = 30): + def __init__(self, session: aiohttp.ClientSession, user_id = None, token = None, base_url = TECH_API_URL): _LOGGER.debug("Init Tech") self.headers = { 'Accept': 'application/json', 'Accept-Encoding': 'gzip' } self.base_url = base_url - self.update_interval = update_interval self.session = session if user_id and token: self.user_id = user_id @@ -31,10 +31,8 @@ def __init__(self, session: aiohttp.ClientSession, user_id = None, token = None, self.authenticated = True else: self.authenticated = False - self.last_update = None - self.update_lock = asyncio.Lock() self.zones = {} - + async def get(self, request_path): url = self.base_url + request_path _LOGGER.debug("Sending GET request: " + url) @@ -91,6 +89,7 @@ async def get_module_data(self, module_udid): raise TechError(401, "Unauthorized") return result + @cached(ttl=10, cache=Cache.MEMORY) async def get_module_zones(self, module_udid): """Returns Tech module zones either from cache or it will update all the cached values for Tech module assuming @@ -103,17 +102,11 @@ async def get_module_zones(self, module_udid): Returns: Dictionary of zones indexed by zone ID. """ - async with self.update_lock: - now = time.time() - _LOGGER.debug("Geting module zones: now: %s, last_update %s, interval: %s", now, self.last_update, self.update_interval) - if self.last_update is None or now > self.last_update + self.update_interval: - _LOGGER.debug("Updating module zones cache..." + module_udid) - result = await self.get_module_data(module_udid) - zones = result["zones"]["elements"] - zones = list(filter(lambda e: e['zone']['zoneState'] != "zoneUnregistered", zones)) - for zone in zones: - self.zones[zone["zone"]["id"]] = zone - self.last_update = now + result = await self.get_module_data(module_udid) + zones = result["zones"]["elements"] + zones = list(filter(lambda e: e['zone']['zoneState'] != "zoneUnregistered", zones)) + for zone in zones: + self.zones[zone["zone"]["id"]] = zone return self.zones async def get_zone(self, module_udid, zone_id): @@ -126,8 +119,8 @@ async def get_zone(self, module_udid, zone_id): Returns: Dictionary of zone. """ - await self.get_module_zones(module_udid) - return self.zones[zone_id] + zones = await self.get_module_zones(module_udid) + return zones[zone_id] async def set_const_temp(self, module_udid, zone_id, target_temp): """Sets constant temperature of the zone. @@ -186,6 +179,7 @@ async def set_zone(self, module_udid, zone_id, on = True): raise TechError(401, "Unauthorized") return result + @cached(ttl=10, cache=Cache.MEMORY) async def get_module_menu(self, module_udid, menu_type): """ Gets module menu options diff --git a/custom_components/tech/tech_update_coordinator.py b/custom_components/tech/tech_update_coordinator.py index 116dc73..b33287c 100644 --- a/custom_components/tech/tech_update_coordinator.py +++ b/custom_components/tech/tech_update_coordinator.py @@ -25,14 +25,10 @@ def __init__(self, hass, config_entry, tech_api : Tech, udid): hass, _LOGGER, # Name of the data. For logging purposes. - name="My sensor", + name= f"Tech module coordinator: {udid}", config_entry=config_entry, # Polling interval. Will only be polled if there are subscribers. - update_interval=timedelta(seconds=30), - # Set always_update to `False` if the data returned from the - # api can be compared via `__eq__` to avoid duplicate updates - # being dispatched to listeners - always_update=True + update_interval=timedelta(seconds=32), ) self.tech_api = tech_api self.udid: str = udid From 2604e621fda42e79af02db30270226d6209199b0 Mon Sep 17 00:00:00 2001 From: andrew-dddd Date: Fri, 19 Dec 2025 11:28:04 +0100 Subject: [PATCH 21/50] Added debug log --- custom_components/tech/climate.py | 1 + 1 file changed, 1 insertion(+) diff --git a/custom_components/tech/climate.py b/custom_components/tech/climate.py index 55f31b6..7a8385e 100644 --- a/custom_components/tech/climate.py +++ b/custom_components/tech/climate.py @@ -132,6 +132,7 @@ def update_properties(self, device: dict[str, Any], device_menu_config: dict[str @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" + _LOGGER.debug("Coordinator update for Tech zone %s", self._attr_name) self.update_properties(self.coordinator.get_zones()[self._id], self.coordinator.get_menu()) self.async_write_ha_state() From f3145b77fd82ef3d7646bb935babbb06bf85ffeb Mon Sep 17 00:00:00 2001 From: andrew-dddd Date: Fri, 19 Dec 2025 11:30:21 +0100 Subject: [PATCH 22/50] Log message update --- custom_components/tech/climate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/tech/climate.py b/custom_components/tech/climate.py index 7a8385e..17d7169 100644 --- a/custom_components/tech/climate.py +++ b/custom_components/tech/climate.py @@ -132,7 +132,7 @@ def update_properties(self, device: dict[str, Any], device_menu_config: dict[str @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" - _LOGGER.debug("Coordinator update for Tech zone %s", self._attr_name) + _LOGGER.debug("Coordinator update for zone %s", self._attr_name) self.update_properties(self.coordinator.get_zones()[self._id], self.coordinator.get_menu()) self.async_write_ha_state() From 3d7bfb9c67a05259bed5e896ebc3c296822e0865 Mon Sep 17 00:00:00 2001 From: andrew-dddd Date: Fri, 19 Dec 2025 11:34:35 +0100 Subject: [PATCH 23/50] Cleaned the code a bit --- custom_components/tech/climate.py | 1 + custom_components/tech/config_flow.py | 2 +- custom_components/tech/tech.py | 9 +++------ 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/custom_components/tech/climate.py b/custom_components/tech/climate.py index 17d7169..35f2bee 100644 --- a/custom_components/tech/climate.py +++ b/custom_components/tech/climate.py @@ -67,6 +67,7 @@ def __init__(self, device: dict[str, Any], coordinator, api: Tech) -> None: """Initialize the Tech device.""" self._api = api self._id: int = device["zone"]["id"] + self._zone_mode_id = device["mode"]["id"] self._udid = coordinator.udid # Set unique_id first as it's required for entity registry diff --git a/custom_components/tech/config_flow.py b/custom_components/tech/config_flow.py index bdd6280..4c47752 100644 --- a/custom_components/tech/config_flow.py +++ b/custom_components/tech/config_flow.py @@ -77,7 +77,7 @@ async def async_step_user(self, user_input=None): def _create_config_entry(self, module: dict) -> ConfigEntry: return ConfigEntry( - data=module, + data=module, title=module["version"], entry_id=uuid.uuid4().hex, discovery_keys=MappingProxyType({}), diff --git a/custom_components/tech/tech.py b/custom_components/tech/tech.py index 38367bc..6a1469f 100644 --- a/custom_components/tech/tech.py +++ b/custom_components/tech/tech.py @@ -104,10 +104,7 @@ async def get_module_zones(self, module_udid): """ result = await self.get_module_data(module_udid) zones = result["zones"]["elements"] - zones = list(filter(lambda e: e['zone']['zoneState'] != "zoneUnregistered", zones)) - for zone in zones: - self.zones[zone["zone"]["id"]] = zone - return self.zones + return list(filter(lambda e: e['zone']['zoneState'] != "zoneUnregistered", zones)) async def get_zone(self, module_udid, zone_id): """Returns zone from Tech API cache. @@ -122,7 +119,7 @@ async def get_zone(self, module_udid, zone_id): zones = await self.get_module_zones(module_udid) return zones[zone_id] - async def set_const_temp(self, module_udid, zone_id, target_temp): + async def set_const_temp(self, module_udid, zone_mode_id, zone_id, target_temp): """Sets constant temperature of the zone. Parameters: @@ -139,7 +136,7 @@ async def set_const_temp(self, module_udid, zone_id, target_temp): _LOGGER.debug("Path: " + path) data = { "mode" : { - "id" : self.zones[zone_id]["mode"]["id"], + "id" : zone_mode_id, "parentId" : zone_id, "mode" : "constantTemp", "constTempTime" : 60, From 1e10d24fb35e22a4ac285e111ff06080b54f5e12 Mon Sep 17 00:00:00 2001 From: andrew-dddd Date: Fri, 19 Dec 2025 11:39:35 +0100 Subject: [PATCH 24/50] Fix --- custom_components/tech/climate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/tech/climate.py b/custom_components/tech/climate.py index 35f2bee..e6a762d 100644 --- a/custom_components/tech/climate.py +++ b/custom_components/tech/climate.py @@ -142,7 +142,7 @@ async def async_set_temperature(self, **kwargs: Any) -> None: temperature = kwargs.get(ATTR_TEMPERATURE) if temperature is not None: try: - await self._api.set_const_temp(self._udid, self._id, temperature) + await self._api.set_const_temp(self._udid, self._zone_mode_id, self._id, temperature) await self.coordinator.async_request_refresh() except Exception as ex: _LOGGER.error( From 56859a6ce5528f4784e1b0fda705c62cdfa5f0ce Mon Sep 17 00:00:00 2001 From: andrew-dddd Date: Fri, 19 Dec 2025 11:50:13 +0100 Subject: [PATCH 25/50] Fix --- custom_components/tech/tech.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/custom_components/tech/tech.py b/custom_components/tech/tech.py index 6a1469f..4625d59 100644 --- a/custom_components/tech/tech.py +++ b/custom_components/tech/tech.py @@ -104,7 +104,11 @@ async def get_module_zones(self, module_udid): """ result = await self.get_module_data(module_udid) zones = result["zones"]["elements"] - return list(filter(lambda e: e['zone']['zoneState'] != "zoneUnregistered", zones)) + zones = list(filter(lambda e: e['zone']['zoneState'] != "zoneUnregistered", zones)) + zones_dict = {} + for zone in zones: + zones_dict[zone["zone"]["id"]] = zone + return zones_dict async def get_zone(self, module_udid, zone_id): """Returns zone from Tech API cache. From 4806bb7a585ab99f071b5c2560c255f4bf8e2064 Mon Sep 17 00:00:00 2001 From: andrew-dddd Date: Fri, 19 Dec 2025 11:51:27 +0100 Subject: [PATCH 26/50] Fix --- custom_components/tech/tech.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/custom_components/tech/tech.py b/custom_components/tech/tech.py index 4625d59..419884a 100644 --- a/custom_components/tech/tech.py +++ b/custom_components/tech/tech.py @@ -105,10 +105,7 @@ async def get_module_zones(self, module_udid): result = await self.get_module_data(module_udid) zones = result["zones"]["elements"] zones = list(filter(lambda e: e['zone']['zoneState'] != "zoneUnregistered", zones)) - zones_dict = {} - for zone in zones: - zones_dict[zone["zone"]["id"]] = zone - return zones_dict + return { zone["zone"]["id"]: zone for zone in zones } async def get_zone(self, module_udid, zone_id): """Returns zone from Tech API cache. From 1a8e60512579c7a233e41bc86aecebdec11d10dc Mon Sep 17 00:00:00 2001 From: andrew-dddd Date: Fri, 19 Dec 2025 12:00:52 +0100 Subject: [PATCH 27/50] Added setting state after changing preset --- custom_components/tech/climate.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/custom_components/tech/climate.py b/custom_components/tech/climate.py index e6a762d..e2f5f1f 100644 --- a/custom_components/tech/climate.py +++ b/custom_components/tech/climate.py @@ -166,6 +166,9 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: preset_mode_id ) + self._attr_preset_modes = [CHANGE_PRESET] + self._attr_preset_mode = CHANGE_PRESET + await self.coordinator.async_request_refresh() except Exception as ex: _LOGGER.error( From 59d7f3460c255a9bfb70bb53b2e9154aace399b1 Mon Sep 17 00:00:00 2001 From: andrew-dddd Date: Fri, 19 Dec 2025 22:16:05 +0100 Subject: [PATCH 28/50] Bumped version --- custom_components/tech/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/tech/manifest.json b/custom_components/tech/manifest.json index 15c1460..21220b1 100644 --- a/custom_components/tech/manifest.json +++ b/custom_components/tech/manifest.json @@ -10,5 +10,5 @@ "codeowners": [ "@MichalKrasowski" ], - "version": "1.0.0" + "version": "1.0.1" } From 4d88e2a9f05871764747a9f401967706cf488c6d Mon Sep 17 00:00:00 2001 From: andrew-dddd Date: Tue, 23 Dec 2025 10:05:48 +0100 Subject: [PATCH 29/50] Added hub as separate entity --- custom_components/tech/climate.py | 171 +++++++++++------- .../tech/tech_update_coordinator.py | 7 +- 2 files changed, 105 insertions(+), 73 deletions(-) diff --git a/custom_components/tech/climate.py b/custom_components/tech/climate.py index e2f5f1f..ddd5747 100644 --- a/custom_components/tech/climate.py +++ b/custom_components/tech/climate.py @@ -45,6 +45,11 @@ async def async_setup_entry( await coordinator._async_update_data() zones = coordinator.get_zones() + + async_add_entities( + [TechHub(entry.data["module"], coordinator, api)] + ) + async_add_entities( TechThermostat(zones[zone], coordinator, api) for zone in zones @@ -55,13 +60,106 @@ async def async_setup_entry( return False +class TechHub(CoordinatorEntity, ClimateEntity): + + _attr_supported_features = ClimateEntityFeature.PRESET_MODE + _attr_preset_modes = DEFAULT_PRESETS + + def __init__(self, hub, coordinator, api: Tech) -> None: + """Initialize the Tech Hub device.""" + self._api = api + self._udid = coordinator.udid + + # Set unique_id first as it's required for entity registry + self._attr_unique_id = self._udid + self._attr_device_info = { + "identifiers": {(DOMAIN, self._attr_unique_id)}, + "name": hub["name"], + "manufacturer": "Tech", + } + + super().__init__(coordinator, context=self._id) + + # Initialize attributes that will be updated + self._attr_name: str | None = hub["name"] + self._attr_preset_mode: str | None = None + + self.update_properties(coordinator.get_menu()) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + _LOGGER.debug("Coordinator update for zone %s", self._attr_name) + self.update_properties(self.coordinator.get_menu()) + self.async_write_ha_state() + + def update_properties(self, device_menu_config: dict[str, Any] | None) -> None: + heating_mode = self.get_heating_mode_from_menu_config(device_menu_config) if device_menu_config else None + + if heating_mode is not None: + if heating_mode["duringChange"] == "t": + self._attr_preset_modes = [CHANGE_PRESET] + self._attr_preset_mode = CHANGE_PRESET + else: + self._attr_preset_modes = DEFAULT_PRESETS + heating_mode_id = heating_mode["params"]["value"] + self._attr_preset_mode = self.map_heating_mode_id_to_name(heating_mode_id) + else: + _LOGGER.warning("Heating mode menu not found for Tech zone %s", self._attr_name) + + async def async_set_preset_mode(self, preset_mode: str) -> None: + try: + if self._attr_preset_mode == CHANGE_PRESET: + _LOGGER.debug("Preset mode change already in progress for %s", self._attr_name) + return + + preset_mode_id = DEFAULT_PRESETS.index(preset_mode) + await self._api.set_module_menu( + self._udid, + "mu", + 1000, + preset_mode_id + ) + + self._attr_preset_modes = [CHANGE_PRESET] + self._attr_preset_mode = CHANGE_PRESET + + await self.coordinator.async_request_refresh() + except Exception as ex: + _LOGGER.error( + "Failed to set preset mode for %s to %s: %s", + self._attr_name, + preset_mode, + ex + ) + + def get_heating_mode_from_menu_config(self, menu_config: dict[str, Any]) -> dict[str, Any] | None: + """Get current preset mode from menu config.""" + element = None + heating_mode_menu_id = 1000 + for e in menu_config["elements"]: + if e["id"] == heating_mode_menu_id: + element = e + break + return element + + def map_heating_mode_id_to_name(self, heating_mode_id) -> str: + """Map heating mode id to preset mode name.""" + mapping = { + 0: "Normalny", + 1: "Urlop", + 2: "Ekonomiczny", + 3: "Komfortowy" + } + return mapping.get(heating_mode_id, "Unknown") + + class TechThermostat(CoordinatorEntity, ClimateEntity): """Representation of a Tech climate.""" _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_hvac_modes = SUPPORT_HVAC - _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE - _attr_preset_modes = DEFAULT_PRESETS + _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TARGET_HUMIDITY def __init__(self, device: dict[str, Any], coordinator, api: Tech) -> None: """Initialize the Tech device.""" @@ -87,11 +185,10 @@ def __init__(self, device: dict[str, Any], coordinator, api: Tech) -> None: self._attr_current_humidity: int | None = None self._attr_hvac_action: str | None = None self._attr_hvac_mode: str = HVACMode.OFF - self._attr_preset_mode: str | None = None - self.update_properties(coordinator.get_zones()[self._id], coordinator.get_menu()) + self.update_properties(coordinator.get_zones()[self._id]) - def update_properties(self, device: dict[str, Any], device_menu_config: dict[str, Any] | None) -> None: + def update_properties(self, device: dict[str, Any]) -> None: """Update the properties from device data.""" self._attr_name = device["description"]["name"] @@ -115,26 +212,12 @@ def update_properties(self, device: dict[str, Any], device_menu_config: dict[str mode = zone["zoneState"] self._attr_hvac_mode = HVACMode.HEAT if mode in ["zoneOn", "noAlarm"] else HVACMode.OFF - - heating_mode = self.get_heating_mode_from_menu_config(device_menu_config) if device_menu_config else None - - if heating_mode is not None: - if heating_mode["duringChange"] == "t": - self._attr_preset_modes = [CHANGE_PRESET] - self._attr_preset_mode = CHANGE_PRESET - else: - self._attr_preset_modes = DEFAULT_PRESETS - heating_mode_id = heating_mode["params"]["value"] - self._attr_preset_mode = self.map_heating_mode_id_to_name(heating_mode_id) - else: - _LOGGER.warning("Heating mode menu not found for Tech zone %s", self._attr_name) - @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" _LOGGER.debug("Coordinator update for zone %s", self._attr_name) - self.update_properties(self.coordinator.get_zones()[self._id], self.coordinator.get_menu()) + self.update_properties(self.coordinator.get_zones()[self._id]) self.async_write_ha_state() async def async_set_temperature(self, **kwargs: Any) -> None: @@ -151,32 +234,6 @@ async def async_set_temperature(self, **kwargs: Any) -> None: temperature, ex ) - - async def async_set_preset_mode(self, preset_mode: str) -> None: - try: - if self._attr_preset_mode == CHANGE_PRESET: - _LOGGER.debug("Preset mode change already in progress for %s", self._attr_name) - return - - preset_mode_id = DEFAULT_PRESETS.index(preset_mode) - await self._api.set_module_menu( - self._udid, - "mu", - 1000, - preset_mode_id - ) - - self._attr_preset_modes = [CHANGE_PRESET] - self._attr_preset_mode = CHANGE_PRESET - - await self.coordinator.async_request_refresh() - except Exception as ex: - _LOGGER.error( - "Failed to set preset mode for %s to %s: %s", - self._attr_name, - preset_mode, - ex - ) async def async_set_hvac_mode(self, hvac_mode: str) -> None: """Set new target hvac mode.""" @@ -193,24 +250,4 @@ async def async_set_hvac_mode(self, hvac_mode: str) -> None: self._attr_name, hvac_mode, ex - ) - - def get_heating_mode_from_menu_config(self, menu_config: dict[str, Any]) -> dict[str, Any] | None: - """Get current preset mode from menu config.""" - element = None - heating_mode_menu_id = 1000 - for e in menu_config["elements"]: - if e["id"] == heating_mode_menu_id: - element = e - break - return element - - def map_heating_mode_id_to_name(self, heating_mode_id) -> str: - """Map heating mode id to preset mode name.""" - mapping = { - 0: "Normalny", - 1: "Urlop", - 2: "Ekonomiczny", - 3: "Komfortowy" - } - return mapping.get(heating_mode_id, "Unknown") \ No newline at end of file + ) \ No newline at end of file diff --git a/custom_components/tech/tech_update_coordinator.py b/custom_components/tech/tech_update_coordinator.py index b33287c..cb9b421 100644 --- a/custom_components/tech/tech_update_coordinator.py +++ b/custom_components/tech/tech_update_coordinator.py @@ -55,9 +55,6 @@ async def _async_update_data(self): # Note: asyncio.TimeoutError and aiohttp.ClientError are already # handled by the data update coordinator. async with async_timeout.timeout(10): - # Grab active context variables to limit data required to be fetched from API - # Note: using context is not required if there is no need or ability to limit - # data retrieved from API. _LOGGER.debug("getting data for module %s", self.udid) zones = await self.tech_api.get_module_zones(self.udid) menu = await self.tech_api.get_module_menu(self.udid, "mu") @@ -68,9 +65,7 @@ async def _async_update_data(self): self.data = {"zones": zones, "menu": menu["data"] if menu else None} return self.data - except TechError as err: - # Raising ConfigEntryAuthFailed will cancel future updates - # and start a config flow with SOURCE_REAUTH (async_step_reauth) + except TechError as err: raise UpdateFailed(f"Error communicating with API: {err}") except Exception as err: raise ConfigEntryAuthFailed from err \ No newline at end of file From 9e5fe2e70c8b68f8059ed8012adf5997d660bdd7 Mon Sep 17 00:00:00 2001 From: andrew-dddd Date: Sun, 28 Dec 2025 17:27:54 +0100 Subject: [PATCH 30/50] Added id --- custom_components/tech/climate.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/custom_components/tech/climate.py b/custom_components/tech/climate.py index ddd5747..888fdef 100644 --- a/custom_components/tech/climate.py +++ b/custom_components/tech/climate.py @@ -69,6 +69,7 @@ def __init__(self, hub, coordinator, api: Tech) -> None: """Initialize the Tech Hub device.""" self._api = api self._udid = coordinator.udid + self._id = coordinator.udid # Set unique_id first as it's required for entity registry self._attr_unique_id = self._udid @@ -78,7 +79,7 @@ def __init__(self, hub, coordinator, api: Tech) -> None: "manufacturer": "Tech", } - super().__init__(coordinator, context=self._id) + super().__init__(coordinator, context=self._udid) # Initialize attributes that will be updated self._attr_name: str | None = hub["name"] From c14a2c73b1719db76b5e335e3bc95e17dd0e9689 Mon Sep 17 00:00:00 2001 From: andrew-dddd Date: Sun, 28 Dec 2025 17:45:03 +0100 Subject: [PATCH 31/50] Replaced climate with select entity --- custom_components/tech/climate.py | 32 ++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/custom_components/tech/climate.py b/custom_components/tech/climate.py index 888fdef..c630890 100644 --- a/custom_components/tech/climate.py +++ b/custom_components/tech/climate.py @@ -11,6 +11,9 @@ HVACMode, HVACAction ) + +from homeassistant.components.select import SelectEntity + from homeassistant.const import ( ATTR_TEMPERATURE, UnitOfTemperature, @@ -60,10 +63,9 @@ async def async_setup_entry( return False -class TechHub(CoordinatorEntity, ClimateEntity): - - _attr_supported_features = ClimateEntityFeature.PRESET_MODE - _attr_preset_modes = DEFAULT_PRESETS +class TechHub(CoordinatorEntity, SelectEntity): + _attr_options = DEFAULT_PRESETS + _attr_current_option: str | None = None def __init__(self, hub, coordinator, api: Tech) -> None: """Initialize the Tech Hub device.""" @@ -83,7 +85,7 @@ def __init__(self, hub, coordinator, api: Tech) -> None: # Initialize attributes that will be updated self._attr_name: str | None = hub["name"] - self._attr_preset_mode: str | None = None + self._attr_current_option: str | None = None self.update_properties(coordinator.get_menu()) @@ -99,22 +101,22 @@ def update_properties(self, device_menu_config: dict[str, Any] | None) -> None: if heating_mode is not None: if heating_mode["duringChange"] == "t": - self._attr_preset_modes = [CHANGE_PRESET] + self._attr_options = [CHANGE_PRESET] self._attr_preset_mode = CHANGE_PRESET else: - self._attr_preset_modes = DEFAULT_PRESETS + self._attr_options = DEFAULT_PRESETS heating_mode_id = heating_mode["params"]["value"] - self._attr_preset_mode = self.map_heating_mode_id_to_name(heating_mode_id) + self._attr_current_option = self.map_heating_mode_id_to_name(heating_mode_id) else: _LOGGER.warning("Heating mode menu not found for Tech zone %s", self._attr_name) - async def async_set_preset_mode(self, preset_mode: str) -> None: + async def async_select_option(self, option: str) -> None: try: - if self._attr_preset_mode == CHANGE_PRESET: + if self._attr_current_option == CHANGE_PRESET: _LOGGER.debug("Preset mode change already in progress for %s", self._attr_name) return - preset_mode_id = DEFAULT_PRESETS.index(preset_mode) + preset_mode_id = DEFAULT_PRESETS.index(option) await self._api.set_module_menu( self._udid, "mu", @@ -122,15 +124,15 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: preset_mode_id ) - self._attr_preset_modes = [CHANGE_PRESET] - self._attr_preset_mode = CHANGE_PRESET + self._attr_options = [CHANGE_PRESET] + self._attr_current_option = CHANGE_PRESET await self.coordinator.async_request_refresh() except Exception as ex: _LOGGER.error( "Failed to set preset mode for %s to %s: %s", self._attr_name, - preset_mode, + option, ex ) @@ -160,7 +162,7 @@ class TechThermostat(CoordinatorEntity, ClimateEntity): _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_hvac_modes = SUPPORT_HVAC - _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TARGET_HUMIDITY + _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE def __init__(self, device: dict[str, Any], coordinator, api: Tech) -> None: """Initialize the Tech device.""" From c3083b192e436475c4b777c9d6280f9dea9b1209 Mon Sep 17 00:00:00 2001 From: andrew-dddd Date: Sun, 28 Dec 2025 17:52:31 +0100 Subject: [PATCH 32/50] Replaced climate with select entity --- custom_components/tech/climate.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/custom_components/tech/climate.py b/custom_components/tech/climate.py index c630890..61d54f7 100644 --- a/custom_components/tech/climate.py +++ b/custom_components/tech/climate.py @@ -85,7 +85,6 @@ def __init__(self, hub, coordinator, api: Tech) -> None: # Initialize attributes that will be updated self._attr_name: str | None = hub["name"] - self._attr_current_option: str | None = None self.update_properties(coordinator.get_menu()) @@ -102,7 +101,7 @@ def update_properties(self, device_menu_config: dict[str, Any] | None) -> None: if heating_mode is not None: if heating_mode["duringChange"] == "t": self._attr_options = [CHANGE_PRESET] - self._attr_preset_mode = CHANGE_PRESET + self._attr_current_option = CHANGE_PRESET else: self._attr_options = DEFAULT_PRESETS heating_mode_id = heating_mode["params"]["value"] From d28f2bf8b6176a18404104106ac12a8e5cfbf935 Mon Sep 17 00:00:00 2001 From: andrew-dddd Date: Sun, 28 Dec 2025 18:10:48 +0100 Subject: [PATCH 33/50] Replaced climate with select entity --- custom_components/tech/climate.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/custom_components/tech/climate.py b/custom_components/tech/climate.py index 61d54f7..d41a711 100644 --- a/custom_components/tech/climate.py +++ b/custom_components/tech/climate.py @@ -64,7 +64,7 @@ async def async_setup_entry( class TechHub(CoordinatorEntity, SelectEntity): - _attr_options = DEFAULT_PRESETS + _attr_options: list[str] = DEFAULT_PRESETS _attr_current_option: str | None = None def __init__(self, hub, coordinator, api: Tech) -> None: @@ -91,7 +91,7 @@ def __init__(self, hub, coordinator, api: Tech) -> None: @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" - _LOGGER.debug("Coordinator update for zone %s", self._attr_name) + _LOGGER.debug("Coordinator update for hub %s", self._attr_name) self.update_properties(self.coordinator.get_menu()) self.async_write_ha_state() @@ -107,7 +107,7 @@ def update_properties(self, device_menu_config: dict[str, Any] | None) -> None: heating_mode_id = heating_mode["params"]["value"] self._attr_current_option = self.map_heating_mode_id_to_name(heating_mode_id) else: - _LOGGER.warning("Heating mode menu not found for Tech zone %s", self._attr_name) + _LOGGER.warning("Heating mode menu not found for Tech hub %s", self._attr_name) async def async_select_option(self, option: str) -> None: try: From e748f7e3f7b9a9a503e7b0a85d2998bbb3caee66 Mon Sep 17 00:00:00 2001 From: andrew-dddd Date: Sun, 28 Dec 2025 18:48:09 +0100 Subject: [PATCH 34/50] Added logs --- custom_components/tech/climate.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/custom_components/tech/climate.py b/custom_components/tech/climate.py index d41a711..f8bce39 100644 --- a/custom_components/tech/climate.py +++ b/custom_components/tech/climate.py @@ -97,15 +97,19 @@ def _handle_coordinator_update(self) -> None: def update_properties(self, device_menu_config: dict[str, Any] | None) -> None: heating_mode = self.get_heating_mode_from_menu_config(device_menu_config) if device_menu_config else None + _LOGGER.debug("Updating heating mode for hub %s: %s", self._attr_name, heating_mode) - if heating_mode is not None: + if heating_mode is not None: if heating_mode["duringChange"] == "t": + _LOGGER.debug("Preset mode change in progress for %s", self._attr_name) self._attr_options = [CHANGE_PRESET] self._attr_current_option = CHANGE_PRESET + _LOGGER.debug("Current preset mode for %s: %s", self._attr_name, self._attr_current_option) else: self._attr_options = DEFAULT_PRESETS heating_mode_id = heating_mode["params"]["value"] self._attr_current_option = self.map_heating_mode_id_to_name(heating_mode_id) + _LOGGER.debug("Current preset mode for %s: %s", self._attr_name, self._attr_current_option) else: _LOGGER.warning("Heating mode menu not found for Tech hub %s", self._attr_name) From 8f07d82945bdf25162a224ebcd0f794ab852a7cc Mon Sep 17 00:00:00 2001 From: andrew-dddd Date: Sun, 28 Dec 2025 20:42:41 +0100 Subject: [PATCH 35/50] added reauth --- custom_components/tech/config_flow.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/custom_components/tech/config_flow.py b/custom_components/tech/config_flow.py index 4c47752..9cd28e0 100644 --- a/custom_components/tech/config_flow.py +++ b/custom_components/tech/config_flow.py @@ -74,6 +74,16 @@ async def async_step_user(self, user_input=None): return self.async_show_form( step_id="user", data_schema=DATA_SCHEMA, errors=errors ) + + async def async_step_reauth(self, user_input=None): + """Handle reauth step.""" + if user_input is None: + return self.async_show_form( + step_id="reauth_confirm", + data_schema=DATA_SCHEMA, + ) + + return await self.async_step_user() def _create_config_entry(self, module: dict) -> ConfigEntry: return ConfigEntry( From 7441df139ffe4b67e0ed08ff03bacfbd361b7d49 Mon Sep 17 00:00:00 2001 From: andrew-dddd Date: Sun, 28 Dec 2025 21:02:29 +0100 Subject: [PATCH 36/50] extracted select entity --- custom_components/tech/__init__.py | 2 +- custom_components/tech/climate.py | 105 ----------------------------- 2 files changed, 1 insertion(+), 106 deletions(-) diff --git a/custom_components/tech/__init__.py b/custom_components/tech/__init__.py index 823c60e..d661f13 100644 --- a/custom_components/tech/__init__.py +++ b/custom_components/tech/__init__.py @@ -17,7 +17,7 @@ CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.Schema({})}, extra=vol.ALLOW_EXTRA) # List the platforms that you want to support. -PLATFORMS = [Platform.CLIMATE] +PLATFORMS = [Platform.CLIMATE, Platform.SELECT] async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: diff --git a/custom_components/tech/climate.py b/custom_components/tech/climate.py index f8bce39..c1fc444 100644 --- a/custom_components/tech/climate.py +++ b/custom_components/tech/climate.py @@ -12,8 +12,6 @@ HVACAction ) -from homeassistant.components.select import SelectEntity - from homeassistant.const import ( ATTR_TEMPERATURE, UnitOfTemperature, @@ -31,8 +29,6 @@ _LOGGER = logging.getLogger(__name__) SUPPORT_HVAC: Final = [HVACMode.HEAT, HVACMode.OFF] -DEFAULT_PRESETS = ["Normalny", "Urlop", "Ekonomiczny", "Komfortowy"] -CHANGE_PRESET = "Oczekiwanie na zmianę" async def async_setup_entry( hass: HomeAssistant, @@ -49,10 +45,6 @@ async def async_setup_entry( zones = coordinator.get_zones() - async_add_entities( - [TechHub(entry.data["module"], coordinator, api)] - ) - async_add_entities( TechThermostat(zones[zone], coordinator, api) for zone in zones @@ -61,103 +53,6 @@ async def async_setup_entry( except Exception as ex: _LOGGER.error("Failed to set up Tech climate: %s", ex) return False - - -class TechHub(CoordinatorEntity, SelectEntity): - _attr_options: list[str] = DEFAULT_PRESETS - _attr_current_option: str | None = None - - def __init__(self, hub, coordinator, api: Tech) -> None: - """Initialize the Tech Hub device.""" - self._api = api - self._udid = coordinator.udid - self._id = coordinator.udid - - # Set unique_id first as it's required for entity registry - self._attr_unique_id = self._udid - self._attr_device_info = { - "identifiers": {(DOMAIN, self._attr_unique_id)}, - "name": hub["name"], - "manufacturer": "Tech", - } - - super().__init__(coordinator, context=self._udid) - - # Initialize attributes that will be updated - self._attr_name: str | None = hub["name"] - - self.update_properties(coordinator.get_menu()) - - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - _LOGGER.debug("Coordinator update for hub %s", self._attr_name) - self.update_properties(self.coordinator.get_menu()) - self.async_write_ha_state() - - def update_properties(self, device_menu_config: dict[str, Any] | None) -> None: - heating_mode = self.get_heating_mode_from_menu_config(device_menu_config) if device_menu_config else None - _LOGGER.debug("Updating heating mode for hub %s: %s", self._attr_name, heating_mode) - - if heating_mode is not None: - if heating_mode["duringChange"] == "t": - _LOGGER.debug("Preset mode change in progress for %s", self._attr_name) - self._attr_options = [CHANGE_PRESET] - self._attr_current_option = CHANGE_PRESET - _LOGGER.debug("Current preset mode for %s: %s", self._attr_name, self._attr_current_option) - else: - self._attr_options = DEFAULT_PRESETS - heating_mode_id = heating_mode["params"]["value"] - self._attr_current_option = self.map_heating_mode_id_to_name(heating_mode_id) - _LOGGER.debug("Current preset mode for %s: %s", self._attr_name, self._attr_current_option) - else: - _LOGGER.warning("Heating mode menu not found for Tech hub %s", self._attr_name) - - async def async_select_option(self, option: str) -> None: - try: - if self._attr_current_option == CHANGE_PRESET: - _LOGGER.debug("Preset mode change already in progress for %s", self._attr_name) - return - - preset_mode_id = DEFAULT_PRESETS.index(option) - await self._api.set_module_menu( - self._udid, - "mu", - 1000, - preset_mode_id - ) - - self._attr_options = [CHANGE_PRESET] - self._attr_current_option = CHANGE_PRESET - - await self.coordinator.async_request_refresh() - except Exception as ex: - _LOGGER.error( - "Failed to set preset mode for %s to %s: %s", - self._attr_name, - option, - ex - ) - - def get_heating_mode_from_menu_config(self, menu_config: dict[str, Any]) -> dict[str, Any] | None: - """Get current preset mode from menu config.""" - element = None - heating_mode_menu_id = 1000 - for e in menu_config["elements"]: - if e["id"] == heating_mode_menu_id: - element = e - break - return element - - def map_heating_mode_id_to_name(self, heating_mode_id) -> str: - """Map heating mode id to preset mode name.""" - mapping = { - 0: "Normalny", - 1: "Urlop", - 2: "Ekonomiczny", - 3: "Komfortowy" - } - return mapping.get(heating_mode_id, "Unknown") class TechThermostat(CoordinatorEntity, ClimateEntity): From 371ef98e7abb9f67c5007a58ccd5b5926376dfd4 Mon Sep 17 00:00:00 2001 From: andrew-dddd Date: Sun, 28 Dec 2025 21:09:48 +0100 Subject: [PATCH 37/50] added file --- custom_components/tech/select.py | 142 +++++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 custom_components/tech/select.py diff --git a/custom_components/tech/select.py b/custom_components/tech/select.py new file mode 100644 index 0000000..f80547f --- /dev/null +++ b/custom_components/tech/select.py @@ -0,0 +1,142 @@ +"""Support for Tech HVAC system.""" +from __future__ import annotations + +import logging +from typing import Any + +from custom_components.tech.tech_update_coordinator import TechUpdateCoordinator + +from homeassistant.components.select import SelectEntity + +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import (HomeAssistant, callback) +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .tech import Tech + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_PRESETS = ["Normalny", "Urlop", "Ekonomiczny", "Komfortowy"] +CHANGE_PRESET = "Oczekiwanie na zmianę" + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> bool: + """Set up Tech climate based on config_entry.""" + api: Tech = hass.data[DOMAIN][entry.entry_id] + udid: str = entry.data["module"]["udid"] + + try: + coordinator = TechUpdateCoordinator(hass, entry, api, udid) + await coordinator._async_update_data() + + async_add_entities( + [TechHub(entry.data["module"], coordinator, api)] + ) + + return True + except Exception as ex: + _LOGGER.error("Failed to set up Tech climate: %s", ex) + return False + +class TechHub(CoordinatorEntity, SelectEntity): + _attr_options: list[str] = DEFAULT_PRESETS + _attr_current_option: str | None = None + + def __init__(self, hub, coordinator, api: Tech) -> None: + """Initialize the Tech Hub device.""" + self._api = api + self._udid = coordinator.udid + self._id = coordinator.udid + + # Set unique_id first as it's required for entity registry + self._attr_unique_id = self._udid + self._attr_device_info = { + "identifiers": {(DOMAIN, self._attr_unique_id)}, + "name": hub["name"], + "manufacturer": "Tech", + } + + super().__init__(coordinator, context=self._udid) + + # Initialize attributes that will be updated + self._attr_name: str | None = hub["name"] + + self.update_properties(coordinator.get_menu()) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + _LOGGER.debug("Coordinator update for hub %s", self._attr_name) + self.update_properties(self.coordinator.get_menu()) + self.async_write_ha_state() + + def update_properties(self, device_menu_config: dict[str, Any] | None) -> None: + heating_mode = self.get_heating_mode_from_menu_config(device_menu_config) if device_menu_config else None + _LOGGER.debug("Updating heating mode for hub %s: %s", self._attr_name, heating_mode) + + if heating_mode is not None: + if heating_mode["duringChange"] == "t": + _LOGGER.debug("Preset mode change in progress for %s", self._attr_name) + self._attr_options = [CHANGE_PRESET] + self._attr_current_option = CHANGE_PRESET + _LOGGER.debug("Current preset mode for %s: %s", self._attr_name, self._attr_current_option) + else: + self._attr_options = DEFAULT_PRESETS + heating_mode_id = heating_mode["params"]["value"] + self._attr_current_option = self.map_heating_mode_id_to_name(heating_mode_id) + _LOGGER.debug("Current preset mode for %s: %s", self._attr_name, self._attr_current_option) + else: + _LOGGER.warning("Heating mode menu not found for Tech hub %s", self._attr_name) + + async def async_select_option(self, option: str) -> None: + try: + if self._attr_current_option == CHANGE_PRESET: + _LOGGER.debug("Preset mode change already in progress for %s", self._attr_name) + return + + preset_mode_id = DEFAULT_PRESETS.index(option) + await self._api.set_module_menu( + self._udid, + "mu", + 1000, + preset_mode_id + ) + + self._attr_options = [CHANGE_PRESET] + self._attr_current_option = CHANGE_PRESET + + await self.coordinator.async_request_refresh() + except Exception as ex: + _LOGGER.error( + "Failed to set preset mode for %s to %s: %s", + self._attr_name, + option, + ex + ) + + def get_heating_mode_from_menu_config(self, menu_config: dict[str, Any]) -> dict[str, Any] | None: + """Get current preset mode from menu config.""" + element = None + heating_mode_menu_id = 1000 + for e in menu_config["elements"]: + if e["id"] == heating_mode_menu_id: + element = e + break + return element + + def map_heating_mode_id_to_name(self, heating_mode_id) -> str: + """Map heating mode id to preset mode name.""" + mapping = { + 0: "Normalny", + 1: "Urlop", + 2: "Ekonomiczny", + 3: "Komfortowy" + } + return mapping.get(heating_mode_id, "Unknown") \ No newline at end of file From fd32d7bd83b73fadd7565d0f8cad173b0e25dc65 Mon Sep 17 00:00:00 2001 From: andrew-dddd Date: Sun, 28 Dec 2025 21:47:38 +0100 Subject: [PATCH 38/50] centralized coordinator creation --- custom_components/tech/__init__.py | 11 +++++++++-- custom_components/tech/climate.py | 6 ++---- custom_components/tech/select.py | 7 ++----- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/custom_components/tech/__init__.py b/custom_components/tech/__init__.py index d661f13..3d01942 100644 --- a/custom_components/tech/__init__.py +++ b/custom_components/tech/__init__.py @@ -9,6 +9,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client from homeassistant.helpers.typing import ConfigType +from tech_update_coordinator import TechUpdateCoordinator from .const import DOMAIN from .tech import Tech @@ -40,13 +41,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Store an API object for your platforms to access hass.data.setdefault(DOMAIN, {}) - http_session = aiohttp_client.async_get_clientsession(hass) - hass.data[DOMAIN][entry.entry_id] = Tech( + http_session = aiohttp_client.async_get_clientsession(hass) + api = Tech( http_session, entry.data["user_id"], entry.data["token"] ) + coordinator = TechUpdateCoordinator(hass, entry, api, entry.data["module"]["udid"]) + await coordinator._async_update_data() + + hass.data[DOMAIN][entry.entry_id]["api"] = api + hass.data[DOMAIN][entry.entry_id]["coordinator"] = coordinator + # Use async_forward_entry_setups instead of async_forward_entry_setup await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/custom_components/tech/climate.py b/custom_components/tech/climate.py index c1fc444..182b5db 100644 --- a/custom_components/tech/climate.py +++ b/custom_components/tech/climate.py @@ -36,13 +36,11 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> bool: """Set up Tech climate based on config_entry.""" - api: Tech = hass.data[DOMAIN][entry.entry_id] - udid: str = entry.data["module"]["udid"] + api: Tech = hass.data[DOMAIN][entry.entry_id]["api"] + coordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"] try: - coordinator = TechUpdateCoordinator(hass, entry, api, udid) await coordinator._async_update_data() - zones = coordinator.get_zones() async_add_entities( diff --git a/custom_components/tech/select.py b/custom_components/tech/select.py index f80547f..a65664e 100644 --- a/custom_components/tech/select.py +++ b/custom_components/tech/select.py @@ -30,12 +30,9 @@ async def async_setup_entry( ) -> bool: """Set up Tech climate based on config_entry.""" api: Tech = hass.data[DOMAIN][entry.entry_id] - udid: str = entry.data["module"]["udid"] + coordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"] - try: - coordinator = TechUpdateCoordinator(hass, entry, api, udid) - await coordinator._async_update_data() - + try: async_add_entities( [TechHub(entry.data["module"], coordinator, api)] ) From d30364da612f7317eec0899a2d2b612c17adc5ad Mon Sep 17 00:00:00 2001 From: andrew-dddd Date: Sun, 28 Dec 2025 21:53:24 +0100 Subject: [PATCH 39/50] fix --- custom_components/tech/__init__.py | 2 +- custom_components/tech/climate.py | 3 +-- custom_components/tech/select.py | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/custom_components/tech/__init__.py b/custom_components/tech/__init__.py index 3d01942..36fc061 100644 --- a/custom_components/tech/__init__.py +++ b/custom_components/tech/__init__.py @@ -9,7 +9,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client from homeassistant.helpers.typing import ConfigType -from tech_update_coordinator import TechUpdateCoordinator +from custom_components.tech.tech_update_coordinator import TechUpdateCoordinator from .const import DOMAIN from .tech import Tech diff --git a/custom_components/tech/climate.py b/custom_components/tech/climate.py index 182b5db..1e5d7f4 100644 --- a/custom_components/tech/climate.py +++ b/custom_components/tech/climate.py @@ -37,10 +37,9 @@ async def async_setup_entry( ) -> bool: """Set up Tech climate based on config_entry.""" api: Tech = hass.data[DOMAIN][entry.entry_id]["api"] - coordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"] + coordinator: TechUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"] try: - await coordinator._async_update_data() zones = coordinator.get_zones() async_add_entities( diff --git a/custom_components/tech/select.py b/custom_components/tech/select.py index a65664e..1cdc000 100644 --- a/custom_components/tech/select.py +++ b/custom_components/tech/select.py @@ -30,7 +30,7 @@ async def async_setup_entry( ) -> bool: """Set up Tech climate based on config_entry.""" api: Tech = hass.data[DOMAIN][entry.entry_id] - coordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"] + coordinator: TechUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"] try: async_add_entities( From c9d7fd61aa7241cc01883a9eac51f114441228e6 Mon Sep 17 00:00:00 2001 From: andrew-dddd Date: Sun, 28 Dec 2025 22:14:34 +0100 Subject: [PATCH 40/50] fix --- custom_components/tech/__init__.py | 6 ++++-- custom_components/tech/select.py | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/custom_components/tech/__init__.py b/custom_components/tech/__init__.py index 36fc061..08dea6c 100644 --- a/custom_components/tech/__init__.py +++ b/custom_components/tech/__init__.py @@ -51,8 +51,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = TechUpdateCoordinator(hass, entry, api, entry.data["module"]["udid"]) await coordinator._async_update_data() - hass.data[DOMAIN][entry.entry_id]["api"] = api - hass.data[DOMAIN][entry.entry_id]["coordinator"] = coordinator + hass.data[DOMAIN][entry.entry_id] = { + "api": api, + "coordinator": coordinator + } # Use async_forward_entry_setups instead of async_forward_entry_setup await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/custom_components/tech/select.py b/custom_components/tech/select.py index 1cdc000..e309138 100644 --- a/custom_components/tech/select.py +++ b/custom_components/tech/select.py @@ -29,7 +29,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> bool: """Set up Tech climate based on config_entry.""" - api: Tech = hass.data[DOMAIN][entry.entry_id] + api: Tech = hass.data[DOMAIN][entry.entry_id]["api"] coordinator: TechUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"] try: From fbe9734d35aafcb7e109481a4fd3fba811a06b9a Mon Sep 17 00:00:00 2001 From: andrew-dddd Date: Sun, 28 Dec 2025 22:28:45 +0100 Subject: [PATCH 41/50] fix --- custom_components/tech/select.py | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/custom_components/tech/select.py b/custom_components/tech/select.py index e309138..048beff 100644 --- a/custom_components/tech/select.py +++ b/custom_components/tech/select.py @@ -19,10 +19,14 @@ _LOGGER = logging.getLogger(__name__) -DEFAULT_PRESETS = ["Normalny", "Urlop", "Ekonomiczny", "Komfortowy"] +DEFAULT_PRESETS = { + 0: "Normalny", + 1: "Urlop", + 2: "Ekonomiczny", + 3: "Komfortowy" + } CHANGE_PRESET = "Oczekiwanie na zmianę" - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, @@ -43,7 +47,7 @@ async def async_setup_entry( return False class TechHub(CoordinatorEntity, SelectEntity): - _attr_options: list[str] = DEFAULT_PRESETS + _attr_options: list[str] = list(DEFAULT_PRESETS.values()) _attr_current_option: str | None = None def __init__(self, hub, coordinator, api: Tech) -> None: @@ -85,7 +89,7 @@ def update_properties(self, device_menu_config: dict[str, Any] | None) -> None: self._attr_current_option = CHANGE_PRESET _LOGGER.debug("Current preset mode for %s: %s", self._attr_name, self._attr_current_option) else: - self._attr_options = DEFAULT_PRESETS + self._attr_options = list(DEFAULT_PRESETS.values()) heating_mode_id = heating_mode["params"]["value"] self._attr_current_option = self.map_heating_mode_id_to_name(heating_mode_id) _LOGGER.debug("Current preset mode for %s: %s", self._attr_name, self._attr_current_option) @@ -96,9 +100,9 @@ async def async_select_option(self, option: str) -> None: try: if self._attr_current_option == CHANGE_PRESET: _LOGGER.debug("Preset mode change already in progress for %s", self._attr_name) - return + return - preset_mode_id = DEFAULT_PRESETS.index(option) + preset_mode_id = list(DEFAULT_PRESETS.values()).index(option) await self._api.set_module_menu( self._udid, "mu", @@ -130,10 +134,4 @@ def get_heating_mode_from_menu_config(self, menu_config: dict[str, Any]) -> dict def map_heating_mode_id_to_name(self, heating_mode_id) -> str: """Map heating mode id to preset mode name.""" - mapping = { - 0: "Normalny", - 1: "Urlop", - 2: "Ekonomiczny", - 3: "Komfortowy" - } - return mapping.get(heating_mode_id, "Unknown") \ No newline at end of file + return DEFAULT_PRESETS.get(heating_mode_id, "Unknown") \ No newline at end of file From b80747ef4561ff39858108a13bdf486689d12c46 Mon Sep 17 00:00:00 2001 From: andrew-dddd Date: Sun, 28 Dec 2025 23:11:34 +0100 Subject: [PATCH 42/50] Fix --- custom_components/tech/climate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/tech/climate.py b/custom_components/tech/climate.py index 7d90543..fe6d387 100644 --- a/custom_components/tech/climate.py +++ b/custom_components/tech/climate.py @@ -91,7 +91,7 @@ def __init__(self, device: dict[str, Any], coordinator, api: Tech) -> None: self.update_properties(coordinator.get_zones()[self._id]) - def update_properties(self, device: dict[str, Any], device_menu_config: dict[str, Any] | None) -> None: + def update_properties(self, device: dict[str, Any]) -> None: """Update the properties from device data.""" self._attr_name = device["description"]["name"] From bddd0c340e58b319b8fe689a98b4bb9f1acc1bc1 Mon Sep 17 00:00:00 2001 From: andrew-dddd Date: Thu, 8 Jan 2026 11:07:53 +0100 Subject: [PATCH 43/50] Added typed responses --- custom_components/tech/climate.py | 57 ++---- custom_components/tech/config_flow.py | 26 +-- custom_components/tech/models/__init__.py | 50 +++++ custom_components/tech/models/module.py | 190 ++++++++++++++++++ custom_components/tech/models/module_menu.py | 46 +++++ custom_components/tech/select.py | 25 ++- custom_components/tech/tech.py | 45 +++-- .../tech/tech_update_coordinator.py | 10 +- 8 files changed, 365 insertions(+), 84 deletions(-) create mode 100644 custom_components/tech/models/__init__.py create mode 100644 custom_components/tech/models/module.py create mode 100644 custom_components/tech/models/module_menu.py diff --git a/custom_components/tech/climate.py b/custom_components/tech/climate.py index fe6d387..8fc7bc4 100644 --- a/custom_components/tech/climate.py +++ b/custom_components/tech/climate.py @@ -4,6 +4,7 @@ import logging from typing import Any, Final +from custom_components.tech.models.module import ZoneElement from custom_components.tech.tech_update_coordinator import TechUpdateCoordinator from homeassistant.components.climate import ( ClimateEntity, @@ -63,18 +64,18 @@ class TechThermostat(CoordinatorEntity, ClimateEntity): _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE _attr_preset_modes = DEFAULT_PRESETS - def __init__(self, device: dict[str, Any], coordinator, api: Tech) -> None: + def __init__(self, device: ZoneElement, coordinator, api: Tech) -> None: """Initialize the Tech device.""" self._api = api - self._id: int = device["zone"]["id"] - self._zone_mode_id = device["mode"]["id"] + self._id: int = device.zone.id + self._zone_mode_id = device.mode.id self._udid = coordinator.udid # Set unique_id first as it's required for entity registry self._attr_unique_id = f"{self._udid}_{self._id}" self._attr_device_info = { "identifiers": {(DOMAIN, self._attr_unique_id)}, - "name": device["description"]["name"], + "name": device.description.name, "manufacturer": "Tech", } @@ -91,21 +92,21 @@ def __init__(self, device: dict[str, Any], coordinator, api: Tech) -> None: self.update_properties(coordinator.get_zones()[self._id]) - def update_properties(self, device: dict[str, Any]) -> None: + def update_properties(self, device: ZoneElement) -> None: """Update the properties from device data.""" - self._attr_name = device["description"]["name"] + self._attr_name = device.description.name - zone = device["zone"] - if zone["setTemperature"] is not None: - self._attr_target_temperature = zone["setTemperature"] / 10 + zone = device.zone + if zone.setTemperature is not None: + self._attr_target_temperature = zone.setTemperature / 10 - if zone["currentTemperature"] is not None: - self._attr_current_temperature = zone["currentTemperature"] / 10 + if zone.currentTemperature is not None: + self._attr_current_temperature = zone.currentTemperature / 10 - if zone["humidity"] is not None: - self._attr_current_humidity = zone["humidity"] + if zone.humidity is not None: + self._attr_current_humidity = zone.humidity - state = zone["flags"]["relayState"] + state = zone.flags.relayState if state == "on": self._attr_hvac_action = HVACAction.HEATING elif state == "off": @@ -113,7 +114,7 @@ def update_properties(self, device: dict[str, Any]) -> None: else: self._attr_hvac_action = HVACAction.OFF - mode = zone["zoneState"] + mode = zone.zoneState self._attr_hvac_mode = HVACMode.HEAT if mode in ["zoneOn", "noAlarm"] else HVACMode.OFF @callback @@ -137,32 +138,6 @@ async def async_set_temperature(self, **kwargs: Any) -> None: temperature, ex ) - - async def async_set_preset_mode(self, preset_mode: str) -> None: - try: - if self._attr_preset_mode == CHANGE_PRESET: - _LOGGER.debug("Preset mode change already in progress for %s", self._attr_name) - return - - preset_mode_id = DEFAULT_PRESETS.index(preset_mode) - await self._api.set_module_menu( - self._udid, - "mu", - 1000, - preset_mode_id - ) - - self._attr_preset_modes = [CHANGE_PRESET] - self._attr_preset_mode = CHANGE_PRESET - - await self.coordinator.async_request_refresh() - except Exception as ex: - _LOGGER.error( - "Failed to set preset mode for %s to %s: %s", - self._attr_name, - preset_mode, - ex - ) async def async_set_hvac_mode(self, hvac_mode: str) -> None: """Set new target hvac mode.""" diff --git a/custom_components/tech/config_flow.py b/custom_components/tech/config_flow.py index 9cd28e0..4c2ece7 100644 --- a/custom_components/tech/config_flow.py +++ b/custom_components/tech/config_flow.py @@ -1,4 +1,5 @@ """Config flow for Tech Sterowniki integration.""" +from typing import Any import logging, uuid import voluptuous as vol from homeassistant import config_entries, core, exceptions @@ -7,6 +8,7 @@ from .const import DOMAIN # pylint:disable=unused-import from .tech import Tech from types import MappingProxyType +from models.module import Module, UserModule _LOGGER = logging.getLogger(__name__) @@ -53,7 +55,7 @@ async def async_step_user(self, user_input=None): _LOGGER.debug("Context: " + str(self.context)) validated_input = await validate_input(self.hass, user_input) - modules = self._create_modules_array(validated_input=validated_input) + modules: list[UserModule] = self._create_modules_array(validated_input=validated_input) if len(modules) == 0: return self.async_abort("no_modules") @@ -62,7 +64,7 @@ async def async_step_user(self, user_input=None): for module in modules[1:len(modules)]: await self.hass.config_entries.async_add(self._create_config_entry(module=module)) - return self.async_create_entry(title=modules[0]["version"], data=modules[0]) + return self.async_create_entry(title=modules[0].module_title, data=modules[0]) except CannotConnect: errors["base"] = "cannot_connect" except InvalidAuth: @@ -85,10 +87,10 @@ async def async_step_reauth(self, user_input=None): return await self.async_step_user() - def _create_config_entry(self, module: dict) -> ConfigEntry: + def _create_config_entry(self, module: UserModule) -> ConfigEntry: return ConfigEntry( data=module, - title=module["version"], + title=module.module_title, entry_id=uuid.uuid4().hex, discovery_keys=MappingProxyType({}), domain=DOMAIN, @@ -99,19 +101,19 @@ def _create_config_entry(self, module: dict) -> ConfigEntry: unique_id=None, subentries_data=[]) - def _create_modules_array(self, validated_input: dict) -> [dict]: + def _create_modules_array(self, validated_input: dict) -> list[UserModule]: return [ self._create_module_dict(validated_input, module_dict) for module_dict in validated_input["modules"] ] - def _create_module_dict(self, validated_input: dict, module_dict: dict) -> dict: - return { - "user_id": validated_input["user_id"], - "token": validated_input["token"], - "module": module_dict, - "version": module_dict["version"] + ": " + module_dict["name"] - } + def _create_module_dict(self, validated_input: dict, module: Module) -> UserModule: + return UserModule( + user_id=validated_input["user_id"], + token=validated_input["token"], + module=module, + module_title=module.version + ": " + module.name + ) class CannotConnect(exceptions.HomeAssistantError): diff --git a/custom_components/tech/models/__init__.py b/custom_components/tech/models/__init__.py new file mode 100644 index 0000000..5f10151 --- /dev/null +++ b/custom_components/tech/models/__init__.py @@ -0,0 +1,50 @@ +"""Models package for Tech API responses.""" +from .module_menu import ( + MenuElementOption, + MenuElementParams, + MenuElement, + ModuleMenuData, + ModuleMenuResponse, +) +from .module import ( + Module, + ModuleData, + Zones, + ZoneElement, + Zone, + ZoneDescription, + ZoneMode, + ZoneSchedule, + ZoneFlags, + ScheduleInterval, + GlobalSchedules, + GlobalSchedule, + ControllerParameters, + ControllerMode, + Tile, + TileParams, +) + +__all__ = [ + "MenuElementOption", + "MenuElementParams", + "MenuElement", + "ModuleMenuData", + "ModuleMenuResponse", + "Module", + "ModuleData", + "Zones", + "ZoneElement", + "Zone", + "ZoneDescription", + "ZoneMode", + "ZoneSchedule", + "ZoneFlags", + "ScheduleInterval", + "GlobalSchedules", + "GlobalSchedule", + "ControllerParameters", + "ControllerMode", + "Tile", + "TileParams", +] diff --git a/custom_components/tech/models/module.py b/custom_components/tech/models/module.py new file mode 100644 index 0000000..60ae07f --- /dev/null +++ b/custom_components/tech/models/module.py @@ -0,0 +1,190 @@ +"""Models for module API responses.""" +from typing import Any, Optional +from pydantic import BaseModel + +class Module(BaseModel): + """Represents a single module.""" + id: int + default: bool + name: str + email: str + type: str + controllerStatus: str + moduleStatus: str + additionalInformation: str + phoneNumber: Optional[str] = None + zipCode: str + tag: Optional[str] = None + country: Optional[str] = None + gmtId: int + gmtTime: str + postcodePolicyAccepted: bool + style: str + version: str + company: str + udid: str + +class UserModule(BaseModel): + """Represents a user module.""" + user_id: str + token: str + module: Module + module_title: str + +# Models for get_module_data response + +class ZoneFlags(BaseModel): + """Represents zone flags.""" + relayState: str + minOneWindowOpen: bool + algorithm: str + floorSensor: int + humidityAlgorytm: int + zoneExcluded: int + + +class Zone(BaseModel): + """Represents zone information.""" + id: int + parentId: int + time: str + duringChange: bool + index: int + currentTemperature: int + setTemperature: int + flags: ZoneFlags + zoneState: str + signalStrength: Optional[int] = None + batteryLevel: Optional[int] = None + actuatorsOpen: int + humidity: int + visibility: bool + + +class ZoneDescription(BaseModel): + """Represents zone description.""" + id: int + parentId: int + name: str + styleId: int + styleIcon: str + duringChange: bool + + +class ZoneMode(BaseModel): + """Represents zone mode.""" + id: int + parentId: int + mode: str + constTempTime: int + setTemperature: int + scheduleIndex: int + + +class ScheduleInterval(BaseModel): + """Represents a schedule interval.""" + start: int + stop: int + temp: int + + +class ZoneSchedule(BaseModel): + """Represents zone schedule.""" + id: int + parentId: int + index: int + p0Days: list[str] + p0Intervals: list[ScheduleInterval] + p0SetbackTemp: int + p1Days: list[str] + p1Intervals: list[ScheduleInterval] + p1SetbackTemp: int + + +class ZoneElement(BaseModel): + """Represents a zone element.""" + zone: Zone + description: ZoneDescription + mode: ZoneMode + schedule: ZoneSchedule + actuators: list[Any] + underfloor: dict[str, Any] + windowsSensors: list[Any] + additionalContacts: list[Any] + + +class GlobalSchedule(BaseModel): + """Represents a global schedule.""" + id: int + parentId: int + index: int + name: str + p0Days: list[str] + p0SetbackTemp: int + p0Intervals: list[ScheduleInterval] + p1Days: list[str] + p1SetbackTemp: int + p1Intervals: list[ScheduleInterval] + + +class GlobalSchedules(BaseModel): + """Represents global schedules container.""" + time: str + duringChange: bool + elements: list[GlobalSchedule] + + +class ControllerMode(BaseModel): + """Represents controller mode.""" + id: int + parentId: int + type: int + txtId: int + iconId: int + value: int + menuId: int + + +class ControllerParameters(BaseModel): + """Represents controller parameters.""" + controllerMode: ControllerMode + globalSchedulesNumber: dict[str, Any] + + +class Zones(BaseModel): + """Represents zones container.""" + transaction_time: str + elements: list[ZoneElement] + globalSchedules: GlobalSchedules + controllerParameters: ControllerParameters + + +class TileParams(BaseModel): + """Represents tile parameters.""" + description: str + txtId: int + iconId: int + version: Optional[str] = None + companyId: Optional[int] = None + controllerName: Optional[str] = None + mainControllerId: Optional[int] = None + workingStatus: Optional[bool] = None + + +class Tile(BaseModel): + """Represents a tile.""" + id: int + parentId: int + type: int + menuId: int + orderId: Optional[int] = None + visibility: bool + params: TileParams + + +class ModuleData(BaseModel): + """Represents the full response from get_module_data API.""" + zones: Zones + tiles: list[Tile] + tilesOrder: Optional[Any] = None + tilesLastUpdate: str diff --git a/custom_components/tech/models/module_menu.py b/custom_components/tech/models/module_menu.py new file mode 100644 index 0000000..e96f6a1 --- /dev/null +++ b/custom_components/tech/models/module_menu.py @@ -0,0 +1,46 @@ +"""Models for module menu API responses.""" +from typing import Optional +from pydantic import BaseModel + + +class MenuElementOption(BaseModel): + """Represents a single option in a radio button control.""" + txtId: int + value: int + + +class MenuElementParams(BaseModel): + """Represents parameters of a menu element.""" + description: str + value: Optional[int] = None + default: Optional[int] = None + options: Optional[list[MenuElementOption]] = None + txtId: Optional[int] = None + type: Optional[int] = None + blockHide: Optional[int] = None + + +class MenuElement(BaseModel): + """Represents a single menu element.""" + menuType: str + type: int + id: int + parentId: int + access: bool + txtId: int + wikiTxtId: int + iconId: int + params: MenuElementParams + duringChange: Optional[str] + + +class ModuleMenuData(BaseModel): + """Represents the data section of module menu response.""" + elements: list[MenuElement] + transaction_time: str + + +class ModuleMenuResponse(BaseModel): + """Represents the full response from get_module_menu API.""" + status: str + data: ModuleMenuData diff --git a/custom_components/tech/select.py b/custom_components/tech/select.py index 048beff..cfa514d 100644 --- a/custom_components/tech/select.py +++ b/custom_components/tech/select.py @@ -4,6 +4,8 @@ import logging from typing import Any +from custom_components.tech.models.module import Module, UserModule +from custom_components.tech.models.module_menu import MenuElement, MenuElement, ModuleMenuData, ModuleMenuResponse from custom_components.tech.tech_update_coordinator import TechUpdateCoordinator from homeassistant.components.select import SelectEntity @@ -35,10 +37,11 @@ async def async_setup_entry( """Set up Tech climate based on config_entry.""" api: Tech = hass.data[DOMAIN][entry.entry_id]["api"] coordinator: TechUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"] + user_module: UserModule = entry.data["module"] try: async_add_entities( - [TechHub(entry.data["module"], coordinator, api)] + [TechHub(user_module.module, coordinator, api)] ) return True @@ -50,7 +53,7 @@ class TechHub(CoordinatorEntity, SelectEntity): _attr_options: list[str] = list(DEFAULT_PRESETS.values()) _attr_current_option: str | None = None - def __init__(self, hub, coordinator, api: Tech) -> None: + def __init__(self, hub: Module, coordinator: TechUpdateCoordinator, api: Tech) -> None: """Initialize the Tech Hub device.""" self._api = api self._udid = coordinator.udid @@ -60,14 +63,14 @@ def __init__(self, hub, coordinator, api: Tech) -> None: self._attr_unique_id = self._udid self._attr_device_info = { "identifiers": {(DOMAIN, self._attr_unique_id)}, - "name": hub["name"], + "name": hub.name, "manufacturer": "Tech", } super().__init__(coordinator, context=self._udid) # Initialize attributes that will be updated - self._attr_name: str | None = hub["name"] + self._attr_name: str | None = hub.name self.update_properties(coordinator.get_menu()) @@ -78,19 +81,19 @@ def _handle_coordinator_update(self) -> None: self.update_properties(self.coordinator.get_menu()) self.async_write_ha_state() - def update_properties(self, device_menu_config: dict[str, Any] | None) -> None: - heating_mode = self.get_heating_mode_from_menu_config(device_menu_config) if device_menu_config else None + def update_properties(self, device_menu_config: ModuleMenuResponse | None) -> None: + heating_mode = self.get_heating_mode_from_menu_config(device_menu_config.data) if device_menu_config else None _LOGGER.debug("Updating heating mode for hub %s: %s", self._attr_name, heating_mode) if heating_mode is not None: - if heating_mode["duringChange"] == "t": + if heating_mode.duringChange == "t": _LOGGER.debug("Preset mode change in progress for %s", self._attr_name) self._attr_options = [CHANGE_PRESET] self._attr_current_option = CHANGE_PRESET _LOGGER.debug("Current preset mode for %s: %s", self._attr_name, self._attr_current_option) else: self._attr_options = list(DEFAULT_PRESETS.values()) - heating_mode_id = heating_mode["params"]["value"] + heating_mode_id = heating_mode.params.value self._attr_current_option = self.map_heating_mode_id_to_name(heating_mode_id) _LOGGER.debug("Current preset mode for %s: %s", self._attr_name, self._attr_current_option) else: @@ -122,12 +125,12 @@ async def async_select_option(self, option: str) -> None: ex ) - def get_heating_mode_from_menu_config(self, menu_config: dict[str, Any]) -> dict[str, Any] | None: + def get_heating_mode_from_menu_config(self, menu_config: ModuleMenuData) -> MenuElement | None: """Get current preset mode from menu config.""" element = None heating_mode_menu_id = 1000 - for e in menu_config["elements"]: - if e["id"] == heating_mode_menu_id: + for e in menu_config.elements: + if e.id == heating_mode_menu_id: element = e break return element diff --git a/custom_components/tech/tech.py b/custom_components/tech/tech.py index 419884a..c0a2ce8 100644 --- a/custom_components/tech/tech.py +++ b/custom_components/tech/tech.py @@ -6,7 +6,13 @@ import json import time import asyncio +from typing import Type, TypeVar, overload from aiocache import Cache, cached +from pydantic import BaseModel, TypeAdapter + +from .models import Module, ModuleData, ModuleMenuResponse, ZoneElement + +T = TypeVar("T", bound=BaseModel) logging.basicConfig(level=logging.DEBUG) _LOGGER = logging.getLogger(__name__) @@ -33,7 +39,13 @@ def __init__(self, session: aiohttp.ClientSession, user_id = None, token = None, self.authenticated = False self.zones = {} - async def get(self, request_path): + @overload + async def get(self, request_path: str) -> dict: ... + + @overload + async def get(self, request_path: str, response_type: Type[T]) -> T: ... + + async def get(self, request_path: str, response_type: Type[T] | None = None) -> dict | T: url = self.base_url + request_path _LOGGER.debug("Sending GET request: " + url) async with self.session.get(url, headers=self.headers) as response: @@ -43,6 +55,9 @@ async def get(self, request_path): data = await response.json() _LOGGER.debug(data) + + if response_type is not None: + return response_type.model_validate(data) return data async def post(self, request_path, post_data): @@ -72,25 +87,24 @@ async def authenticate(self, username, password): } return result["authenticated"] - async def list_modules(self): + async def list_modules(self) -> list[Module]: if self.authenticated: path = "users/" + self.user_id + "/modules" result = await self.get(path) + return TypeAdapter(list[Module]).validate_python(result) else: raise TechError(401, "Unauthorized") - return result - async def get_module_data(self, module_udid): + async def get_module_data(self, module_udid) -> ModuleData: _LOGGER.debug("Getting module data..." + module_udid + ", " + self.user_id) if self.authenticated: path = "users/" + self.user_id + "/modules/" + module_udid - result = await self.get(path) + return await self.get(path, ModuleData) else: raise TechError(401, "Unauthorized") - return result @cached(ttl=10, cache=Cache.MEMORY) - async def get_module_zones(self, module_udid): + async def get_module_zones(self, module_udid) -> dict[int, ZoneElement]: """Returns Tech module zones either from cache or it will update all the cached values for Tech module assuming no update has occurred for at least the [update_interval]. @@ -103,11 +117,11 @@ async def get_module_zones(self, module_udid): Dictionary of zones indexed by zone ID. """ result = await self.get_module_data(module_udid) - zones = result["zones"]["elements"] - zones = list(filter(lambda e: e['zone']['zoneState'] != "zoneUnregistered", zones)) - return { zone["zone"]["id"]: zone for zone in zones } + zones = result.zones.elements + zones = list(filter(lambda e: e.zone.zoneState != "zoneUnregistered", zones)) + return { zone.zone.id: zone for zone in zones } - async def get_zone(self, module_udid, zone_id): + async def get_zone(self, module_udid, zone_id) -> ZoneElement: """Returns zone from Tech API cache. Parameters: @@ -115,7 +129,7 @@ async def get_zone(self, module_udid, zone_id): zone_id (int): The Tech module zone ID. Returns: - Dictionary of zone. + ZoneElement object. """ zones = await self.get_module_zones(module_udid) return zones[zone_id] @@ -178,7 +192,7 @@ async def set_zone(self, module_udid, zone_id, on = True): return result @cached(ttl=10, cache=Cache.MEMORY) - async def get_module_menu(self, module_udid, menu_type): + async def get_module_menu(self, module_udid, menu_type) -> ModuleMenuResponse: """ Gets module menu options Parameters: @@ -186,16 +200,15 @@ async def get_module_menu(self, module_udid, menu_type): menu_type (string): Menu type, one of the following: "MU", "MI", "MS", "MP" Return: - JSON object with results + ModuleMenuResponse object with results """ _LOGGER.debug("Getting module menu: %s", menu_type) if self.authenticated: path = f"users/{self.user_id}/modules/{module_udid}/menu/{menu_type}" - result = await self.get(path) + return await self.get(path, ModuleMenuResponse) else: raise TechError(401, "Unauthorized") - return result async def set_module_menu(self, module_udid, menu_type, menu_id, menu_value): """ Sets module menu value diff --git a/custom_components/tech/tech_update_coordinator.py b/custom_components/tech/tech_update_coordinator.py index cb9b421..577a9da 100644 --- a/custom_components/tech/tech_update_coordinator.py +++ b/custom_components/tech/tech_update_coordinator.py @@ -6,6 +6,8 @@ import async_timeout +from custom_components.tech.models.module import ZoneElement +from custom_components.tech.models.module_menu import ModuleMenuResponse from custom_components.tech.tech import (Tech, TechError) from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import ( @@ -37,11 +39,11 @@ def get_data(self) -> dict[str, Any]: """Return the latest data.""" return self.data - def get_zones(self) -> dict[str, Any]: + def get_zones(self) -> dict[int, ZoneElement]: """Return the latest zones data.""" return self.data["zones"] - def get_menu(self) -> dict[str, Any] | None: + def get_menu(self) -> ModuleMenuResponse | None: """Return the latest menu data.""" return self.data["menu"] @@ -59,11 +61,11 @@ async def _async_update_data(self): zones = await self.tech_api.get_module_zones(self.udid) menu = await self.tech_api.get_module_menu(self.udid, "mu") - if menu["status"] != "success": + if menu.status != "success": _LOGGER.warning("Failed to get menu config for Tech module %s, response: %s", self.udid, menu) menu = None - self.data = {"zones": zones, "menu": menu["data"] if menu else None} + self.data = {"zones": zones, "menu": menu.data if menu else None} return self.data except TechError as err: raise UpdateFailed(f"Error communicating with API: {err}") From cb4f04a5f75e61e87a86615640e8c3f2c1f2213b Mon Sep 17 00:00:00 2001 From: andrew-dddd Date: Thu, 8 Jan 2026 13:03:46 +0100 Subject: [PATCH 44/50] removed reauth --- custom_components/tech/config_flow.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/custom_components/tech/config_flow.py b/custom_components/tech/config_flow.py index 4c2ece7..a01578e 100644 --- a/custom_components/tech/config_flow.py +++ b/custom_components/tech/config_flow.py @@ -77,15 +77,15 @@ async def async_step_user(self, user_input=None): step_id="user", data_schema=DATA_SCHEMA, errors=errors ) - async def async_step_reauth(self, user_input=None): - """Handle reauth step.""" - if user_input is None: - return self.async_show_form( - step_id="reauth_confirm", - data_schema=DATA_SCHEMA, - ) + #async def async_step_reauth(self, user_input=None): + # """Handle reauth step.""" + #if user_input is None: + # return self.async_show_form( + # step_id="reauth_confirm", + # data_schema=DATA_SCHEMA, + # ) - return await self.async_step_user() + #return await self.async_step_user() def _create_config_entry(self, module: UserModule) -> ConfigEntry: return ConfigEntry( From 4b6f927cbd40edac3317279d3378c804dea64cfa Mon Sep 17 00:00:00 2001 From: andrew-dddd Date: Thu, 8 Jan 2026 13:14:38 +0100 Subject: [PATCH 45/50] removed whitespaces --- custom_components/tech/config_flow.py | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/custom_components/tech/config_flow.py b/custom_components/tech/config_flow.py index a01578e..2804844 100644 --- a/custom_components/tech/config_flow.py +++ b/custom_components/tech/config_flow.py @@ -30,7 +30,7 @@ async def validate_input(hass: core.HomeAssistant, data): if not await api.authenticate(data["username"], data["password"]): raise InvalidAuth modules = await api.list_modules() - + # If you cannot connect: # throw CannotConnect # If the authentication is wrong: @@ -52,7 +52,7 @@ async def async_step_user(self, user_input=None): errors = {} if user_input is not None: try: - _LOGGER.debug("Context: " + str(self.context)) + _LOGGER.debug("Context: " + str(self.context)) validated_input = await validate_input(self.hass, user_input) modules: list[UserModule] = self._create_modules_array(validated_input=validated_input) @@ -63,7 +63,7 @@ async def async_step_user(self, user_input=None): if len(modules) > 1: for module in modules[1:len(modules)]: await self.hass.config_entries.async_add(self._create_config_entry(module=module)) - + return self.async_create_entry(title=modules[0].module_title, data=modules[0]) except CannotConnect: errors["base"] = "cannot_connect" @@ -76,16 +76,6 @@ async def async_step_user(self, user_input=None): return self.async_show_form( step_id="user", data_schema=DATA_SCHEMA, errors=errors ) - - #async def async_step_reauth(self, user_input=None): - # """Handle reauth step.""" - #if user_input is None: - # return self.async_show_form( - # step_id="reauth_confirm", - # data_schema=DATA_SCHEMA, - # ) - - #return await self.async_step_user() def _create_config_entry(self, module: UserModule) -> ConfigEntry: return ConfigEntry( @@ -121,4 +111,4 @@ class CannotConnect(exceptions.HomeAssistantError): class InvalidAuth(exceptions.HomeAssistantError): - """Error to indicate there is invalid auth.""" + """Error to indicate there is invalid auth.""" \ No newline at end of file From 40e4d9674349496d0bf9b703b5a39aa221d18aec Mon Sep 17 00:00:00 2001 From: andrew-dddd Date: Thu, 8 Jan 2026 13:20:50 +0100 Subject: [PATCH 46/50] added minor version --- custom_components/tech/config_flow.py | 1 + 1 file changed, 1 insertion(+) diff --git a/custom_components/tech/config_flow.py b/custom_components/tech/config_flow.py index 2804844..c887813 100644 --- a/custom_components/tech/config_flow.py +++ b/custom_components/tech/config_flow.py @@ -44,6 +44,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Tech Sterowniki.""" VERSION = 1 + MINOR_VERSION = 1 # Pick one of the available connection classes in homeassistant/config_entries.py CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL From 1bae648392c3420ca070635c53c93b38540676a4 Mon Sep 17 00:00:00 2001 From: andrew-dddd Date: Thu, 8 Jan 2026 13:24:26 +0100 Subject: [PATCH 47/50] udpated imports --- custom_components/tech/__init__.py | 2 +- custom_components/tech/climate.py | 4 ++-- custom_components/tech/config_flow.py | 2 +- custom_components/tech/select.py | 6 +++--- custom_components/tech/tech_update_coordinator.py | 6 +++--- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/custom_components/tech/__init__.py b/custom_components/tech/__init__.py index 08dea6c..e22e40c 100644 --- a/custom_components/tech/__init__.py +++ b/custom_components/tech/__init__.py @@ -9,7 +9,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client from homeassistant.helpers.typing import ConfigType -from custom_components.tech.tech_update_coordinator import TechUpdateCoordinator +from .tech_update_coordinator import TechUpdateCoordinator from .const import DOMAIN from .tech import Tech diff --git a/custom_components/tech/climate.py b/custom_components/tech/climate.py index 8fc7bc4..56c94b6 100644 --- a/custom_components/tech/climate.py +++ b/custom_components/tech/climate.py @@ -4,8 +4,8 @@ import logging from typing import Any, Final -from custom_components.tech.models.module import ZoneElement -from custom_components.tech.tech_update_coordinator import TechUpdateCoordinator +from .models.module import ZoneElement +from .tech_update_coordinator import TechUpdateCoordinator from homeassistant.components.climate import ( ClimateEntity, ClimateEntityFeature, diff --git a/custom_components/tech/config_flow.py b/custom_components/tech/config_flow.py index c887813..448ab7e 100644 --- a/custom_components/tech/config_flow.py +++ b/custom_components/tech/config_flow.py @@ -8,7 +8,7 @@ from .const import DOMAIN # pylint:disable=unused-import from .tech import Tech from types import MappingProxyType -from models.module import Module, UserModule +from .models.module import Module, UserModule _LOGGER = logging.getLogger(__name__) diff --git a/custom_components/tech/select.py b/custom_components/tech/select.py index cfa514d..f13eb27 100644 --- a/custom_components/tech/select.py +++ b/custom_components/tech/select.py @@ -4,9 +4,9 @@ import logging from typing import Any -from custom_components.tech.models.module import Module, UserModule -from custom_components.tech.models.module_menu import MenuElement, MenuElement, ModuleMenuData, ModuleMenuResponse -from custom_components.tech.tech_update_coordinator import TechUpdateCoordinator +from .models.module import Module, UserModule +from .models.module_menu import MenuElement, MenuElement, ModuleMenuData, ModuleMenuResponse +from .tech_update_coordinator import TechUpdateCoordinator from homeassistant.components.select import SelectEntity diff --git a/custom_components/tech/tech_update_coordinator.py b/custom_components/tech/tech_update_coordinator.py index 577a9da..f1ae1f1 100644 --- a/custom_components/tech/tech_update_coordinator.py +++ b/custom_components/tech/tech_update_coordinator.py @@ -6,9 +6,9 @@ import async_timeout -from custom_components.tech.models.module import ZoneElement -from custom_components.tech.models.module_menu import ModuleMenuResponse -from custom_components.tech.tech import (Tech, TechError) +from .models.module import ZoneElement +from .models.module_menu import ModuleMenuResponse +from .tech import (Tech, TechError) from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import ( DataUpdateCoordinator, From dc315e26a4ff1b7bc4a626ba4869b471e9780358 Mon Sep 17 00:00:00 2001 From: andrew-dddd Date: Thu, 8 Jan 2026 13:34:05 +0100 Subject: [PATCH 48/50] fix --- custom_components/tech/select.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/custom_components/tech/select.py b/custom_components/tech/select.py index f13eb27..203e7aa 100644 --- a/custom_components/tech/select.py +++ b/custom_components/tech/select.py @@ -34,10 +34,10 @@ async def async_setup_entry( entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> bool: - """Set up Tech climate based on config_entry.""" + """Set up Tech select based on config_entry.""" api: Tech = hass.data[DOMAIN][entry.entry_id]["api"] coordinator: TechUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"] - user_module: UserModule = entry.data["module"] + user_module: UserModule = entry.data try: async_add_entities( @@ -46,7 +46,7 @@ async def async_setup_entry( return True except Exception as ex: - _LOGGER.error("Failed to set up Tech climate: %s", ex) + _LOGGER.error("Failed to set up Tech select: %s", ex) return False class TechHub(CoordinatorEntity, SelectEntity): From 73c17a23575d1b9e4889eba7d6090a3f11b4b544 Mon Sep 17 00:00:00 2001 From: andrew-dddd Date: Thu, 8 Jan 2026 13:53:04 +0100 Subject: [PATCH 49/50] fix --- custom_components/tech/select.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/tech/select.py b/custom_components/tech/select.py index 203e7aa..8737b31 100644 --- a/custom_components/tech/select.py +++ b/custom_components/tech/select.py @@ -37,11 +37,11 @@ async def async_setup_entry( """Set up Tech select based on config_entry.""" api: Tech = hass.data[DOMAIN][entry.entry_id]["api"] coordinator: TechUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"] - user_module: UserModule = entry.data + module_data = Module(**entry.data["module"]) try: async_add_entities( - [TechHub(user_module.module, coordinator, api)] + [TechHub(module_data, coordinator, api)] ) return True From 235b3c31b0f21150b12919a99163e87e383252e3 Mon Sep 17 00:00:00 2001 From: andrew-dddd Date: Thu, 8 Jan 2026 14:25:43 +0100 Subject: [PATCH 50/50] fix --- custom_components/tech/select.py | 19 +++++++++---------- .../tech/tech_update_coordinator.py | 8 ++++---- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/custom_components/tech/select.py b/custom_components/tech/select.py index 8737b31..204b690 100644 --- a/custom_components/tech/select.py +++ b/custom_components/tech/select.py @@ -5,7 +5,7 @@ from typing import Any from .models.module import Module, UserModule -from .models.module_menu import MenuElement, MenuElement, ModuleMenuData, ModuleMenuResponse +from .models.module_menu import MenuElement, MenuElement, ModuleMenuData from .tech_update_coordinator import TechUpdateCoordinator from homeassistant.components.select import SelectEntity @@ -53,7 +53,7 @@ class TechHub(CoordinatorEntity, SelectEntity): _attr_options: list[str] = list(DEFAULT_PRESETS.values()) _attr_current_option: str | None = None - def __init__(self, hub: Module, coordinator: TechUpdateCoordinator, api: Tech) -> None: + def __init__(self, module: Module, coordinator: TechUpdateCoordinator, api: Tech) -> None: """Initialize the Tech Hub device.""" self._api = api self._udid = coordinator.udid @@ -63,14 +63,14 @@ def __init__(self, hub: Module, coordinator: TechUpdateCoordinator, api: Tech) - self._attr_unique_id = self._udid self._attr_device_info = { "identifiers": {(DOMAIN, self._attr_unique_id)}, - "name": hub.name, + "name": module.name, "manufacturer": "Tech", } super().__init__(coordinator, context=self._udid) # Initialize attributes that will be updated - self._attr_name: str | None = hub.name + self._attr_name: str | None = module.name self.update_properties(coordinator.get_menu()) @@ -81,8 +81,8 @@ def _handle_coordinator_update(self) -> None: self.update_properties(self.coordinator.get_menu()) self.async_write_ha_state() - def update_properties(self, device_menu_config: ModuleMenuResponse | None) -> None: - heating_mode = self.get_heating_mode_from_menu_config(device_menu_config.data) if device_menu_config else None + def update_properties(self, module_menu_data: ModuleMenuData | None) -> None: + heating_mode = self.get_heating_mode_from_menu_config(module_menu_data) if module_menu_data else None _LOGGER.debug("Updating heating mode for hub %s: %s", self._attr_name, heating_mode) if heating_mode is not None: @@ -127,13 +127,12 @@ async def async_select_option(self, option: str) -> None: def get_heating_mode_from_menu_config(self, menu_config: ModuleMenuData) -> MenuElement | None: """Get current preset mode from menu config.""" - element = None + heating_mode_menu_id = 1000 for e in menu_config.elements: if e.id == heating_mode_menu_id: - element = e - break - return element + return e + return None def map_heating_mode_id_to_name(self, heating_mode_id) -> str: """Map heating mode id to preset mode name.""" diff --git a/custom_components/tech/tech_update_coordinator.py b/custom_components/tech/tech_update_coordinator.py index f1ae1f1..7704994 100644 --- a/custom_components/tech/tech_update_coordinator.py +++ b/custom_components/tech/tech_update_coordinator.py @@ -6,9 +6,9 @@ import async_timeout -from .models.module import ZoneElement -from .models.module_menu import ModuleMenuResponse -from .tech import (Tech, TechError) +from custom_components.tech.models.module import ZoneElement +from custom_components.tech.models.module_menu import ModuleMenuData +from custom_components.tech.tech import (Tech, TechError) from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import ( DataUpdateCoordinator, @@ -43,7 +43,7 @@ def get_zones(self) -> dict[int, ZoneElement]: """Return the latest zones data.""" return self.data["zones"] - def get_menu(self) -> ModuleMenuResponse | None: + def get_menu(self) -> ModuleMenuData | None: """Return the latest menu data.""" return self.data["menu"]