├── .gitignore ├── README.md ├── custom_components └── tech │ ├── CONTRIBUTING.md │ ├── LICENSE.md │ ├── __init__.py │ ├── climate.py │ ├── config_flow.py │ ├── const.py │ ├── images │ ├── ha-tech-1.png │ ├── ha-tech-2.png │ ├── ha-tech-add-integration-1.png │ ├── ha-tech-add-integration-2.png │ ├── ha-tech-add-integration-3.png │ ├── ha-tech-add-integration.png │ ├── ha-tech-logs-ex.png │ └── ha-tech-logs.png │ ├── manifest.json │ ├── strings.json │ ├── tech.py │ ├── test_tech.py │ └── translations │ └── en.json ├── hacs.json └── repository.yaml /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | .vscode 3 | .idea -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Dislaimer 2 | Due to a stall on the original [mariusz-ostoja-swierczynski/tech-controllers](https://github.com/mariusz-ostoja-swierczynski/tech-controllers) repo, we have forked. 3 | Most of the work was done by Mariusz Ostoja-Świerczyński, for which we're very thankful as the community. 4 | It's time to move on, however. New formats of plugins appear, HA goes on with the feature implementation, 5 | and as a community we need make sure that this plugin keeps up to the changing world. 6 | 7 | # TECH Controllers integration for Home Assistant 8 | The integration of heating controllers from Polish company TECH Sterowniki Sp. z o.o. It uses API to their web control application eModul.eu, therefore your controller needs to be accessible from internet and you need an account either on https://emodul.eu or https://emodul.pl. 9 | 10 | eModul API Documentation https://emodul.eu/docs/api-v1.txt 11 | 12 | The integration is based on API provided by TECH which supports following controllers: 13 | * L-4 Wifi 14 | * L-7 15 | * L-8 16 | * WiFi 8S 17 | * ST-8S WiFi 18 | 19 | Unfortunately, I own only L-8 controller based on which it was developed and tested. Therefore, please report within [this issue](https://github.com/mariusz-ostoja-swierczynski/tech-controllers/issues/2) if this integration works with your controller and what version. 20 | 21 | ## Disclaimer 22 | This is my first integration ever developed for Home Assistant, and although I don't see any way how this software can harm your devices, you are using it on your own risk and I do not provide any warranties. 23 | 24 | ## Features 25 | * Configuration through Integrations (not via configuration.yaml) 26 | * Provides Climate entities representing zones in household 27 | * Climate entities displays data through Thermostat card 28 | * Displays zone name 29 | * Displays current zone temperature 30 | * Controls target zone temperature 31 | * Displays current zone state (heating or idle) 32 | * Controls and displays zone mode (on or off) 33 | 34 | ![Tech Thermostat Cards](/custom_components/tech/images/ha-tech-1.png) 35 | 36 | ## Plans for development 37 | * Support for multiply controllers 38 | * Publish the tech.py Python Package to PyPI 39 | * Write tests for HA component 40 | * Support for window opening sensor 41 | * Support for cold tolerance setting 42 | * Support for zones schedules 43 | 44 | ## Installation 45 | 46 | 1. Copy entire repository content into your config/custom_components/tech folder of your Home Assistant installation. 47 | **Note:** If you don't have in your installation "custom_components" folder you need to create one and "tech" folder in it. 48 | 2. Restart Home Assistant. 49 | 3. Go to Configuration -> Integrations and click Add button. 50 | 4. Search for "Tech Controllers" integration and select it. 51 | 5. Enter your username (could be email) and password for your eModule account and click "Submit" button. 52 | 6. You should see "Success!" dialog with a name and version of your main Tech controller. 53 | **Note:** The integration currently supports handling only one controller. If the API returns list of more than one controllers in your household, the only first one will be used. 54 | 7. Now you should have Climate entities representing your home zones available in Home Assistant. Go to your UI Lovelace configuration and add Thermostat card with your Climate entities. 55 | 56 | ![Tech Controllers Setup 1](/custom_components/tech/images/ha-tech-add-integration-1.png) 57 | 58 | ![Tech Controllers Setup 2](/custom_components/tech/images/ha-tech-add-integration-2.png) 59 | 60 | ![Tech Controllers Setup 3](/custom_components/tech/images/ha-tech-add-integration-3.png) 61 | 62 | ![Tech Controllers Setup 4](/custom_components/tech/images/ha-tech-2.png) 63 | 64 | ## List of reported working TECH Controllers 65 | * L4-WiFi (v.1.0.24) 66 | * L-7 (v.2.0.8) 67 | * L-7E (v.1.0.6) 68 | * L-8 (v.3.0.14) 69 | * L-9r (v1.0.2) 70 | * WiFi 8S (v.2.1.8) 71 | * ST-8s WIFI (v.1.0.5) 72 | * ST-16s WIFI (v.1.0.5) 73 | * M-9 (v1.0.12) 74 | -------------------------------------------------------------------------------- /custom_components/tech/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to TECH Controllers integration for Home Assistant 2 | 3 | :+1::tada: First off all, many thanks for taking the time to contribute! Appreciated! :tada::+1: 4 | 5 | The following is a set of guidelines for contributing to this integration. These are mostly guidelines, not rules. Use your best judgment, and feel free to propose changes to this document in a pull request. 6 | 7 | ## Reporting Bugs 8 | 9 | This section guides you through submitting a bug report for the integration. Following these guidelines helps maintainers and the community understand your report, reproduce the behavior, and find related reports. 10 | 11 | Before creating bug reports, please check [this issues list](https://github.com/mariusz-ostoja-swierczynski/tech-controllers/issues) as you might find out that you don't need to create one. When you are creating a bug report, please **include as many details as possible**: 12 | 13 | * **Use a clear and descriptive title** for the issue to identify the problem. 14 | * **Describe the exact steps which reproduce the problem** in as many details as possible. 15 | * **Provide specific examples to demonstrate the steps**. 16 | * **Describe the behavior you observed after following the steps** and point out what exactly is the problem with that behavior. 17 | * **Explain which behavior you expected to see instead and why.** 18 | * **Please try to include logs** 19 | 20 | ### Getting logs from your Home Assistant 21 | 22 | 1. Enable debug logs for "tech" component by adding following to your configuration.yaml file within config folder: 23 | ```yaml 24 | logger: 25 | default: info 26 | logs: 27 | homeassistant.components.tech: debug 28 | ``` 29 | 2. Restart your Home Assistant. 30 | 31 | 3. Go to **Developer Tools** from left menu, then **LOGS** tab and press **LOAD FULL HOME ASSISTANT LOG** button. 32 | 33 | ![HA TECH LOGS](/images/ha-tech-logs.png) 34 | 35 | 4. Search for a place with homeassistant.components.climate or homeassistant.components.tech or just climate, copy it and add to the reported issue. 36 | 37 | ![HA TECH LOGS EXAMPLE](/images/ha-tech-logs-ex.png) 38 | -------------------------------------------------------------------------------- /custom_components/tech/LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [year] [fullname] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /custom_components/tech/__init__.py: -------------------------------------------------------------------------------- 1 | """The Tech Controllers integration.""" 2 | from __future__ import annotations 3 | 4 | import logging 5 | import voluptuous as vol 6 | 7 | from homeassistant.config_entries import ConfigEntry 8 | from homeassistant.const import Platform 9 | from homeassistant.core import HomeAssistant 10 | from homeassistant.helpers import aiohttp_client 11 | from homeassistant.helpers.typing import ConfigType 12 | 13 | from .const import DOMAIN 14 | from .tech import Tech 15 | 16 | _LOGGER = logging.getLogger(__name__) 17 | CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.Schema({})}, extra=vol.ALLOW_EXTRA) 18 | 19 | # List the platforms that you want to support. 20 | PLATFORMS = [Platform.CLIMATE] 21 | 22 | 23 | async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: 24 | """Set up the Tech Controllers component.""" 25 | hass.data.setdefault(DOMAIN, {}) 26 | return True 27 | 28 | 29 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 30 | """Set up Tech Controllers from a config entry.""" 31 | _LOGGER.debug("Setting up component's entry.") 32 | _LOGGER.debug("Entry id: %s", entry.entry_id) 33 | _LOGGER.debug( 34 | "Entry -> title: %s, data: %s, id: %s, domain: %s", 35 | entry.title, 36 | entry.data, 37 | entry.entry_id, 38 | entry.domain 39 | ) 40 | 41 | # Store an API object for your platforms to access 42 | hass.data.setdefault(DOMAIN, {}) 43 | http_session = aiohttp_client.async_get_clientsession(hass) 44 | hass.data[DOMAIN][entry.entry_id] = Tech( 45 | http_session, 46 | entry.data["user_id"], 47 | entry.data["token"] 48 | ) 49 | 50 | # Use async_forward_entry_setups instead of async_forward_entry_setup 51 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 52 | return True 53 | 54 | 55 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 56 | """Unload a config entry.""" 57 | # Use async_unload_platforms instead of async_forward_entry_unload 58 | unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) 59 | 60 | if unload_ok: 61 | hass.data[DOMAIN].pop(entry.entry_id) 62 | 63 | return unload_ok 64 | -------------------------------------------------------------------------------- /custom_components/tech/climate.py: -------------------------------------------------------------------------------- 1 | """Support for Tech HVAC system.""" 2 | from __future__ import annotations 3 | 4 | import logging 5 | from typing import Any, Final 6 | 7 | from homeassistant.components.climate import ( 8 | ClimateEntity, 9 | ClimateEntityFeature, 10 | HVACMode, 11 | HVACAction, 12 | ) 13 | from homeassistant.const import ( 14 | ATTR_TEMPERATURE, 15 | UnitOfTemperature, 16 | ) 17 | from homeassistant.config_entries import ConfigEntry 18 | from homeassistant.core import HomeAssistant 19 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 20 | 21 | from .const import DOMAIN 22 | from .tech import Tech 23 | 24 | _LOGGER = logging.getLogger(__name__) 25 | 26 | SUPPORT_HVAC: Final = [HVACMode.HEAT, HVACMode.OFF] 27 | 28 | async def async_setup_entry( 29 | hass: HomeAssistant, 30 | entry: ConfigEntry, 31 | async_add_entities: AddEntitiesCallback, 32 | ) -> bool: 33 | """Set up Tech climate based on config_entry.""" 34 | api: Tech = hass.data[DOMAIN][entry.entry_id] 35 | udid: str = entry.data["module"]["udid"] 36 | 37 | try: 38 | zones = await api.get_module_zones(udid) 39 | async_add_entities( 40 | TechThermostat(zones[zone], api, udid) 41 | for zone in zones 42 | ) 43 | return True 44 | except Exception as ex: 45 | _LOGGER.error("Failed to set up Tech climate: %s", ex) 46 | return False 47 | 48 | 49 | class TechThermostat(ClimateEntity): 50 | """Representation of a Tech climate.""" 51 | 52 | _attr_temperature_unit = UnitOfTemperature.CELSIUS 53 | _attr_hvac_modes = SUPPORT_HVAC 54 | _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE 55 | 56 | def __init__(self, device: dict[str, Any], api: Tech, udid: str) -> None: 57 | """Initialize the Tech device.""" 58 | self._api = api 59 | self._id: int = device["zone"]["id"] 60 | self._udid = udid 61 | 62 | # Set unique_id first as it's required for entity registry 63 | self._attr_unique_id = f"{udid}_{device['zone']['id']}" 64 | self._attr_device_info = { 65 | "identifiers": {(DOMAIN, self._attr_unique_id)}, 66 | "name": device["description"]["name"], 67 | "manufacturer": "Tech", 68 | } 69 | 70 | # Initialize attributes that will be updated 71 | self._attr_name: str | None = None 72 | self._attr_target_temperature: float | None = None 73 | self._attr_current_temperature: float | None = None 74 | self._attr_current_humidity: int | None = None 75 | self._attr_hvac_action: str | None = None 76 | self._attr_hvac_mode: str = HVACMode.OFF 77 | 78 | self.update_properties(device) 79 | 80 | def update_properties(self, device: dict[str, Any]) -> None: 81 | """Update the properties from device data.""" 82 | self._attr_name = device["description"]["name"] 83 | 84 | zone = device["zone"] 85 | if zone["setTemperature"] is not None: 86 | self._attr_target_temperature = zone["setTemperature"] / 10 87 | 88 | if zone["currentTemperature"] is not None: 89 | self._attr_current_temperature = zone["currentTemperature"] / 10 90 | 91 | if zone["humidity"] is not None: 92 | self._attr_current_humidity = zone["humidity"] 93 | 94 | state = zone["flags"]["relayState"] 95 | if state == "on": 96 | self._attr_hvac_action = HVACAction.HEATING 97 | elif state == "off": 98 | self._attr_hvac_action = HVACAction.IDLE 99 | else: 100 | self._attr_hvac_action = HVACAction.OFF 101 | 102 | mode = zone["zoneState"] 103 | self._attr_hvac_mode = HVACMode.HEAT if mode in ["zoneOn", "noAlarm"] else HVACMode.OFF 104 | 105 | async def async_update(self) -> None: 106 | """Update the entity.""" 107 | try: 108 | device = await self._api.get_zone(self._udid, self._id) 109 | self.update_properties(device) 110 | except Exception as ex: 111 | _LOGGER.error("Failed to update Tech zone %s: %s", self._attr_name, ex) 112 | 113 | async def async_set_temperature(self, **kwargs: Any) -> None: 114 | """Set new target temperature.""" 115 | temperature = kwargs.get(ATTR_TEMPERATURE) 116 | if temperature is not None: 117 | try: 118 | await self._api.set_const_temp(self._udid, self._id, temperature) 119 | except Exception as ex: 120 | _LOGGER.error( 121 | "Failed to set temperature for %s to %s: %s", 122 | self._attr_name, 123 | temperature, 124 | ex 125 | ) 126 | 127 | async def async_set_hvac_mode(self, hvac_mode: str) -> None: 128 | """Set new target hvac mode.""" 129 | try: 130 | await self._api.set_zone( 131 | self._udid, 132 | self._id, 133 | hvac_mode == HVACMode.HEAT 134 | ) 135 | except Exception as ex: 136 | _LOGGER.error( 137 | "Failed to set hvac mode for %s to %s: %s", 138 | self._attr_name, 139 | hvac_mode, 140 | ex 141 | ) 142 | -------------------------------------------------------------------------------- /custom_components/tech/config_flow.py: -------------------------------------------------------------------------------- 1 | """Config flow for Tech Sterowniki integration.""" 2 | import logging, uuid 3 | import voluptuous as vol 4 | from homeassistant import config_entries, core, exceptions 5 | from homeassistant.helpers import aiohttp_client 6 | from homeassistant.config_entries import ConfigEntry 7 | from .const import DOMAIN # pylint:disable=unused-import 8 | from .tech import Tech 9 | 10 | _LOGGER = logging.getLogger(__name__) 11 | 12 | DATA_SCHEMA = vol.Schema({ 13 | vol.Required("username"): str, 14 | vol.Required("password"): str, 15 | }) 16 | 17 | 18 | async def validate_input(hass: core.HomeAssistant, data): 19 | """Validate the user input allows us to connect. 20 | 21 | Data has the keys from DATA_SCHEMA with values provided by the user. 22 | """ 23 | 24 | http_session = aiohttp_client.async_get_clientsession(hass) 25 | api = Tech(http_session) 26 | 27 | if not await api.authenticate(data["username"], data["password"]): 28 | raise InvalidAuth 29 | modules = await api.list_modules() 30 | 31 | # If you cannot connect: 32 | # throw CannotConnect 33 | # If the authentication is wrong: 34 | # InvalidAuth 35 | 36 | # Return info that you want to store in the config entry. 37 | return { "user_id": api.user_id, "token": api.token, "modules": modules } 38 | 39 | 40 | class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 41 | """Handle a config flow for Tech Sterowniki.""" 42 | 43 | VERSION = 1 44 | # Pick one of the available connection classes in homeassistant/config_entries.py 45 | CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL 46 | 47 | async def async_step_user(self, user_input=None): 48 | """Handle the initial step.""" 49 | errors = {} 50 | if user_input is not None: 51 | try: 52 | _LOGGER.debug("Context: " + str(self.context)) 53 | validated_input = await validate_input(self.hass, user_input) 54 | 55 | modules = self._create_modules_array(validated_input=validated_input) 56 | 57 | if len(modules) == 0: 58 | return self.async_abort("no_modules") 59 | 60 | if len(modules) > 1: 61 | for module in modules[1:len(modules)]: 62 | await self.hass.config_entries.async_add(self._create_config_entry(module=module)) 63 | 64 | return self.async_create_entry(title=modules[0]["version"], data=modules[0]) 65 | except CannotConnect: 66 | errors["base"] = "cannot_connect" 67 | except InvalidAuth: 68 | errors["base"] = "invalid_auth" 69 | except Exception: # pylint: disable=broad-except 70 | _LOGGER.exception("Unexpected exception") 71 | errors["base"] = "unknown" 72 | 73 | return self.async_show_form( 74 | step_id="user", data_schema=DATA_SCHEMA, errors=errors 75 | ) 76 | 77 | def _create_config_entry(self, module: dict) -> ConfigEntry: 78 | return ConfigEntry( 79 | data=module, 80 | title=module["version"], 81 | entry_id=uuid.uuid4().hex, 82 | domain=DOMAIN, 83 | version=ConfigFlow.VERSION, 84 | minor_version=ConfigFlow.MINOR_VERSION, 85 | source=ConfigFlow.CONNECTION_CLASS) 86 | 87 | def _create_modules_array(self, validated_input: dict) -> [dict]: 88 | return [ 89 | self._create_module_dict(validated_input, module_dict) 90 | for module_dict in validated_input["modules"] 91 | ] 92 | 93 | def _create_module_dict(self, validated_input: dict, module_dict: dict) -> dict: 94 | return { 95 | "user_id": validated_input["user_id"], 96 | "token": validated_input["token"], 97 | "module": module_dict, 98 | "version": module_dict["version"] + ": " + module_dict["name"] 99 | } 100 | 101 | 102 | class CannotConnect(exceptions.HomeAssistantError): 103 | """Error to indicate we cannot connect.""" 104 | 105 | 106 | class InvalidAuth(exceptions.HomeAssistantError): 107 | """Error to indicate there is invalid auth.""" 108 | -------------------------------------------------------------------------------- /custom_components/tech/const.py: -------------------------------------------------------------------------------- 1 | """Constants for the Tech Sterowniki integration.""" 2 | 3 | DOMAIN = "tech" 4 | -------------------------------------------------------------------------------- /custom_components/tech/images/ha-tech-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MichalKrasowski/tech-controllers/ca2a6a85f666d45cd764c8be517fecbdad61da76/custom_components/tech/images/ha-tech-1.png -------------------------------------------------------------------------------- /custom_components/tech/images/ha-tech-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MichalKrasowski/tech-controllers/ca2a6a85f666d45cd764c8be517fecbdad61da76/custom_components/tech/images/ha-tech-2.png -------------------------------------------------------------------------------- /custom_components/tech/images/ha-tech-add-integration-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MichalKrasowski/tech-controllers/ca2a6a85f666d45cd764c8be517fecbdad61da76/custom_components/tech/images/ha-tech-add-integration-1.png -------------------------------------------------------------------------------- /custom_components/tech/images/ha-tech-add-integration-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MichalKrasowski/tech-controllers/ca2a6a85f666d45cd764c8be517fecbdad61da76/custom_components/tech/images/ha-tech-add-integration-2.png -------------------------------------------------------------------------------- /custom_components/tech/images/ha-tech-add-integration-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MichalKrasowski/tech-controllers/ca2a6a85f666d45cd764c8be517fecbdad61da76/custom_components/tech/images/ha-tech-add-integration-3.png -------------------------------------------------------------------------------- /custom_components/tech/images/ha-tech-add-integration.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MichalKrasowski/tech-controllers/ca2a6a85f666d45cd764c8be517fecbdad61da76/custom_components/tech/images/ha-tech-add-integration.png -------------------------------------------------------------------------------- /custom_components/tech/images/ha-tech-logs-ex.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MichalKrasowski/tech-controllers/ca2a6a85f666d45cd764c8be517fecbdad61da76/custom_components/tech/images/ha-tech-logs-ex.png -------------------------------------------------------------------------------- /custom_components/tech/images/ha-tech-logs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MichalKrasowski/tech-controllers/ca2a6a85f666d45cd764c8be517fecbdad61da76/custom_components/tech/images/ha-tech-logs.png -------------------------------------------------------------------------------- /custom_components/tech/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "tech", 3 | "name": "Tech Controllers", 4 | "integration_type": "hub", 5 | "config_flow": true, 6 | "documentation": "https://github.com/MichalKrasowski/tech-controllers", 7 | "issue_tracker": "https://github.com/MichalKrasowski/tech-controllers/issues", 8 | "requirements": [], 9 | "dependencies": [], 10 | "codeowners": [ 11 | "@MichalKrasowski" 12 | ], 13 | "version": "1.0.0" 14 | } 15 | -------------------------------------------------------------------------------- /custom_components/tech/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Tech Sterowniki", 3 | "config": { 4 | "step": { 5 | "user": { 6 | "title": "Tech Sterowniki Login", 7 | "description": "Please enter your credentials to emodul.eu or emodul.pl service.", 8 | "data": { 9 | "username": "[%key:common::config_flow::data::username%]", 10 | "password": "[%key:common::config_flow::data::password%]" 11 | } 12 | } 13 | }, 14 | "error": { 15 | "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", 16 | "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", 17 | "unknown": "[%key:common::config_flow::error::unknown%]" 18 | }, 19 | "abort": { 20 | "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" 21 | } 22 | } 23 | } -------------------------------------------------------------------------------- /custom_components/tech/tech.py: -------------------------------------------------------------------------------- 1 | """ 2 | Python wrapper for getting interaction with Tech devices. 3 | """ 4 | import logging 5 | import aiohttp 6 | import json 7 | import time 8 | import asyncio 9 | 10 | logging.basicConfig(level=logging.DEBUG) 11 | _LOGGER = logging.getLogger(__name__) 12 | 13 | class Tech: 14 | """Main class to perform Tech API requests""" 15 | 16 | TECH_API_URL = "https://emodul.eu/api/v1/" 17 | 18 | def __init__(self, session: aiohttp.ClientSession, user_id = None, token = None, base_url = TECH_API_URL, update_interval = 30): 19 | _LOGGER.debug("Init Tech") 20 | self.headers = { 21 | 'Accept': 'application/json', 22 | 'Accept-Encoding': 'gzip' 23 | } 24 | self.base_url = base_url 25 | self.update_interval = update_interval 26 | self.session = session 27 | if user_id and token: 28 | self.user_id = user_id 29 | self.token = token 30 | self.headers.setdefault("Authorization", "Bearer " + token) 31 | self.authenticated = True 32 | else: 33 | self.authenticated = False 34 | self.last_update = None 35 | self.update_lock = asyncio.Lock() 36 | self.zones = {} 37 | 38 | async def get(self, request_path): 39 | url = self.base_url + request_path 40 | _LOGGER.debug("Sending GET request: " + url) 41 | async with self.session.get(url, headers=self.headers) as response: 42 | if response.status != 200: 43 | _LOGGER.warning("Invalid response from Tech API: %s", response.status) 44 | raise TechError(response.status, await response.text()) 45 | 46 | data = await response.json() 47 | _LOGGER.debug(data) 48 | return data 49 | 50 | async def post(self, request_path, post_data): 51 | url = self.base_url + request_path 52 | _LOGGER.debug("Sending POST request: " + url) 53 | async with self.session.post(url, data=post_data, headers=self.headers) as response: 54 | if response.status != 200: 55 | _LOGGER.warning("Invalid response from Tech API: %s", response.status) 56 | raise TechError(response.status, await response.text()) 57 | 58 | data = await response.json() 59 | _LOGGER.debug(data) 60 | return data 61 | 62 | async def authenticate(self, username, password): 63 | path = "authentication" 64 | post_data = '{"username": "' + username + '", "password": "' + password + '"}' 65 | result = await self.post(path, post_data) 66 | self.authenticated = result["authenticated"] 67 | if self.authenticated: 68 | self.user_id = str(result["user_id"]) 69 | self.token = result["token"] 70 | self.headers = { 71 | 'Accept': 'application/json', 72 | 'Accept-Encoding': 'gzip', 73 | 'Authorization': 'Bearer ' + self.token 74 | } 75 | return result["authenticated"] 76 | 77 | async def list_modules(self): 78 | if self.authenticated: 79 | path = "users/" + self.user_id + "/modules" 80 | result = await self.get(path) 81 | else: 82 | raise TechError(401, "Unauthorized") 83 | return result 84 | 85 | async def get_module_data(self, module_udid): 86 | _LOGGER.debug("Getting module data..." + module_udid + ", " + self.user_id) 87 | if self.authenticated: 88 | path = "users/" + self.user_id + "/modules/" + module_udid 89 | result = await self.get(path) 90 | else: 91 | raise TechError(401, "Unauthorized") 92 | return result 93 | 94 | async def get_module_zones(self, module_udid): 95 | """Returns Tech module zones either from cache or it will 96 | update all the cached values for Tech module assuming 97 | no update has occurred for at least the [update_interval]. 98 | 99 | Parameters: 100 | inst (Tech): The instance of the Tech API. 101 | module_udid (string): The Tech module udid. 102 | 103 | Returns: 104 | Dictionary of zones indexed by zone ID. 105 | """ 106 | async with self.update_lock: 107 | now = time.time() 108 | _LOGGER.debug("Geting module zones: now: %s, last_update %s, interval: %s", now, self.last_update, self.update_interval) 109 | if self.last_update is None or now > self.last_update + self.update_interval: 110 | _LOGGER.debug("Updating module zones cache..." + module_udid) 111 | result = await self.get_module_data(module_udid) 112 | zones = result["zones"]["elements"] 113 | zones = list(filter(lambda e: e['zone']['zoneState'] != "zoneUnregistered", zones)) 114 | for zone in zones: 115 | self.zones[zone["zone"]["id"]] = zone 116 | self.last_update = now 117 | return self.zones 118 | 119 | async def get_zone(self, module_udid, zone_id): 120 | """Returns zone from Tech API cache. 121 | 122 | Parameters: 123 | module_udid (string): The Tech module udid. 124 | zone_id (int): The Tech module zone ID. 125 | 126 | Returns: 127 | Dictionary of zone. 128 | """ 129 | await self.get_module_zones(module_udid) 130 | return self.zones[zone_id] 131 | 132 | async def set_const_temp(self, module_udid, zone_id, target_temp): 133 | """Sets constant temperature of the zone. 134 | 135 | Parameters: 136 | module_udid (string): The Tech module udid. 137 | zone_id (int): The Tech module zone ID. 138 | target_temp (float): The target temperature to be set within the zone. 139 | 140 | Returns: 141 | JSON object with the result. 142 | """ 143 | _LOGGER.debug("Setting zone constant temperature...") 144 | if self.authenticated: 145 | path = "users/" + self.user_id + "/modules/" + module_udid + "/zones" 146 | data = { 147 | "mode" : { 148 | "id" : self.zones[zone_id]["mode"]["id"], 149 | "parentId" : zone_id, 150 | "mode" : "constantTemp", 151 | "constTempTime" : 60, 152 | "setTemperature" : int(target_temp * 10), 153 | "scheduleIndex" : 0 154 | } 155 | } 156 | _LOGGER.debug(data) 157 | result = await self.post(path, json.dumps(data)) 158 | _LOGGER.debug(result) 159 | else: 160 | raise TechError(401, "Unauthorized") 161 | return result 162 | 163 | async def set_zone(self, module_udid, zone_id, on = True): 164 | """Turns the zone on or off. 165 | 166 | Parameters: 167 | module_udid (string): The Tech module udid. 168 | zone_id (int): The Tech module zone ID. 169 | on (bool): Flag indicating to turn the zone on if True or off if False. 170 | 171 | Returns: 172 | JSON object with the result. 173 | """ 174 | _LOGGER.debug("Turing zone on/off: %s", on) 175 | if self.authenticated: 176 | path = "users/" + self.user_id + "/modules/" + module_udid + "/zones" 177 | data = { 178 | "zone" : { 179 | "id" : zone_id, 180 | "zoneState" : "zoneOn" if on else "zoneOff" 181 | } 182 | } 183 | _LOGGER.debug(data) 184 | result = await self.post(path, json.dumps(data)) 185 | _LOGGER.debug(result) 186 | else: 187 | raise TechError(401, "Unauthorized") 188 | return result 189 | 190 | class TechError(Exception): 191 | """Raised when Tech APi request ended in error. 192 | Attributes: 193 | status_code - error code returned by Tech API 194 | status - more detailed description 195 | """ 196 | def __init__(self, status_code, status): 197 | self.status_code = status_code 198 | self.status = status -------------------------------------------------------------------------------- /custom_components/tech/test_tech.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import asyncio 3 | import aiohttp 4 | import tech 5 | import json 6 | 7 | class TestTechMethods(unittest.TestCase): 8 | def setUp(self): 9 | self._loop = asyncio.get_event_loop() 10 | self._session = aiohttp.ClientSession(loop = self._loop) 11 | self._tech = tech.Tech(self._session) 12 | self._loop.run_until_complete(self._tech.authenticate("email", "password")) 13 | """ 14 | def test_authenticate(self): 15 | result = self._loop.run_until_complete(self._tech.authenticate("email", "password")) 16 | #authentication = json.loads(json.dumps(result)) 17 | self.assertTrue(result) 18 | """ 19 | def test_list_modules(self): 20 | result = self._loop.run_until_complete(self._tech.list_modules()) 21 | self.assertTrue(result[0]) 22 | 23 | def test_module_data(self): 24 | result = self._loop.run_until_complete(self._tech.get_module_data("module_id")) 25 | zones = json.loads(json.dumps(result)) 26 | self.assertTrue("zones" in zones) 27 | 28 | def tearDown(self): 29 | self._loop.run_until_complete(self._session.close()) 30 | 31 | if __name__ == '__main__': 32 | unittest.main() 33 | -------------------------------------------------------------------------------- /custom_components/tech/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "Device already configured", 5 | "no_modules": "No modules detected" 6 | }, 7 | "error": { 8 | "cannot_connect": "Cannot connect to Tech API.", 9 | "invalid_auth": "Invalid username or password!", 10 | "unknown": "Unknown error. Please try again or report bug to ..." 11 | }, 12 | "step": { 13 | "user": { 14 | "title": "Tech Controllers Login", 15 | "description": "Please enter your credentials to emodul.eu or emodul.pl service.", 16 | "data": { 17 | "password": "Password", 18 | "username": "Username" 19 | } 20 | } 21 | } 22 | }, 23 | "title": "Tech Controllers" 24 | } 25 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Tech Controllers", 3 | "country": "PL", 4 | "render_readme": true, 5 | "iot_class": "Cloud Polling", 6 | "domains": ["climate"] 7 | } 8 | -------------------------------------------------------------------------------- /repository.yaml: -------------------------------------------------------------------------------- 1 | name: Tech Controllers 2 | url: 'https://github.com/MichalKrasowski/tech-controllers' 3 | maintainer: Michal Krasowski 4 | --------------------------------------------------------------------------------