├── .gitattributes ├── custom_components ├── __init__.py └── eta_webservices │ ├── utils.py │ ├── manifest.json │ ├── services.yaml │ ├── diagnostics.py │ ├── const.py │ ├── services.py │ ├── button.py │ ├── binary_sensor.py │ ├── switch.py │ ├── time.py │ ├── __init__.py │ ├── entity.py │ ├── coordinator.py │ ├── translations │ ├── en.json │ └── de.json │ ├── number.py │ ├── sensor.py │ ├── api.py │ └── config_flow.py ├── requirements_dev.txt ├── .gitignore ├── images ├── sensors.png ├── controls.png ├── diagnostic.png └── template_sensor.png ├── hacs.json ├── requirements_test.txt ├── LICENSE └── README.md /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf -------------------------------------------------------------------------------- /custom_components/__init__.py: -------------------------------------------------------------------------------- 1 | """Custom components module.""" 2 | -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | homeassistant 2 | xmltodict 3 | aoihttp 4 | packaging 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | pythonenv* 3 | venv 4 | .venv 5 | .coverage 6 | .idea 7 | -------------------------------------------------------------------------------- /images/sensors.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tidone/homeassistant_eta_integration/HEAD/images/sensors.png -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ETA", 3 | "render_readme": true, 4 | "homeassistant": "2023.9.0" 5 | } 6 | -------------------------------------------------------------------------------- /images/controls.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tidone/homeassistant_eta_integration/HEAD/images/controls.png -------------------------------------------------------------------------------- /images/diagnostic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tidone/homeassistant_eta_integration/HEAD/images/diagnostic.png -------------------------------------------------------------------------------- /requirements_test.txt: -------------------------------------------------------------------------------- 1 | pytest-homeassistant-custom-component 2 | mock 3 | xmltodict 4 | pytest-asyncio 5 | packaging 6 | -------------------------------------------------------------------------------- /images/template_sensor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tidone/homeassistant_eta_integration/HEAD/images/template_sensor.png -------------------------------------------------------------------------------- /custom_components/eta_webservices/utils.py: -------------------------------------------------------------------------------- 1 | from homeassistant.helpers.device_registry import DeviceInfo 2 | 3 | from .const import DOMAIN 4 | 5 | 6 | def create_device_info(host: str, port: str): 7 | return DeviceInfo( 8 | identifiers={(DOMAIN, "eta_" + host.replace(".", "_") + "_" + str(port))}, 9 | name="ETA", 10 | manufacturer="ETA", 11 | configuration_url="https://www.meineta.at", 12 | ) 13 | -------------------------------------------------------------------------------- /custom_components/eta_webservices/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "eta_webservices", 3 | "name": "Eta Sensors", 4 | "codeowners": ["@nigl", "@Tidone"], 5 | "config_flow": true, 6 | "dependencies": [], 7 | "documentation": "https://github.com/Tidone/homeassistant_eta_integration", 8 | "iot_class": "local_polling", 9 | "issue_tracker": "https://github.com/Tidone/homeassistant_eta_integration/issues", 10 | "requirements": ["xmltodict", "packaging"], 11 | "version": "1.1.14" 12 | } 13 | -------------------------------------------------------------------------------- /custom_components/eta_webservices/services.yaml: -------------------------------------------------------------------------------- 1 | write_value: 2 | fields: 3 | endpoint_url: 4 | required: true 5 | selector: 6 | text: 7 | value: 8 | required: true 9 | selector: 10 | text: 11 | begin: 12 | required: false 13 | selector: 14 | number: 15 | min: 0 16 | max: 96 17 | mode: box 18 | end: 19 | required: false 20 | selector: 21 | number: 22 | min: 0 23 | max: 96 24 | mode: box 25 | write_value_scaled: 26 | target: 27 | entity: 28 | integration: eta_webservices 29 | domain: number 30 | fields: 31 | value: 32 | required: true 33 | selector: 34 | number: 35 | step: 0.1 36 | force_decimals: 37 | required: true 38 | selector: 39 | boolean: -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Joakim Sørensen @ludeeus 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/eta_webservices/diagnostics.py: -------------------------------------------------------------------------------- 1 | """Diagnostics support for ETA Sensors.""" 2 | from __future__ import annotations 3 | 4 | from typing import Any 5 | 6 | from homeassistant.components.diagnostics import async_redact_data 7 | from homeassistant.config_entries import ConfigEntry 8 | from homeassistant.const import CONF_HOST, CONF_PORT 9 | from homeassistant.core import HomeAssistant 10 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 11 | 12 | from .const import DOMAIN 13 | from .api import EtaAPI 14 | 15 | 16 | async def async_get_config_entry_diagnostics( 17 | hass: HomeAssistant, entry: ConfigEntry 18 | ) -> dict[str, Any]: 19 | """Return diagnostics for a config entry.""" 20 | config = hass.data[DOMAIN][entry.entry_id] 21 | 22 | host = config.get(CONF_HOST) 23 | port = config.get(CONF_PORT) 24 | session = async_get_clientsession(hass) 25 | 26 | eta_client = EtaAPI(session, host, port) 27 | user_menu = await eta_client.get_menu() 28 | api_version = await eta_client.get_api_version() 29 | 30 | return {"config": config, "api_version": str(api_version), "menu": user_menu} 31 | -------------------------------------------------------------------------------- /custom_components/eta_webservices/const.py: -------------------------------------------------------------------------------- 1 | NAME = "eta_webservices" 2 | DOMAIN = "eta_webservices" 3 | ISSUE_URL = "https://github.com/Tidone/homeassistant_eta_integration/issues" 4 | 5 | 6 | FLOAT_DICT = "FLOAT_DICT" 7 | SWITCHES_DICT = "SWITCHES_DICT" 8 | TEXT_DICT = "TEXT_DICT" 9 | WRITABLE_DICT = "WRITABLE_DICT" 10 | CHOSEN_FLOAT_SENSORS = "chosen_float_sensors" 11 | CHOSEN_SWITCHES = "chosen_switches" 12 | CHOSEN_TEXT_SENSORS = "chosen_text_sensors" 13 | CHOSEN_WRITABLE_SENSORS = "chosen_writable_sensors" 14 | 15 | FORCE_LEGACY_MODE = "force_legacy_mode" 16 | ENABLE_DEBUG_LOGGING = "enable_debug_logging" 17 | 18 | OPTIONS_UPDATE_SENSOR_VALUES = "update_sensor_values" 19 | OPTIONS_ENUMERATE_NEW_ENDPOINTS = "enumerate_new_endpoints" 20 | ADVANCED_OPTIONS_IGNORE_DECIMAL_PLACES_RESTRICTION = ( 21 | "ignore_decimal_places_restriction_for_writable_entities" 22 | ) 23 | 24 | ERROR_UPDATE_COORDINATOR = "error_update_coordinator" 25 | WRITABLE_UPDATE_COORDINATOR = "writable_update_coordinator" 26 | 27 | CUSTOM_UNIT_MINUTES_SINCE_MIDNIGHT = "minutes_since_midnight" 28 | INVISIBLE_UNITS = [CUSTOM_UNIT_MINUTES_SINCE_MIDNIGHT] 29 | 30 | # Defaults 31 | DEFAULT_NAME = DOMAIN 32 | REQUEST_TIMEOUT = 60 33 | 34 | STARTUP_MESSAGE = f""" 35 | ------------------------------------------------------------------- 36 | {NAME} 37 | This is a custom integration! 38 | If you have any issues with this you need to open an issue here: 39 | {ISSUE_URL} 40 | ------------------------------------------------------------------- 41 | """ 42 | -------------------------------------------------------------------------------- /custom_components/eta_webservices/services.py: -------------------------------------------------------------------------------- 1 | import voluptuous as vol 2 | from homeassistant.config_entries import ConfigEntry 3 | from homeassistant.const import CONF_HOST, CONF_PORT 4 | from homeassistant.core import HomeAssistant, ServiceCall 5 | from homeassistant.exceptions import HomeAssistantError 6 | from homeassistant.helpers import config_validation as cv 7 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 8 | 9 | from .const import ( 10 | DOMAIN, 11 | ) 12 | 13 | from .api import EtaAPI 14 | 15 | WRITE_ENDPOINT_SCHEMA = vol.Schema( 16 | { 17 | vol.Required("endpoint_url"): cv.string, 18 | vol.Required("value"): cv.string, 19 | vol.Optional("begin"): vol.All(vol.Coerce(int), vol.Range(min=0, max=96)), 20 | vol.Optional("end"): vol.All(vol.Coerce(int), vol.Range(min=0, max=96)), 21 | }, 22 | ) 23 | 24 | 25 | async def async_setup_services(hass: HomeAssistant, config_entry: ConfigEntry) -> None: 26 | session = async_get_clientsession(hass) 27 | config = hass.data[DOMAIN][config_entry.entry_id] 28 | 29 | async def handle_write(call: ServiceCall): 30 | """Handle the service call.""" 31 | url = call.data.get("endpoint_url") 32 | value = call.data.get("value") 33 | begin = call.data.get("begin", None) 34 | end = call.data.get("end", None) 35 | eta_client = EtaAPI(session, config.get(CONF_HOST), config.get(CONF_PORT)) 36 | success = await eta_client.write_endpoint(url, value, begin, end) 37 | if not success: 38 | raise HomeAssistantError("Could not write value, see log for details") 39 | 40 | hass.services.async_register( 41 | DOMAIN, "write_value", handle_write, schema=WRITE_ENDPOINT_SCHEMA 42 | ) 43 | -------------------------------------------------------------------------------- /custom_components/eta_webservices/button.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from homeassistant.components.button import ButtonEntity, ENTITY_ID_FORMAT 4 | from homeassistant.config_entries import ConfigEntry 5 | from homeassistant.const import CONF_HOST, CONF_PORT, EntityCategory 6 | from homeassistant.core import HomeAssistant 7 | from homeassistant.helpers.entity import generate_entity_id 8 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 9 | 10 | from .const import DOMAIN, ERROR_UPDATE_COORDINATOR 11 | from .coordinator import ETAErrorUpdateCoordinator 12 | from .utils import create_device_info 13 | 14 | 15 | async def async_setup_entry( 16 | hass: HomeAssistant, 17 | config_entry: ConfigEntry, 18 | async_add_entities: AddEntitiesCallback, 19 | ) -> None: 20 | config = hass.data[DOMAIN][config_entry.entry_id] 21 | error_coordinator = config[ERROR_UPDATE_COORDINATOR] 22 | 23 | buttons = [ 24 | EtaResendErrorEventsButton(config, hass, error_coordinator), 25 | ] 26 | 27 | async_add_entities(buttons) 28 | 29 | 30 | class EtaResendErrorEventsButton(ButtonEntity): 31 | _attr_has_entity_name = True 32 | _attr_should_poll = False 33 | 34 | def __init__( 35 | self, config: dict, hass: HomeAssistant, coordinator: ETAErrorUpdateCoordinator 36 | ) -> None: 37 | host = config.get(CONF_HOST) 38 | port = config.get(CONF_PORT) 39 | self.coordinator = coordinator 40 | 41 | self._attr_translation_key = "send_error_events_btn" 42 | self._attr_unique_id = ( 43 | "eta_" + host.replace(".", "_") + "_" + str(port) + "_send_events_btn" 44 | ) 45 | self.entity_id = generate_entity_id( 46 | ENTITY_ID_FORMAT, self._attr_unique_id, hass=hass 47 | ) 48 | self._attr_device_info = create_device_info(host, port) 49 | self._attr_entity_category = EntityCategory.DIAGNOSTIC 50 | 51 | async def async_press(self) -> None: 52 | """Force the error update coordinator to resend all error events""" 53 | # Delete the old error list to force the coordinator to resend all events 54 | self.coordinator.data = [] 55 | await self.coordinator.async_refresh() 56 | -------------------------------------------------------------------------------- /custom_components/eta_webservices/binary_sensor.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | 5 | _LOGGER = logging.getLogger(__name__) 6 | from .coordinator import ETAErrorUpdateCoordinator 7 | from .entity import EtaErrorEntity 8 | 9 | from homeassistant.components.binary_sensor import ( 10 | BinarySensorDeviceClass, 11 | BinarySensorEntity, 12 | ENTITY_ID_FORMAT, 13 | ) 14 | 15 | from homeassistant.core import HomeAssistant 16 | from homeassistant import config_entries 17 | from homeassistant.helpers.entity import generate_entity_id 18 | from homeassistant.const import CONF_HOST 19 | from .const import DOMAIN, ERROR_UPDATE_COORDINATOR 20 | 21 | 22 | async def async_setup_entry( 23 | hass: HomeAssistant, 24 | config_entry: config_entries.ConfigEntry, 25 | async_add_entities, 26 | ): 27 | """Setup error sensor""" 28 | config = hass.data[DOMAIN][config_entry.entry_id] 29 | 30 | error_coordinator = config[ERROR_UPDATE_COORDINATOR] 31 | 32 | sensors = [EtaErrorSensor(config, hass, error_coordinator)] 33 | async_add_entities(sensors, update_before_add=True) 34 | 35 | 36 | class EtaErrorSensor(BinarySensorEntity, EtaErrorEntity): 37 | """Representation of a Sensor.""" 38 | 39 | def __init__( 40 | self, config: dict, hass: HomeAssistant, coordinator: ETAErrorUpdateCoordinator 41 | ) -> None: 42 | """ 43 | Initialize sensor. 44 | 45 | To show all values: http://192.168.178.75:8080/user/errors 46 | 47 | """ 48 | _LOGGER.info("ETA Integration - init error sensor") 49 | 50 | super().__init__(coordinator, config, hass, ENTITY_ID_FORMAT, "_errors") 51 | 52 | self._attr_has_entity_name = True 53 | self._attr_translation_key = "state_sensor" 54 | 55 | self._attr_device_class = BinarySensorDeviceClass.PROBLEM 56 | 57 | host = config.get(CONF_HOST) 58 | 59 | # replace the unique id and entity id to keep the entity backwards compatible 60 | self._attr_unique_id = "eta_" + host.replace(".", "_") + "_errors" 61 | self.entity_id = generate_entity_id( 62 | ENTITY_ID_FORMAT, self._attr_unique_id, hass=hass 63 | ) 64 | 65 | self.handle_data_updates(self.coordinator.data) 66 | 67 | def handle_data_updates(self, data: list): 68 | self._attr_is_on = len(data) > 0 69 | -------------------------------------------------------------------------------- /custom_components/eta_webservices/switch.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from datetime import timedelta 5 | 6 | _LOGGER = logging.getLogger(__name__) 7 | from .api import EtaAPI, ETAEndpoint 8 | from .entity import EtaEntity 9 | 10 | from homeassistant.components.switch import ( 11 | SwitchEntity, 12 | ENTITY_ID_FORMAT, 13 | ) 14 | 15 | from homeassistant.core import HomeAssistant 16 | from homeassistant import config_entries 17 | from .const import DOMAIN, CHOSEN_SWITCHES, SWITCHES_DICT 18 | 19 | SCAN_INTERVAL = timedelta(minutes=1) 20 | 21 | 22 | async def async_setup_entry( 23 | hass: HomeAssistant, 24 | config_entry: config_entries.ConfigEntry, 25 | async_add_entities, 26 | ): 27 | """Setup switches from a config entry created in the integrations UI.""" 28 | config = hass.data[DOMAIN][config_entry.entry_id] 29 | 30 | chosen_entities = config[CHOSEN_SWITCHES] 31 | switches = [ 32 | EtaSwitch(config, hass, entity, config[SWITCHES_DICT][entity]) 33 | for entity in chosen_entities 34 | ] 35 | async_add_entities(switches, update_before_add=True) 36 | 37 | 38 | class EtaSwitch(EtaEntity, SwitchEntity): 39 | """Representation of a Switch.""" 40 | 41 | def __init__( 42 | self, 43 | config: dict, 44 | hass: HomeAssistant, 45 | unique_id: str, 46 | endpoint_info: ETAEndpoint, 47 | ) -> None: 48 | """ 49 | Initialize switch. 50 | 51 | To show all values: http://192.168.178.75:8080/user/menu 52 | """ 53 | _LOGGER.info("ETA Integration - init switch") 54 | 55 | super().__init__(config, hass, unique_id, endpoint_info, ENTITY_ID_FORMAT) 56 | 57 | self._attr_icon = "mdi:power" 58 | 59 | self.on_value = endpoint_info["valid_values"].get("on_value", 1803) 60 | self.off_value = endpoint_info["valid_values"].get("off_value", 1802) 61 | self._attr_is_on = False 62 | self._attr_should_poll = True 63 | 64 | async def async_update(self): 65 | """Fetch new state data for the switch. 66 | This is the only method that should fetch new data for Home Assistant. 67 | readme: activate first: https://www.meineta.at/javax.faces.resource/downloads/ETA-RESTful-v1.2.pdf.xhtml?ln=default&v=0 68 | """ 69 | eta_client = EtaAPI(self.session, self.host, self.port) 70 | value = await eta_client.get_switch_state(self.uri) 71 | if value == self.on_value: 72 | self._attr_is_on = True 73 | else: 74 | self._attr_is_on = False 75 | 76 | async def async_turn_on(self, **kwargs): 77 | eta_client = EtaAPI(self.session, self.host, self.port) 78 | res = await eta_client.set_switch_state(self.uri, self.on_value) 79 | if res: 80 | self._attr_is_on = True 81 | 82 | async def async_turn_off(self, **kwargs): 83 | eta_client = EtaAPI(self.session, self.host, self.port) 84 | res = await eta_client.set_switch_state(self.uri, self.off_value) 85 | if res: 86 | self._attr_is_on = False 87 | -------------------------------------------------------------------------------- /custom_components/eta_webservices/time.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from datetime import timedelta, time 5 | 6 | _LOGGER = logging.getLogger(__name__) 7 | from .api import EtaAPI, ETAEndpoint 8 | from .coordinator import ETAWritableUpdateCoordinator 9 | from .entity import EtaWritableSensorEntity 10 | 11 | from homeassistant.components.time import ( 12 | TimeEntity, 13 | ENTITY_ID_FORMAT, 14 | ) 15 | 16 | from homeassistant.core import HomeAssistant 17 | from homeassistant.exceptions import HomeAssistantError 18 | from homeassistant import config_entries 19 | from .const import ( 20 | DOMAIN, 21 | CHOSEN_WRITABLE_SENSORS, 22 | WRITABLE_DICT, 23 | CUSTOM_UNIT_MINUTES_SINCE_MIDNIGHT, 24 | WRITABLE_UPDATE_COORDINATOR, 25 | ) 26 | 27 | SCAN_INTERVAL = timedelta(minutes=1) 28 | 29 | 30 | async def async_setup_entry( 31 | hass: HomeAssistant, 32 | config_entry: config_entries.ConfigEntry, 33 | async_add_entities, 34 | ): 35 | """Setup time sensors from a config entry created in the integrations UI.""" 36 | config = hass.data[DOMAIN][config_entry.entry_id] 37 | 38 | coordinator = config[WRITABLE_UPDATE_COORDINATOR] 39 | 40 | chosen_entities = config[CHOSEN_WRITABLE_SENSORS] 41 | time_sensors = [ 42 | EtaTime(config, hass, entity, config[WRITABLE_DICT][entity], coordinator) 43 | for entity in chosen_entities 44 | if config[WRITABLE_DICT][entity]["unit"] == CUSTOM_UNIT_MINUTES_SINCE_MIDNIGHT 45 | ] 46 | async_add_entities(time_sensors, update_before_add=True) 47 | 48 | 49 | class EtaTime(TimeEntity, EtaWritableSensorEntity): 50 | """Representation of a Time Sensor.""" 51 | 52 | def __init__( 53 | self, 54 | config: dict, 55 | hass: HomeAssistant, 56 | unique_id: str, 57 | endpoint_info: ETAEndpoint, 58 | coordinator: ETAWritableUpdateCoordinator, 59 | ) -> None: 60 | """ 61 | Initialize sensor. 62 | 63 | To show all values: http://192.168.178.75:8080/user/menu 64 | """ 65 | _LOGGER.info("ETA Integration - init time sensor") 66 | 67 | super().__init__( 68 | coordinator, config, hass, unique_id, endpoint_info, ENTITY_ID_FORMAT 69 | ) 70 | 71 | # set an initial value to avoid errors. This will be overwritten by the coordinator immediately after initialization. 72 | self._attr_native_value = time(hour=19) 73 | self._attr_should_poll = True 74 | 75 | def handle_data_updates(self, data: float) -> None: 76 | total_minutes = int(data) 77 | hours = total_minutes // 60 78 | minutes = total_minutes % 60 79 | 80 | self._attr_native_value = time(hour=hours, minute=minutes) 81 | 82 | async def async_set_value(self, value: time): 83 | total_minutes = value.hour * 60 + value.minute 84 | if total_minutes >= 60 * 24: 85 | raise HomeAssistantError("Invalid time: Must be between 00:00 and 23:59") 86 | eta_client = EtaAPI(self.session, self.host, self.port) 87 | success = await eta_client.write_endpoint(self.uri, total_minutes) 88 | if not success: 89 | raise HomeAssistantError("Could not write value, see log for details") 90 | await self.coordinator.async_refresh() 91 | -------------------------------------------------------------------------------- /custom_components/eta_webservices/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from homeassistant import config_entries, core 3 | from homeassistant.const import Platform 4 | 5 | from .const import DOMAIN, ERROR_UPDATE_COORDINATOR, WRITABLE_UPDATE_COORDINATOR 6 | from .coordinator import ETAErrorUpdateCoordinator, ETAWritableUpdateCoordinator 7 | from .services import async_setup_services 8 | from .const import ( 9 | WRITABLE_DICT, 10 | CHOSEN_WRITABLE_SENSORS, 11 | FORCE_LEGACY_MODE, 12 | ) 13 | 14 | PLATFORMS: list[Platform] = [ 15 | Platform.BINARY_SENSOR, 16 | Platform.BUTTON, 17 | Platform.NUMBER, 18 | Platform.SENSOR, 19 | Platform.SWITCH, 20 | Platform.TIME, 21 | ] 22 | 23 | _LOGGER = logging.getLogger(__name__) 24 | 25 | 26 | async def async_setup_entry( 27 | hass: core.HomeAssistant, entry: config_entries.ConfigEntry 28 | ) -> bool: 29 | """Set up platform from a ConfigEntry.""" 30 | hass.data.setdefault(DOMAIN, {}) 31 | config = dict(entry.data) 32 | # Registers update listener to update config entry when options are updated. 33 | unsub_options_update_listener = entry.add_update_listener(options_update_listener) 34 | # Store a reference to the unsubscribe function to cleanup if an entry is unloaded. 35 | config["unsub_options_update_listener"] = unsub_options_update_listener 36 | 37 | if entry.options: 38 | config.update(entry.options) 39 | 40 | error_coordinator = ETAErrorUpdateCoordinator(hass, config) 41 | writable_coordinator = ETAWritableUpdateCoordinator(hass, config) 42 | config[ERROR_UPDATE_COORDINATOR] = error_coordinator 43 | config[WRITABLE_UPDATE_COORDINATOR] = writable_coordinator 44 | 45 | await error_coordinator.async_config_entry_first_refresh() 46 | await writable_coordinator.async_config_entry_first_refresh() 47 | 48 | hass.data[DOMAIN][entry.entry_id] = config 49 | 50 | # Forward the setup to the sensor platform. 51 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 52 | 53 | await async_setup_services(hass, entry) 54 | 55 | return True 56 | 57 | 58 | async def async_migrate_entry( 59 | hass: core.HomeAssistant, config_entry: config_entries.ConfigEntry 60 | ): 61 | _LOGGER.debug("Migrating from version %s", config_entry.version) 62 | if config_entry.version == 1: 63 | new_data = config_entry.data.copy() 64 | 65 | new_data[WRITABLE_DICT] = [] 66 | new_data[CHOSEN_WRITABLE_SENSORS] = [] 67 | new_data[FORCE_LEGACY_MODE] = False 68 | 69 | hass.config_entries.async_update_entry(config_entry, data=new_data, version=5) 70 | elif config_entry.version == 2: 71 | new_data = config_entry.data.copy() 72 | 73 | new_data[FORCE_LEGACY_MODE] = False 74 | 75 | hass.config_entries.async_update_entry(config_entry, data=new_data, version=5) 76 | elif config_entry.version in (3, 4): 77 | new_data = config_entry.data.copy() 78 | hass.config_entries.async_update_entry(config_entry, data=new_data, version=5) 79 | 80 | _LOGGER.info("Migration to version %s successful", config_entry.version) 81 | return True 82 | 83 | 84 | async def options_update_listener( 85 | hass: core.HomeAssistant, config_entry: config_entries.ConfigEntry 86 | ): 87 | """Handle options update.""" 88 | await hass.config_entries.async_reload(config_entry.entry_id) 89 | 90 | 91 | async def async_unload_entry( 92 | hass: core.HomeAssistant, entry: config_entries.ConfigEntry 93 | ) -> bool: 94 | """Unload a config entry.""" 95 | unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) 96 | 97 | if unload_ok: 98 | # Remove options_update_listener. 99 | hass.data[DOMAIN][entry.entry_id]["unsub_options_update_listener"]() 100 | 101 | # Remove config entry from domain. 102 | hass.data[DOMAIN].pop(entry.entry_id) 103 | 104 | return unload_ok 105 | -------------------------------------------------------------------------------- /custom_components/eta_webservices/entity.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod 2 | from typing import Any, Generic, TypeVar, cast 3 | from homeassistant.components.sensor import SensorEntity 4 | from homeassistant.const import CONF_HOST, CONF_PORT 5 | from homeassistant.core import HomeAssistant, callback 6 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 7 | from homeassistant.helpers.entity import Entity, generate_entity_id 8 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 9 | 10 | from .api import ETAEndpoint, EtaAPI 11 | from .utils import create_device_info 12 | from .coordinator import ETAErrorUpdateCoordinator, ETAWritableUpdateCoordinator 13 | 14 | _EntityT = TypeVar("_EntityT") 15 | 16 | 17 | class EtaEntity(Entity): 18 | def __init__( 19 | self, 20 | config: dict, 21 | hass: HomeAssistant, 22 | unique_id: str, 23 | endpoint_info: ETAEndpoint, 24 | entity_id_format: str, 25 | ) -> None: 26 | self._attr_name = endpoint_info["friendly_name"] 27 | self.session = async_get_clientsession(hass) 28 | self.host = config.get(CONF_HOST) 29 | self.port = config.get(CONF_PORT) 30 | self.uri = endpoint_info["url"] 31 | 32 | self._attr_device_info = create_device_info(self.host, self.port) 33 | self.entity_id = generate_entity_id(entity_id_format, unique_id, hass=hass) 34 | self._attr_unique_id = unique_id 35 | 36 | 37 | class EtaSensorEntity(SensorEntity, EtaEntity, Generic[_EntityT]): 38 | async def async_update(self): 39 | """Fetch new state data for the sensor. 40 | This is the only method that should fetch new data for Home Assistant. 41 | readme: activate first: https://www.meineta.at/javax.faces.resource/downloads/ETA-RESTful-v1.2.pdf.xhtml?ln=default&v=0 42 | """ 43 | eta_client = EtaAPI(self.session, self.host, self.port) 44 | value, _ = await eta_client.get_data(self.uri) 45 | self._attr_native_value = cast(_EntityT, value) 46 | 47 | 48 | class EtaWritableSensorEntity( 49 | EtaEntity, CoordinatorEntity[ETAWritableUpdateCoordinator] 50 | ): 51 | def __init__( 52 | self, 53 | coordinator: ETAWritableUpdateCoordinator, 54 | config: dict, 55 | hass: HomeAssistant, 56 | unique_id: str, 57 | endpoint_info: ETAEndpoint, 58 | entity_id_format: str, 59 | ) -> None: 60 | EtaEntity.__init__( 61 | self, config, hass, unique_id, endpoint_info, entity_id_format 62 | ) 63 | CoordinatorEntity.__init__(self, coordinator) 64 | 65 | self.handle_data_updates(float(coordinator.data[self.unique_id])) 66 | 67 | @abstractmethod 68 | def handle_data_updates(self, data: float) -> None: 69 | raise NotImplementedError 70 | 71 | @callback 72 | def _handle_coordinator_update(self) -> None: 73 | """Update attributes when the coordinator updates.""" 74 | data = self.coordinator.data[self.unique_id] 75 | self.handle_data_updates(float(data)) 76 | super()._handle_coordinator_update() 77 | 78 | 79 | class EtaErrorEntity(CoordinatorEntity[ETAErrorUpdateCoordinator]): 80 | def __init__( 81 | self, 82 | coordinator: ETAErrorUpdateCoordinator, 83 | config: dict, 84 | hass: HomeAssistant, 85 | entity_id_format: str, 86 | unique_id_suffix: str, 87 | ) -> None: 88 | super().__init__(coordinator) 89 | 90 | host = config.get(CONF_HOST) 91 | port = config.get(CONF_PORT) 92 | 93 | self._attr_unique_id = ( 94 | "eta_" + host.replace(".", "_") + "_" + str(port) + unique_id_suffix 95 | ) 96 | 97 | self.entity_id = generate_entity_id( 98 | entity_id_format, self._attr_unique_id, hass=hass 99 | ) 100 | 101 | self._attr_device_info = create_device_info(host, port) 102 | 103 | @abstractmethod 104 | def handle_data_updates(self, data) -> None: 105 | raise NotImplementedError 106 | 107 | @callback 108 | def _handle_coordinator_update(self) -> None: 109 | """Update attributes when the coordinator updates.""" 110 | self.handle_data_updates(self.coordinator.data) 111 | super()._handle_coordinator_update() 112 | -------------------------------------------------------------------------------- /custom_components/eta_webservices/coordinator.py: -------------------------------------------------------------------------------- 1 | """The Airzone integration.""" 2 | 3 | from __future__ import annotations 4 | 5 | from asyncio import timeout 6 | from datetime import timedelta 7 | import logging 8 | 9 | from homeassistant.const import CONF_HOST, CONF_PORT 10 | from homeassistant.core import HomeAssistant 11 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator 12 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 13 | 14 | from .const import ( 15 | DOMAIN, 16 | WRITABLE_DICT, 17 | CHOSEN_WRITABLE_SENSORS, 18 | CUSTOM_UNIT_MINUTES_SINCE_MIDNIGHT, 19 | ) 20 | from .api import EtaAPI, ETAError, ETAEndpoint 21 | 22 | DATA_SCAN_INTERVAL = timedelta(minutes=1) 23 | # the error endpoint doesn't have to be updated as often because we don't expect any updates most of the time 24 | ERROR_SCAN_INTERVAL = timedelta(minutes=2) 25 | 26 | _LOGGER = logging.getLogger(__name__) 27 | 28 | 29 | class ETAErrorUpdateCoordinator(DataUpdateCoordinator[list[ETAError]]): 30 | """Class to manage fetching error data from the ETA terminal.""" 31 | 32 | def __init__(self, hass: HomeAssistant, config: dict) -> None: 33 | """Initialize.""" 34 | 35 | self.host = config.get(CONF_HOST) 36 | self.port = config.get(CONF_PORT) 37 | self.session = async_get_clientsession(hass) 38 | 39 | super().__init__( 40 | hass, 41 | _LOGGER, 42 | name=DOMAIN, 43 | update_interval=ERROR_SCAN_INTERVAL, 44 | ) 45 | 46 | def _handle_error_events(self, new_errors): 47 | old_errors = self.data 48 | if old_errors is None: 49 | old_errors = [] 50 | 51 | for error in old_errors: 52 | if error not in new_errors: 53 | self.hass.bus.async_fire( 54 | "eta_webservices_error_cleared", event_data=error 55 | ) 56 | 57 | for error in new_errors: 58 | if error not in old_errors: 59 | self.hass.bus.async_fire( 60 | "eta_webservices_error_detected", event_data=error 61 | ) 62 | 63 | async def _async_update_data(self) -> list[ETAError]: 64 | """Update data via library.""" 65 | errors = [] 66 | eta_client = EtaAPI(self.session, self.host, self.port) 67 | 68 | async with timeout(10): 69 | errors = await eta_client.get_errors() 70 | self._handle_error_events(errors) 71 | return errors 72 | 73 | 74 | class ETAWritableUpdateCoordinator(DataUpdateCoordinator[dict]): 75 | """Class to manage fetching data from the ETA terminal.""" 76 | 77 | def __init__(self, hass: HomeAssistant, config: dict) -> None: 78 | """Initialize.""" 79 | 80 | self.host = config.get(CONF_HOST) 81 | self.port = config.get(CONF_PORT) 82 | self.session = async_get_clientsession(hass) 83 | self.chosen_writable_sensors: list[str] = config[CHOSEN_WRITABLE_SENSORS] 84 | self.all_writable_sensors: dict[str, ETAEndpoint] = config[WRITABLE_DICT] 85 | 86 | super().__init__( 87 | hass, 88 | _LOGGER, 89 | name=DOMAIN, 90 | update_interval=DATA_SCAN_INTERVAL, 91 | ) 92 | 93 | def _should_force_number_handling(self, unit): 94 | return unit == CUSTOM_UNIT_MINUTES_SINCE_MIDNIGHT 95 | 96 | async def _async_update_data(self) -> dict: 97 | """Update data via library.""" 98 | data = {} 99 | eta_client = EtaAPI(self.session, self.host, self.port) 100 | 101 | for sensor in self.chosen_writable_sensors: 102 | async with timeout(10): 103 | value, _ = await eta_client.get_data( 104 | self.all_writable_sensors[sensor]["url"], 105 | # force the api to return the number value instead of the text value, even if the eta endpoint returns an invalid unit 106 | # This is the case for e.g. time endpoints, which have an empty unit, but we still need the number value (minutes since midnight), instead of the text value ("19:00") 107 | self._should_force_number_handling( 108 | self.all_writable_sensors[sensor]["unit"] 109 | ), 110 | ) 111 | data[sensor] = value 112 | data[sensor.removesuffix("_writable")] = value 113 | 114 | return data 115 | -------------------------------------------------------------------------------- /custom_components/eta_webservices/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "title": "ETA Step 1", 6 | "description": "Enter the host and port of your ETA terminal. If you need help with the configuration have a look here: https://github.com/Tidone/homeassistant_eta_integration \n\n Note: Depending on the total number of endpoints of your ETA terminal, getting all possible sensors can take a very long time! Have patience when you click on Submit.\n\nSelect the old API mode if you have problems with the new ETA API.", 7 | "data": { 8 | "host": "Host", 9 | "port": "Port", 10 | "force_legacy_mode": "Force old API mode", 11 | "enable_debug_logging": "Enable verbose logging" 12 | } 13 | }, 14 | "select_entities": { 15 | "title": "ETA Step 2", 16 | "description": "Select endpoints which should be added as entities", 17 | "data": { 18 | "chosen_float_sensors": "Possible sensors", 19 | "chosen_switches": "Possible switches", 20 | "chosen_text_sensors": "Possible state sensors", 21 | "chosen_writable_sensors": "Possible writable sensors" 22 | } 23 | } 24 | }, 25 | "error": { 26 | "unknown_host": "Could not connect to the ETA terminal: Wrong host or port", 27 | "no_eta_endpoint": "Could not find a valid ETA endpoint. Did you enable the webservices in meinETA?", 28 | "wrong_api_version": "API version of ETA terminal is too low. Some entities may not be detected. Please consider updating the firmware of your ETA terminal to the latest version.", 29 | "legacy_mode_selected": "Using old API mode. Some entities may not be detected, or may be detected in the wrong category.", 30 | "value_update_error": "At least one endpoint is reporting an error. The respective entities won't be shown in the list." 31 | }, 32 | "abort": { 33 | "single_instance_allowed": "Host already configured. Only a single instance is allowed." 34 | } 35 | }, 36 | "options": { 37 | "step": { 38 | "init": { 39 | "title": "Initialize options", 40 | "description": "Select if the sensors should be updated before going to the next step", 41 | "data": { 42 | "update_sensor_values": "Update sensor values", 43 | "enumerate_new_endpoints": "Update list of sensors" 44 | } 45 | }, 46 | "user": { 47 | "data": { 48 | "chosen_float_sensors": "Possible sensors", 49 | "chosen_switches": "Possible switches", 50 | "chosen_text_sensors": "Possible state sensors", 51 | "chosen_writable_sensors": "Possible writable sensors", 52 | "unavailable_sensors": "Unavailable sensors" 53 | } 54 | }, 55 | "advanced_options": { 56 | "title": "Advanced options", 57 | "description": "Select advanced options for the integration. You can find more information about these options here: https://github.com/Tidone/homeassistant_eta_integration/wiki/Advanded-Options", 58 | "data": { 59 | "ignore_decimal_places_restriction_for_writable_entities": "HACK: Handle decimal places for these writable sensor values" 60 | } 61 | } 62 | }, 63 | "error": { 64 | "wrong_api_version": "API version of ETA terminal is too low. Some entities may not be detected. Please consider updating the firmware of your ETA terminal to the latest version.", 65 | "value_update_error": "At least one endpoint could not be updated. You may have to update the list of available sensors.", 66 | "unavailable_sensors": "One or more selected sensors are not available any more. You have to manually add their replacements, if available. Please check the list at the bottom." 67 | } 68 | }, 69 | "entity": { 70 | "button": { 71 | "send_error_events_btn": { 72 | "name": "Resend Error Events" 73 | } 74 | }, 75 | "binary_sensor": { 76 | "state_sensor": { 77 | "name": "State" 78 | } 79 | }, 80 | "sensor": { 81 | "nbr_active_errors_sensor": { 82 | "name": "Number of active errors" 83 | }, 84 | "latest_error_sensor": { 85 | "name": "Latest active error" 86 | } 87 | } 88 | }, 89 | "services": { 90 | "write_value": { 91 | "name": "Set value", 92 | "description": "Sets the value of an endpoint (Attention: Exercise caution! A wrong value can render your ETA unit unusable.)", 93 | "fields": { 94 | "endpoint_url": { 95 | "name": "Endpoint URI", 96 | "description": "URI of the endpoint (only the numeric part, without host and port) (see http://[eta_host]:[eta_port]/user/menu)" 97 | }, 98 | "value": { 99 | "name": "Value", 100 | "description": "The value to be set" 101 | }, 102 | "begin": { 103 | "name": "Begin", 104 | "description": "Optional start time in 15 minute increments since midnight" 105 | }, 106 | "end": { 107 | "name": "End", 108 | "description": "Optional end time in 15 minute increments since midnight" 109 | } 110 | } 111 | }, 112 | "write_value_scaled": { 113 | "name": "Set scaled value", 114 | "description": "Sets the value of an endpoint, applying the scale factor defined in the ETA API", 115 | "fields": { 116 | "value": { 117 | "name": "Value", 118 | "description": "The value to be set (in human-readable format)" 119 | }, 120 | "force_decimals": { 121 | "name": "Force decimals", 122 | "description": "If enabled, decimal places will be preserved even if the ETA API does not support them for this endpoint (may lead to errors!)" 123 | } 124 | } 125 | } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /custom_components/eta_webservices/number.py: -------------------------------------------------------------------------------- 1 | """ 2 | Platform for ETA number integration in Home Assistant 3 | 4 | author Tidone 5 | 6 | """ 7 | 8 | from __future__ import annotations 9 | 10 | import logging 11 | import voluptuous as vol 12 | from datetime import timedelta 13 | 14 | from homeassistant.exceptions import HomeAssistantError 15 | 16 | _LOGGER = logging.getLogger(__name__) 17 | from .api import EtaAPI, ETAEndpoint, ETAValidWritableValues 18 | from .entity import EtaWritableSensorEntity 19 | 20 | from homeassistant.components.number import ( 21 | NumberDeviceClass, 22 | NumberEntity, 23 | NumberMode, 24 | ENTITY_ID_FORMAT, 25 | ) 26 | 27 | from homeassistant.core import HomeAssistant 28 | from homeassistant import config_entries 29 | from homeassistant.const import EntityCategory 30 | from homeassistant.helpers import config_validation as cv 31 | from homeassistant.helpers.entity_platform import async_get_current_platform 32 | from homeassistant.helpers.typing import VolDictType 33 | from .coordinator import ETAWritableUpdateCoordinator 34 | from .const import ( 35 | DOMAIN, 36 | CHOSEN_WRITABLE_SENSORS, 37 | WRITABLE_DICT, 38 | WRITABLE_UPDATE_COORDINATOR, 39 | INVISIBLE_UNITS, 40 | ADVANCED_OPTIONS_IGNORE_DECIMAL_PLACES_RESTRICTION, 41 | ) 42 | 43 | SCAN_INTERVAL = timedelta(minutes=1) 44 | 45 | WRITE_VALUE_SCALED_SCHEMA: VolDictType = { 46 | vol.Required("value"): vol.Number(), 47 | vol.Required("force_decimals"): cv.boolean, 48 | } 49 | 50 | 51 | async def async_setup_entry( 52 | hass: HomeAssistant, 53 | config_entry: config_entries.ConfigEntry, 54 | async_add_entities, 55 | ): 56 | """Setup sensors from a config entry created in the integrations UI.""" 57 | config = hass.data[DOMAIN][config_entry.entry_id] 58 | 59 | coordinator = config[WRITABLE_UPDATE_COORDINATOR] 60 | 61 | chosen_writable_sensors = config[CHOSEN_WRITABLE_SENSORS] 62 | sensors = [ 63 | EtaWritableNumberSensor( 64 | config, hass, entity, config[WRITABLE_DICT][entity], coordinator 65 | ) 66 | for entity in chosen_writable_sensors 67 | if config[WRITABLE_DICT][entity]["unit"] 68 | not in INVISIBLE_UNITS # exclude all endpoints with a custom unit (e.g. time endpoints) 69 | ] 70 | async_add_entities(sensors, update_before_add=True) 71 | 72 | platform = async_get_current_platform() 73 | platform.async_register_entity_service( 74 | "write_value_scaled", WRITE_VALUE_SCALED_SCHEMA, "async_set_native_value" 75 | ) 76 | 77 | 78 | class EtaWritableNumberSensor(NumberEntity, EtaWritableSensorEntity): 79 | """Representation of a Number Entity.""" 80 | 81 | def __init__( 82 | self, 83 | config: dict, 84 | hass: HomeAssistant, 85 | unique_id: str, 86 | endpoint_info: ETAEndpoint, 87 | coordinator: ETAWritableUpdateCoordinator, 88 | ) -> None: 89 | """ 90 | Initialize sensor. 91 | 92 | To show all values: http://192.168.178.75:8080/user/menu 93 | 94 | """ 95 | _LOGGER.info("ETA Integration - init writable number sensor") 96 | 97 | super().__init__( 98 | coordinator, config, hass, unique_id, endpoint_info, ENTITY_ID_FORMAT 99 | ) 100 | 101 | self.ignore_decimal_places_restriction = unique_id in config.get( 102 | ADVANCED_OPTIONS_IGNORE_DECIMAL_PLACES_RESTRICTION, [] 103 | ) 104 | self._attr_device_class = self.determine_device_class(endpoint_info["unit"]) 105 | self.valid_values: ETAValidWritableValues = endpoint_info["valid_values"] 106 | 107 | self._attr_native_unit_of_measurement = endpoint_info["unit"] 108 | if self._attr_native_unit_of_measurement == "": 109 | self._attr_native_unit_of_measurement = None 110 | 111 | self._attr_entity_category = EntityCategory.CONFIG 112 | 113 | self._attr_mode = NumberMode.BOX 114 | self._attr_native_min_value = self.valid_values["scaled_min_value"] 115 | self._attr_native_max_value = self.valid_values["scaled_max_value"] 116 | if self.ignore_decimal_places_restriction: 117 | # set the step size based on the scale factor, i.e. use as many decimal places as the scale factor allows 118 | self._attr_native_step = pow( 119 | 10, (len(str(self.valid_values["scale_factor"])) - 1) * -1 120 | ) 121 | else: 122 | # calculate the step size based on the number of decimal places 123 | self._attr_native_step = pow(10, self.valid_values["dec_places"] * -1) 124 | 125 | def handle_data_updates(self, data: float) -> None: 126 | self._attr_native_value = data 127 | 128 | async def async_set_native_value( 129 | self, value: float, force_decimals: bool = False 130 | ) -> None: 131 | """Update the current value.""" 132 | if self.ignore_decimal_places_restriction or force_decimals: 133 | _LOGGER.debug( 134 | "ETA Integration - HACK: Ignoring decimal places restriction for writable sensor %s", 135 | self._attr_name, 136 | ) 137 | # scale the value based on the scale factor and ignore the dec_places, i.e. set as many decimal places as the scale factor allows 138 | raw_value = round(value * self.valid_values["scale_factor"], 0) 139 | else: 140 | raw_value = round(value, self.valid_values["dec_places"]) 141 | raw_value *= self.valid_values["scale_factor"] 142 | raw_value = round(raw_value, 0) 143 | 144 | eta_client = EtaAPI(self.session, self.host, self.port) 145 | success = await eta_client.write_endpoint(self.uri, raw_value) 146 | if not success: 147 | raise HomeAssistantError("Could not write value, see log for details") 148 | await self.coordinator.async_refresh() 149 | 150 | @staticmethod 151 | def determine_device_class(unit): 152 | unit_dict_eta = { 153 | "°C": NumberDeviceClass.TEMPERATURE, 154 | "W": NumberDeviceClass.POWER, 155 | "A": NumberDeviceClass.CURRENT, 156 | "Hz": NumberDeviceClass.FREQUENCY, 157 | "Pa": NumberDeviceClass.PRESSURE, 158 | "V": NumberDeviceClass.VOLTAGE, 159 | "W/m²": NumberDeviceClass.IRRADIANCE, 160 | "bar": NumberDeviceClass.PRESSURE, 161 | "kW": NumberDeviceClass.POWER, 162 | "kWh": NumberDeviceClass.ENERGY, 163 | "kg": NumberDeviceClass.WEIGHT, 164 | "mV": NumberDeviceClass.VOLTAGE, 165 | "s": NumberDeviceClass.DURATION, 166 | } 167 | 168 | if unit in unit_dict_eta: 169 | return unit_dict_eta[unit] 170 | 171 | return None 172 | -------------------------------------------------------------------------------- /custom_components/eta_webservices/translations/de.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "title": "ETA Schritt 1", 6 | "description": "Gib die Netzwerkadresse deines ETA Geräts ein. Wenn du Hilfe bei der Konfiguration brauchst, schau hier nach: https://github.com/Tidone/homeassistant_eta_integration \n\n Info: Abhängig von der Anzahl der möglichen Endpunkte deinse ETA Geräts kann die Abfrage eine lange Zeit dauern! Bitte hab etwas Geduld nachdem du auf Absenden gedrückt hast.\n\nWähle den alten API Modus falls du Probleme mit dem neuen Modus hast.", 7 | "data": { 8 | "host": "Host", 9 | "port": "Port", 10 | "force_legacy_mode": "Erzwinge die alte API Version", 11 | "enable_debug_logging": "Aktiviere ausführliche Protokolle" 12 | } 13 | }, 14 | "select_entities": { 15 | "title": "ETA Schritt 2", 16 | "description": "Wähle die Sensoren aus, die hinzugefügt werden sollen.", 17 | "data": { 18 | "chosen_float_sensors": "Sensoren", 19 | "chosen_switches": "Schalter", 20 | "chosen_text_sensors": "Zustandssensoren", 21 | "chosen_writable_sensors": "Schreibbare Sensoren" 22 | } 23 | } 24 | }, 25 | "error": { 26 | "unknown_host": "Konnte keine Verbindung zum ETA Gerät aufbauen: Falscher Host oder Port", 27 | "no_eta_endpoint": "Konnte keinen ETA Endpunkt finden. Hast du die Webservices in meinETA aktiviert?", 28 | "wrong_api_version": "API Version des ETA Geräts ist zu alt. Einige Entitäten werden möglicherweise nicht oder falsch erkannt. Aktualisiere die Firmware deines ETA Geräts auf die aktuellste Firmware, falls möglich.", 29 | "legacy_mode_selected": "Alter API Modus wird verwendet. Einige Entitäten werden möglicherweise nicht oder falsch erkannt.", 30 | "value_update_error": "Mindestens ein Endpunkt meldet einen Fehler. Die entsprechenden Entitäten werden in der Liste nicht angezeigt." 31 | }, 32 | "abort": { 33 | "single_instance_allowed": "Host bereits konfiguriert. Nur eine Instanz ist erlaubt." 34 | } 35 | }, 36 | "options": { 37 | "step": { 38 | "init": { 39 | "title": "Optionen initialisieren", 40 | "description": "Wähle aus, ob die Sensoren aktualisiert werden sollen bevor der nächste Schritt angezeigt wird", 41 | "data": { 42 | "update_sensor_values": "Sensorwerte aktualisieren", 43 | "enumerate_new_endpoints": "Liste der Sensoren aktualisieren" 44 | } 45 | }, 46 | "user": { 47 | "data": { 48 | "chosen_float_sensors": "Sensoren", 49 | "chosen_switches": "Schalter", 50 | "chosen_text_sensors": "Zustandssensoren", 51 | "chosen_writable_sensors": "Schreibbare Sensoren", 52 | "unavailable_sensors": "Nicht verfügbare Sensoren" 53 | } 54 | }, 55 | "advanced_options": { 56 | "title": "Erweiterte Optionen", 57 | "description": "Wähle erweiterte Optionen für die Integration. Weitere Informationen zu diesen Optionen findest du hier: https://github.com/Tidone/homeassistant_eta_integration/wiki/Advanded-Options", 58 | "data": { 59 | "ignore_decimal_places_restriction_for_writable_entities": "HACK: Füge Dezimalstellen zu den Werten dieser schreibbaren Sensoren hinzu" 60 | } 61 | } 62 | }, 63 | "error": { 64 | "wrong_api_version": "API Version des ETA Geräts ist zu alt. Einige Entitäten werden möglicherweise nicht oder falsch erkannt. Aktualisiere die Firmware deines ETA Geräts auf die aktuellste Firmware, falls möglich.", 65 | "value_update_error": "Mindestens ein Endpunkt konnte nicht aktualisiert werden. Du musst die Liste der verfügbaren Sensoren eventuell aktualisieren..", 66 | "unavailable_sensors": "Mindestens ein ausgewählter Sensor ist nicht mehr verfügbar. Du musst den Ersatz für sie manuell hinzufügen, falls verfügbar. Bitte prüfe die Liste am Ende." 67 | } 68 | }, 69 | "entity": { 70 | "button": { 71 | "send_error_events_btn": { 72 | "name": "Fehler-Ereignisse neu senden" 73 | } 74 | }, 75 | "binary_sensor": { 76 | "state_sensor": { 77 | "name": "Zustand" 78 | } 79 | }, 80 | "sensor": { 81 | "nbr_active_errors_sensor": { 82 | "name": "Anzahl der aktiven Fehler" 83 | }, 84 | "latest_error_sensor": { 85 | "name": "Neuester aktiver Fehler" 86 | } 87 | } 88 | }, 89 | "services": { 90 | "write_value": { 91 | "name": "Wert setzen", 92 | "description": "Setzt den Wert eines Endpunkts (Achtung: Nur unter großer Vorsicht verwenden! Ein falscher Wert kann das ETA Gerät unbrauchbar machen.)", 93 | "fields": { 94 | "endpoint_url": { 95 | "name": "Endpunkt URI", 96 | "description": "URI des Endpunkts (nur die Zahlenfolge, ohne Host und Port) (siehe http://[eta_host]:[eta_port]/user/menu)" 97 | }, 98 | "value": { 99 | "name": "Wert", 100 | "description": "Zu setzender Wert" 101 | }, 102 | "begin": { 103 | "name": "Startzeit", 104 | "description": "Optionale Startzeit in 15 Minuten Schritten seit Mitternacht" 105 | }, 106 | "end": { 107 | "name": "Endzeit", 108 | "description": "Optionale Endzeit in 15 Minuten Schritten seit Mitternacht" 109 | } 110 | } 111 | }, 112 | "write_value_scaled": { 113 | "name": "Skalierten Wert setzen", 114 | "description": "Setzt den Wert eines Endpunkts unter Anwendung des in der ETA API definierten Skalierungsfaktors", 115 | "fields": { 116 | "value": { 117 | "name": "Wert", 118 | "description": "Zu setzender Wert (im menschenlesbaren Format)" 119 | }, 120 | "force_decimals": { 121 | "name": "Dezimalstellen erzwingen", 122 | "description": "Wenn aktiviert, werden Dezimalstellen beibehalten, auch wenn die ETA API sie für diesen Endpunkt nicht unterstützt (kann zu Fehlern führen!)" 123 | } 124 | } 125 | } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /custom_components/eta_webservices/sensor.py: -------------------------------------------------------------------------------- 1 | """ 2 | Platform for ETA sensor integration in Home Assistant 3 | 4 | Help Links: 5 | Entity Source: https://github.com/home-assistant/core/blob/dev/homeassistant/helpers/entity.py 6 | SensorEntity derives from Entity https://github.com/home-assistant/core/blob/dev/homeassistant/components/sensor/__init__.py 7 | 8 | 9 | author nigl, Tidone 10 | 11 | """ 12 | 13 | from __future__ import annotations 14 | 15 | import logging 16 | from datetime import timedelta 17 | 18 | _LOGGER = logging.getLogger(__name__) 19 | from .api import ETAEndpoint, ETAError 20 | from .coordinator import ETAErrorUpdateCoordinator, ETAWritableUpdateCoordinator 21 | from .entity import EtaSensorEntity, EtaErrorEntity, EtaWritableSensorEntity 22 | 23 | from homeassistant.components.sensor import ( 24 | SensorDeviceClass, 25 | SensorEntity, 26 | SensorStateClass, 27 | ENTITY_ID_FORMAT, 28 | ) 29 | 30 | from homeassistant.core import HomeAssistant 31 | from homeassistant import config_entries 32 | from homeassistant.const import EntityCategory 33 | from .const import ( 34 | DOMAIN, 35 | CHOSEN_FLOAT_SENSORS, 36 | CHOSEN_TEXT_SENSORS, 37 | CHOSEN_WRITABLE_SENSORS, 38 | FLOAT_DICT, 39 | TEXT_DICT, 40 | ERROR_UPDATE_COORDINATOR, 41 | WRITABLE_UPDATE_COORDINATOR, 42 | ) 43 | 44 | SCAN_INTERVAL = timedelta(minutes=1) 45 | 46 | 47 | async def async_setup_entry( 48 | hass: HomeAssistant, 49 | config_entry: config_entries.ConfigEntry, 50 | async_add_entities, 51 | ): 52 | """Setup sensors from a config entry created in the integrations UI.""" 53 | config = hass.data[DOMAIN][config_entry.entry_id] 54 | 55 | writable_coordinator = config[WRITABLE_UPDATE_COORDINATOR] 56 | 57 | chosen_float_sensors = config[CHOSEN_FLOAT_SENSORS] 58 | chosen_writable_sensors = config[CHOSEN_WRITABLE_SENSORS] 59 | # sensors don't use a coordinator if they are not also selected as writable endpoints, 60 | sensors = [ 61 | EtaFloatSensor( 62 | config, 63 | hass, 64 | entity, 65 | config[FLOAT_DICT][entity], 66 | ) 67 | for entity in chosen_float_sensors 68 | if entity + "_writable" not in chosen_writable_sensors 69 | ] 70 | # sensors use a coordinator if they are also selected as writable endpoints, 71 | # to be able to update the value immediately if the user writes a new value 72 | sensors.extend( 73 | [ 74 | EtaFloatWritableSensor( 75 | config, 76 | hass, 77 | entity, 78 | config[FLOAT_DICT][entity], 79 | writable_coordinator, 80 | ) 81 | for entity in chosen_float_sensors 82 | if entity + "_writable" in chosen_writable_sensors 83 | ] 84 | ) 85 | chosen_text_sensors = config[CHOSEN_TEXT_SENSORS] 86 | sensors.extend( 87 | [ 88 | EtaTextSensor( 89 | config, 90 | hass, 91 | entity, 92 | config[TEXT_DICT][entity], 93 | ) 94 | for entity in chosen_text_sensors 95 | ] 96 | ) 97 | error_coordinator = config[ERROR_UPDATE_COORDINATOR] 98 | sensors.extend( 99 | [ 100 | EtaNbrErrorsSensor(config, hass, error_coordinator), 101 | EtaLatestErrorSensor(config, hass, error_coordinator), 102 | ] 103 | ) 104 | async_add_entities(sensors, update_before_add=True) 105 | 106 | 107 | def _determine_device_class(unit): 108 | unit_dict_eta = { 109 | "°C": SensorDeviceClass.TEMPERATURE, 110 | "W": SensorDeviceClass.POWER, 111 | "A": SensorDeviceClass.CURRENT, 112 | "Hz": SensorDeviceClass.FREQUENCY, 113 | "Pa": SensorDeviceClass.PRESSURE, 114 | "V": SensorDeviceClass.VOLTAGE, 115 | "W/m²": SensorDeviceClass.IRRADIANCE, 116 | "bar": SensorDeviceClass.PRESSURE, 117 | "kW": SensorDeviceClass.POWER, 118 | "kWh": SensorDeviceClass.ENERGY, 119 | "kg": SensorDeviceClass.WEIGHT, 120 | "mV": SensorDeviceClass.VOLTAGE, 121 | "s": SensorDeviceClass.DURATION, 122 | "%rH": SensorDeviceClass.HUMIDITY, 123 | } 124 | 125 | if unit in unit_dict_eta: 126 | return unit_dict_eta[unit] 127 | 128 | return None 129 | 130 | 131 | def _get_native_unit(unit): 132 | if unit == "%rH": 133 | return "%" 134 | if unit == "": 135 | return None 136 | return unit 137 | 138 | 139 | class EtaFloatSensor(EtaSensorEntity[float]): 140 | """Representation of a Float Sensor.""" 141 | 142 | def __init__( 143 | self, 144 | config: dict, 145 | hass: HomeAssistant, 146 | unique_id: str, 147 | endpoint_info: ETAEndpoint, 148 | ) -> None: 149 | """ 150 | Initialize sensor. 151 | 152 | To show all values: http://192.168.178.75:8080/user/menu 153 | 154 | """ 155 | _LOGGER.info("ETA Integration - init float sensor") 156 | 157 | super().__init__(config, hass, unique_id, endpoint_info, ENTITY_ID_FORMAT) 158 | 159 | self._attr_device_class = _determine_device_class(endpoint_info["unit"]) 160 | 161 | self._attr_native_unit_of_measurement = _get_native_unit(endpoint_info["unit"]) 162 | 163 | if self._attr_device_class == SensorDeviceClass.ENERGY: 164 | self._attr_state_class = SensorStateClass.TOTAL_INCREASING 165 | else: 166 | self._attr_state_class = SensorStateClass.MEASUREMENT 167 | 168 | self._attr_native_value = float 169 | 170 | 171 | class EtaFloatWritableSensor(SensorEntity, EtaWritableSensorEntity): 172 | """Representation of a Float Sensor.""" 173 | 174 | def __init__( 175 | self, 176 | config: dict, 177 | hass: HomeAssistant, 178 | unique_id: str, 179 | endpoint_info: ETAEndpoint, 180 | coordinator: ETAWritableUpdateCoordinator, 181 | ) -> None: 182 | """ 183 | Initialize sensor. 184 | 185 | To show all values: http://192.168.178.75:8080/user/menu 186 | 187 | """ 188 | _LOGGER.info("ETA Integration - init float sensor with coordinator") 189 | 190 | super().__init__( 191 | coordinator, config, hass, unique_id, endpoint_info, ENTITY_ID_FORMAT 192 | ) 193 | 194 | self._attr_device_class = _determine_device_class(endpoint_info["unit"]) 195 | 196 | self._attr_native_unit_of_measurement = _get_native_unit(endpoint_info["unit"]) 197 | 198 | if self._attr_device_class == SensorDeviceClass.ENERGY: 199 | self._attr_state_class = SensorStateClass.TOTAL_INCREASING 200 | else: 201 | self._attr_state_class = SensorStateClass.MEASUREMENT 202 | 203 | def handle_data_updates(self, data: float) -> None: 204 | self._attr_native_value = data 205 | 206 | 207 | class EtaTextSensor(EtaSensorEntity[str]): 208 | """Representation of a Text Sensor.""" 209 | 210 | def __init__( 211 | self, 212 | config: dict, 213 | hass: HomeAssistant, 214 | unique_id: str, 215 | endpoint_info: ETAEndpoint, 216 | ) -> None: 217 | """ 218 | Initialize sensor. 219 | 220 | To show all values: http://192.168.178.75:8080/user/menu 221 | 222 | """ 223 | _LOGGER.info("ETA Integration - init text sensor") 224 | 225 | super().__init__(config, hass, unique_id, endpoint_info, ENTITY_ID_FORMAT) 226 | 227 | self._attr_native_value = "" 228 | 229 | 230 | class EtaNbrErrorsSensor(SensorEntity, EtaErrorEntity): 231 | """Representation of a sensor showing the number of active errors.""" 232 | 233 | def __init__( 234 | self, config: dict, hass: HomeAssistant, coordinator: ETAErrorUpdateCoordinator 235 | ) -> None: 236 | super().__init__( 237 | coordinator, config, hass, ENTITY_ID_FORMAT, "_nbr_active_errors" 238 | ) 239 | 240 | self._attr_entity_category = EntityCategory.DIAGNOSTIC 241 | self._attr_state_class = SensorStateClass.MEASUREMENT 242 | 243 | self._attr_native_value = float 244 | self._attr_native_unit_of_measurement = None 245 | 246 | self._attr_has_entity_name = True 247 | self._attr_translation_key = "nbr_active_errors_sensor" 248 | 249 | self.handle_data_updates(self.coordinator.data) 250 | 251 | def handle_data_updates(self, data: list): 252 | self._attr_native_value = len(data) 253 | 254 | 255 | class EtaLatestErrorSensor(SensorEntity, EtaErrorEntity): 256 | """Representation of a sensor showing the latest active error.""" 257 | 258 | def __init__( 259 | self, config: dict, hass: HomeAssistant, coordinator: ETAErrorUpdateCoordinator 260 | ) -> None: 261 | super().__init__(coordinator, config, hass, ENTITY_ID_FORMAT, "_latest_error") 262 | 263 | self._attr_entity_category = EntityCategory.DIAGNOSTIC 264 | 265 | self._attr_native_value = "" 266 | self._attr_native_unit_of_measurement = None 267 | 268 | self._attr_has_entity_name = True 269 | self._attr_translation_key = "latest_error_sensor" 270 | 271 | self.handle_data_updates(self.coordinator.data) 272 | 273 | def handle_data_updates(self, data: list[ETAError]): 274 | if len(data) == 0: 275 | self._attr_native_value = "-" 276 | return 277 | 278 | sorted_errors = sorted(data, key=lambda d: d["time"]) 279 | self._attr_native_value = sorted_errors[-1]["msg"] 280 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg)](https://github.com/custom-components/hacs) 2 | 3 | # ETA Integration for Home Assistant 4 | 5 | Integration of ETA (Heating) sensors and switches to Home Assistant 6 | 7 | This integration uses the [ETA REST API](https://www.meineta.at/javax.faces.resource/downloads/ETA-RESTful-v1.2.pdf.xhtml?ln=default&v=0) to get sensor values and set switch states from the ETA pellets heating unit. 8 | 9 | This is a fork of [nigl's repo](https://github.com/nigl/homeassistant_eta_integration) with the following changes: 10 | 11 | - Friendly sensor names 12 | - Shows the current values for all sensors during configuration 13 | - This makes it way easier to select the relevant sensors 14 | - Implemented Switches 15 | - Implemented Text Sensors (state of some endpoints, e.g. `Bereit` (`Ready`) or `Heizen` (`Heating`) for the boiler) 16 | - Implemented error sensors: 17 | - A binary sensor, which activates if the ETA terminal reports at least one error 18 | - A sensor, which shows the number of active errors 19 | - A sensor, which shows the latest active error message 20 | - Implemented error events ([details](#error-events)) 21 | - Implemented a custom service to set the value of an endpoint ([details](#set-value-service)) 22 | - Implemented writable sensors ([details](#writable-sensors)) 23 | - Implemented time sensors (only for API v1.2 or higher) 24 | 25 | ## Screenshots 26 | 27 | | Sensors | Controls | Diagnostic | 28 | | :----------------------------: | :------------------------------: | :----------------------------------: | 29 | | ![Sensors](images/sensors.png) | ![Controls](images/controls.png) | ![Diagnostic](images/diagnostic.png) | 30 | 31 | ## Installation 32 | 33 | This integration can be configured directly in Home Assistant via HACS: 34 | 35 | 1. Go to `HACS` -> `Integrations` -> Click on the three dots in the top right corner --> Click on `Userdefined repositories` 36 | 1. Insert `https://github.com/Tidone/homeassistant_eta_integration` into the field `Repository` 37 | 1. Choose `Integration` in the dropdown field `Category`. 38 | 1. Click on the `Add` button. 39 | 1. Then search for the new added `ETA` integration, click on it and the click on the button `Download` on the bottom right corner 40 | 1. Restart Home Assistant when it says to. 41 | 1. In Home Assistant, go to `Configuration` -> `Integrations` -> Click `+ Add Integration` 42 | Search for `Eta Sensors` and follow the instructions. 43 | - **Note**: After entering the host and port the integration will query information about every possible endpoint. This step can take a very long time, so please have some patience. 44 | - **Note**: This only affects the configuration step when adding the integration. After the integration has been configured, only the selected entities will be queried. 45 | - **Note**: The integration will also query the current sensor values of all endpoints when clicking on `Configure`. This will also take a bit of time, but not as much as when adding the integration for the first time. 46 | 47 | ## General Notes 48 | 49 | - You have to activate the webservices API on your pellet heater first: see the "official" [documentation](https://www.meineta.at/javax.faces.resource/downloads/ETA-RESTful-v1.2.pdf.xhtml?ln=default&v=0): 50 | 51 | - Log in to `meinETA` 52 | - Go to `Settings` in the middle of the page (not the bottom one!) 53 | - Click on `Activate Webservices` 54 | - Follow the instructions 55 | 56 | - For best results, your pellet heater has to support at least API version **1.2**. If you are on an older version the integration will fall back to a compatibility mode, which means that some sensors may not be correctly detected/identified. The ones that are correctly detected and identified should still work without problems.\ 57 | Writable sensors may not work correctly in this mode (they may set the wrong value), because version 1.1 lacks the necessary functions to query details about sensors.\ 58 | If you want to update the firmware of your pellet heater you can find the firmware files on `meinETA` (`Settings at the bottom` -> `Installation & Software`). 59 | 60 | - Your ETA pellets unit needs a static IP address! Either configure the IP adress directly on the ETA terminal, or set the DHCP server on your router to give the ETA unit a static lease. 61 | 62 | - If your pellets unit is behind a proxy (`nginx`, `Cloudflare`, etc.) you may have to increase the timeout of the proxy when adding or configuring the integration. 63 | - The integration can take a very long time (> 5 minutes) when enumerating the list of available sensors. A proxy server may interrupt the connection between the browser and the HA server because it thinks the HA server is down because it takes too long to send the requested data. 64 | - Check the manual of your proxy server for how to increase the timeouts. 65 | - For nginx you may have to set the options `proxy_connect_timeout`, `proxy_send_timeout`, `proxy_read_timeout` and `send_timeout` to a higher number (600 seconds). 66 | 67 | ## Updating the List of Sensors 68 | 69 | If the sensors on the ETA unit are changed, the integration can be updated to reflect that. This is useful for example if new sensors are added, which should be shown in HA. 70 | 71 | To do that follow these steps: 72 | 1. Go to `Settings` -> `Devices & services` -> `ETA Sensors` 73 | 1. Click on the gear symbol (`Configure`) 74 | 1. In the popup dialog you can choose between different options to update the list of sensors: 75 | - The first option, `Update sensor values`, will only update the current values of all sensors in the list. It will not update the list of sensors itself. This option is enabled by default to make it easier to find the correct sensor in the list. 76 | - The second option, `Update list of sensors`, will update the whole list of sensors. 77 | 1. New sensors will then be added to the list, where you can select them in the next step. 78 | 1. Deleted or renamed sensors will be handled differently depending on if the sensor has previously been added to HA: 79 | - If the sensor has not been added to HA, it will simply be removed from the list. If it has been renamed on the ETA terminal, it will show its new name instead. 80 | - If the sensor has previously been added to HA, its entity will remain in HA, but it will be orphaned. HA will show a warning that the integration does not provide this entity any more.\ 81 | **If the sensor has been renamed in the ETA terminal, its new name will show up in the list instead, but the integration will not link the new name to the old entity!** You have to find the new name in the list of available sensors and add it again. If you want to keep the history of the entitiy you have to manually rename the new entity to its old name. If you do this the integration will orphan this entity again the next time the list of sensors is updated in the options, because it can't keep track if the user renames the entities. 82 | 83 | ## Logs 84 | 85 | If you have problems setting up this integration you can enable verbose logs on the dialog where you enter your ETA credentials. 86 | This will log all communication responses, which may help locating the problem. 87 | After setting up the integration you can download the logs at `Settings` -> `System` -> `Logs` -> `Download Full Log`. 88 | Please note that these logs may be very large, and contain sensitive information from other integrations. If you want to post them somewhere you may have to manually edit the file and delete the lines from before you started setting up this integration. 89 | 90 | ## Error Events 91 | 92 | This integration publishes an event whenever a new error is reported by the ETA terminal, or when an active error is cleared. 93 | These events can then be handled in automations. 94 | 95 | ### Event Info 96 | 97 | If a new error is reported, an `eta_webservices_error_detected` event is published.\ 98 | If an error is cleared, an `eta_webservices_error_cleared` event is published. 99 | 100 | Every event has the following data: 101 | | Name | Info | Sample Data | 102 | |------------|----------------------------------------------------|---------------------------------------------------------------------------------------------| 103 | | `msg` | Short error message | Water pressure too low 0,00 bar | 104 | | `priority` | Error priority | Error | 105 | | `time` | Time of the error, as reported by the ETA terminal | 2011-06-29T12:48:12 | 106 | | `text` | Detailed error message | Top up heating water! If this warning occurs more than once a year, please contact plumber. | 107 | | `fub` | Functional Block of the error | Kessel | 108 | | `host` | Address of the ETA terminal connection | 0.0.0.0 | 109 | | `port` | Port of the ETA terminal connection | 8080 | 110 | 111 | ### Checking Event Info 112 | 113 | If you want to check the data of an active event, you can follow these steps. 114 | 115 | **Note**: This is only possible if the ETA terminal actually reports an ective error! 116 | 117 | 1. Open Home Assistant in two tabs 118 | 1. On the first tab go to `Settings` -> `Devices & Services` -> `Devices` on top -> `ETA` 119 | 1. On the second tab go to `Developer tools` -> `Events` on top -> Enter `eta_webservices_error_detected` in the field `Event to subscribe to` -> Click on `Start Listening` 120 | 1. On the first tab click on the `Resend Error Events` button 121 | 1. On the second tab you can now see the detailed event info 122 | 123 | ### Sending a Test Event 124 | 125 | If you want to send a test event to check if your automations work you can follow these steps: 126 | 127 | 1. Go to `Developer tools` -> `Events` on top 128 | 1. Enter `eta_webservices_error_detected` in the field `Event type` 129 | 1. Enter your test payload in the `Event data` field 130 | - ``` 131 | msg: Test 132 | priority: Error 133 | time: "2023-11-06T12:48:12" 134 | text: This is a test error. 135 | fub: Kessel 136 | host: 0.0.0.0 137 | port: 8080 138 | ``` 139 | 1. Click on `Fire Event` 140 | 1. Your automation should have been triggered 141 | 142 | ## Writable Sensors 143 | 144 | This implementation supports setting the value of sensors which have a unit of `°C`, `kg`, or `%`. 145 | 146 | These writable sensors are not immediately added to the dashboard in Home Assistant. You can find the sensors (after adding them via configuration) on the ETA device page under `Config`. 147 | 148 | ### Migration 149 | 150 | You can add writable sensors by clicking on `Configure` on the ETA Integeration page in Home Assistant. 151 | If you are clicking this button for the first time after updating this integration from a previous version without support for these sensor types, this step will take a while because the integration will have to query the list of valid sensors from the ETA unit again. 152 | 153 | ### Caveats on APi v1.1 154 | 155 | API v1.1 does not have some endpoints, which are used to query the valid values of writable sensors. 156 | If your terminal is on this API version, the integration will fall back to a compatibility mode and guess the valid value ranges for these sensors. 157 | 158 | Also, on API v1.1 it is not possible to query if a sensor is writable at all! This integration therefore shows all sensors in the list of writable sensors, and the user has to choose the ones which are actually writable. 159 | 160 | ### Legal Notes 161 | 162 | The authors cannot be made responsible if the user renders their ETA heating unit unusable because they set a sensor to an invalid value. 163 | 164 | ## Custom Services 165 | 166 | THis integration provides some custom services. More information can be found on the [wiki](https://github.com/Tidone/homeassistant_eta_integration/wiki/Custom-Services). 167 | 168 | ## Integrating the ETA Unit into the Energy Dashboard 169 | 170 | You can add the ETA Heating Unit into the Energy Dashboard by converting the total pellets consumption into kWh, and adding that as a gas heater. 171 | 172 | To convert the consumption you have to add a custom template sensor to your `configuration.yaml` file: 173 | 174 | ``` 175 | # Convert pellet consumption (kg) to energy consumption (kWh) 176 | template: 177 | - sensor: 178 | - name: eta_total_energy 179 | unit_of_measurement: kWh 180 | device_class: energy 181 | state_class: total_increasing 182 | state: > 183 | {% if states('sensor.eta__kessel_zahlerstande_gesamtverbrauch') | float(default=none) is not none %} 184 | {{ states('sensor.eta__kessel_zahlerstande_gesamtverbrauch') | float(default=0.0) | multiply(4.8) | round(1) }} 185 | {% else %} 186 | {{ states('sensor.eta__kessel_zahlerstande_gesamtverbrauch') }} 187 | {% endif %} 188 | ``` 189 | 190 | Make sure to replace the `<IP> field with the IP address of your ETA unit. You can also check the sensor id by going to the options of the sensor, and searching for it in the entities. 191 | 192 | You can also use the web interface to create a template helper: 193 | ![template helper](images/template_sensor.png) 194 | 195 | You can then add your ETA heating unit to your Energy Dashboard by adding this new sensor to the list of gas sources. 196 | 197 | ## Future Development 198 | 199 | If you have some ideas about expansions to this implementation, please open an issue and I may look into it. 200 | -------------------------------------------------------------------------------- /custom_components/eta_webservices/api.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | import logging 3 | from typing import TypedDict 4 | 5 | from aiohttp import ClientSession 6 | from packaging import version 7 | import xmltodict 8 | 9 | from .const import CUSTOM_UNIT_MINUTES_SINCE_MIDNIGHT 10 | # Make sure to update _get_all_sensors_v12() if a new custom unit is added 11 | 12 | _LOGGER = logging.getLogger(__name__) 13 | 14 | 15 | class ETAValidSwitchValues(TypedDict): 16 | on_value: int 17 | off_value: int 18 | 19 | 20 | class ETAValidWritableValues(TypedDict): 21 | scaled_min_value: float 22 | scaled_max_value: float 23 | scale_factor: int 24 | dec_places: int 25 | 26 | 27 | class ETAEndpoint(TypedDict): 28 | url: str 29 | value: float | str 30 | valid_values: dict | ETAValidSwitchValues | ETAValidWritableValues | None 31 | friendly_name: str 32 | unit: str 33 | endpoint_type: str 34 | 35 | 36 | class ETAError(TypedDict): 37 | msg: str 38 | priority: str 39 | time: datetime 40 | text: str 41 | fub: str 42 | host: str 43 | port: int 44 | 45 | 46 | class EtaAPI: 47 | def __init__(self, session, host, port) -> None: 48 | self._session: ClientSession = session 49 | self._host = host 50 | self._port = int(port) 51 | 52 | self._float_sensor_units = [ 53 | "%", 54 | "A", 55 | "Hz", 56 | "Ohm", 57 | "Pa", 58 | "U/min", 59 | "V", 60 | "W", 61 | "W/m²", 62 | "bar", 63 | "kW", 64 | "kWh", 65 | "kg", 66 | "l", 67 | "l/min", 68 | "mV", 69 | "m²", 70 | "s", 71 | "°C", 72 | "%rH", 73 | CUSTOM_UNIT_MINUTES_SINCE_MIDNIGHT, 74 | ] 75 | 76 | self._writable_sensor_units = [ 77 | "%", 78 | "°C", 79 | "kg", 80 | CUSTOM_UNIT_MINUTES_SINCE_MIDNIGHT, 81 | ] 82 | self._default_valid_writable_values = { 83 | "%": ETAValidWritableValues( 84 | scaled_min_value=-100, 85 | scaled_max_value=100, 86 | scale_factor=1, 87 | dec_places=0, 88 | ), 89 | "°C": ETAValidWritableValues( 90 | scaled_min_value=-100, 91 | scaled_max_value=200, 92 | scale_factor=1, 93 | dec_places=0, 94 | ), 95 | "kg": ETAValidWritableValues( 96 | scaled_min_value=-100000, 97 | scaled_max_value=100000, 98 | scale_factor=1, 99 | dec_places=0, 100 | ), 101 | } 102 | 103 | def build_uri(self, suffix): 104 | return "http://" + self._host + ":" + str(self._port) + suffix 105 | 106 | def _evaluate_xml_dict(self, xml_dict, uri_dict, prefix=""): 107 | if type(xml_dict) == list: 108 | for child in xml_dict: 109 | self._evaluate_xml_dict(child, uri_dict, prefix) 110 | else: 111 | if "object" in xml_dict: 112 | child = xml_dict["object"] 113 | new_prefix = f"{prefix}_{xml_dict['@name']}" 114 | # add parent to uri_dict and evaluate childs then 115 | uri_dict[f"{prefix}_{xml_dict['@name']}"] = xml_dict["@uri"] 116 | self._evaluate_xml_dict(child, uri_dict, new_prefix) 117 | else: 118 | uri_dict[f"{prefix}_{xml_dict['@name']}"] = xml_dict["@uri"] 119 | 120 | async def _get_request(self, suffix): 121 | data = await self._session.get(self.build_uri(suffix)) 122 | return data 123 | 124 | async def post_request(self, suffix, data): 125 | data = await self._session.post(self.build_uri(suffix), data=data) 126 | return data 127 | 128 | async def does_endpoint_exists(self): 129 | resp = await self._get_request("/user/menu") 130 | return resp.status == 200 131 | 132 | async def get_api_version(self): 133 | data = await self._get_request("/user/api") 134 | text = await data.text() 135 | return version.parse(xmltodict.parse(text)["eta"]["api"]["@version"]) 136 | 137 | async def is_correct_api_version(self): 138 | eta_version = await self.get_api_version() 139 | required_version = version.parse("1.2") 140 | 141 | return eta_version >= required_version 142 | 143 | def _parse_data(self, data, force_number_handling=False): 144 | _LOGGER.debug("Parsing data %s", data) 145 | unit = data["@unit"] 146 | if unit in self._float_sensor_units or force_number_handling: 147 | scale_factor = int(data["@scaleFactor"]) 148 | # ignore the decPlaces to avoid removing any additional precision the API values have 149 | # i.e. the API may send a value of 444 with scaleFactor=10, but set decPlaces=0, 150 | # which would remove the decimal places and set the value to 44 instead of 44.4 151 | raw_value = float(data["#text"]) 152 | value = raw_value / scale_factor 153 | else: 154 | # use default text string representation for values that cannot be parsed properly 155 | value = data["@strValue"] 156 | return value, unit 157 | 158 | async def get_data(self, uri, force_number_handling=False): 159 | data = await self._get_request("/user/var/" + str(uri)) 160 | text = await data.text() 161 | data = xmltodict.parse(text)["eta"]["value"] 162 | return self._parse_data(data, force_number_handling) 163 | 164 | async def _get_data_plus_raw(self, uri): 165 | data = await self._get_request("/user/var/" + str(uri)) 166 | text = await data.text() 167 | data = xmltodict.parse(text)["eta"]["value"] 168 | value, unit = self._parse_data(data) 169 | return value, unit, data 170 | 171 | async def get_menu(self): 172 | data = await self._get_request("/user/menu") 173 | text = await data.text() 174 | return xmltodict.parse(text) 175 | 176 | async def _get_raw_sensor_dict(self): 177 | data = await self.get_menu() 178 | raw_dict = data["eta"]["menu"]["fub"] 179 | return raw_dict 180 | 181 | async def _get_sensors_dict(self): 182 | raw_dict = await self._get_raw_sensor_dict() 183 | uri_dict = {} 184 | self._evaluate_xml_dict(raw_dict, uri_dict) 185 | return uri_dict 186 | 187 | async def get_all_sensors( 188 | self, force_legacy_mode, float_dict, switches_dict, text_dict, writable_dict 189 | ): 190 | if not force_legacy_mode and await self.is_correct_api_version(): 191 | _LOGGER.debug("Get all sensors - API v1.2") 192 | # New version with varinfo endpoint detected 193 | return await self._get_all_sensors_v12( 194 | float_dict, switches_dict, text_dict, writable_dict 195 | ) 196 | else: 197 | _LOGGER.debug("Get all sensors - API v1.1") 198 | # varinfo not available -> fall back to compatibility mode 199 | return await self._get_all_sensors_v11( 200 | float_dict, switches_dict, text_dict, writable_dict 201 | ) 202 | 203 | def _get_friendly_name(self, key: str): 204 | components = key.split("_")[1:] # The first part ist always empty 205 | return " > ".join(components) 206 | 207 | def _is_switch_v11(self, endpoint_info: ETAEndpoint, raw_value: str): 208 | if endpoint_info["unit"] == "" and raw_value in ("1802", "1803"): 209 | return True 210 | return False 211 | 212 | def _parse_switch_values_v11(self, endpoint_info: ETAEndpoint): 213 | endpoint_info["valid_values"] = ETAValidSwitchValues( 214 | on_value=1803, off_value=1802 215 | ) 216 | 217 | def _is_writable_v11(self, endpoint_info: ETAEndpoint): 218 | # API v1.1 lacks the necessary function to query detailed info about the endpoint 219 | # that's why we just check the unit to see if it is in the list of acceptable writable sensor units 220 | if endpoint_info["unit"] in self._writable_sensor_units: 221 | return True 222 | return False 223 | 224 | def _parse_valid_writable_values_v11( 225 | self, endpoint_info: ETAEndpoint, raw_dict: dict 226 | ): 227 | # API v1.1 lacks the necessary function to query detailed info about the endpoint 228 | # that's why we have to assume sensible valid ranges for the endpoints based on their unit 229 | endpoint_info["valid_values"] = self._default_valid_writable_values[ 230 | endpoint_info["unit"] 231 | ] 232 | endpoint_info["valid_values"]["dec_places"] = int(raw_dict["@decPlaces"]) 233 | endpoint_info["valid_values"]["scale_factor"] = int(raw_dict["@scaleFactor"]) 234 | 235 | async def _get_all_sensors_v11( 236 | self, float_dict, switches_dict, text_dict, writable_dict 237 | ): 238 | all_endpoints = await self._get_sensors_dict() 239 | _LOGGER.debug("Got list of all endpoints: %s", all_endpoints) 240 | queried_endpoints = [] 241 | for key in all_endpoints: 242 | try: 243 | if all_endpoints[key] in queried_endpoints: 244 | _LOGGER.debug("Skipping duplicate endpoint %s", all_endpoints[key]) 245 | # ignore duplicate endpoints 246 | continue 247 | 248 | _LOGGER.debug("Querying endpoint %s", all_endpoints[key]) 249 | 250 | queried_endpoints.append(all_endpoints[key]) 251 | 252 | value, unit, raw_dict = await self._get_data_plus_raw( 253 | all_endpoints[key] 254 | ) 255 | 256 | endpoint_info = ETAEndpoint( 257 | url=all_endpoints[key], 258 | valid_values=None, 259 | friendly_name=self._get_friendly_name(key), 260 | unit=unit, 261 | # Fallback: declare all endpoints as text sensors. 262 | # If the unit is in the list of known units, the sensor will be detected as a float sensor anyway. 263 | endpoint_type="TEXT", 264 | value=value, 265 | ) 266 | 267 | unique_key = ( 268 | "eta_" 269 | + self._host.replace(".", "_") 270 | + "_" 271 | + key.lower().replace(" ", "_") 272 | ) 273 | 274 | if self._is_writable_v11(endpoint_info): 275 | _LOGGER.debug("Adding as writable sensor") 276 | # this is checked separately because all writable sensors are registered as both a sensor entity and a number entity 277 | # add a suffix to the unique id to make sure it is still unique in case the sensor is selected in the writable list and in the sensor list 278 | self._parse_valid_writable_values_v11(endpoint_info, raw_dict) 279 | writable_dict[unique_key + "_writable"] = endpoint_info 280 | 281 | if self._is_float_sensor(endpoint_info): 282 | _LOGGER.debug("Adding as float sensor") 283 | float_dict[unique_key] = endpoint_info 284 | elif self._is_switch_v11(endpoint_info, raw_dict["#text"]): 285 | _LOGGER.debug("Adding as switch") 286 | self._parse_switch_values_v11(endpoint_info) 287 | switches_dict[unique_key] = endpoint_info 288 | elif self._is_text_sensor(endpoint_info) and value != "": 289 | _LOGGER.debug("Adding as text sensor") 290 | # Ignore enpoints with an empty value 291 | # This has to be the last branch for the above fallback to work 292 | text_dict[unique_key] = endpoint_info 293 | else: 294 | _LOGGER.debug("Not adding endpoint: Unknown type") 295 | 296 | except Exception: 297 | _LOGGER.debug("Invalid endpoint", exc_info=True) 298 | 299 | def _parse_switch_values(self, endpoint_info: ETAEndpoint): 300 | valid_values = ETAValidSwitchValues(on_value=0, off_value=0) 301 | for key in endpoint_info["valid_values"]: 302 | if key in ("Ein", "On", "Ja", "Yes"): 303 | valid_values["on_value"] = endpoint_info["valid_values"][key] 304 | elif key in ("Aus", "Off", "Nein", "No"): 305 | valid_values["off_value"] = endpoint_info["valid_values"][key] 306 | endpoint_info["valid_values"] = valid_values 307 | 308 | async def _get_all_sensors_v12( 309 | self, float_dict, switches_dict, text_dict, writable_dict 310 | ): 311 | all_endpoints = await self._get_sensors_dict() 312 | _LOGGER.debug("Got list of all endpoints: %s", all_endpoints) 313 | queried_endpoints = [] 314 | for key in all_endpoints: 315 | try: 316 | if all_endpoints[key] in queried_endpoints: 317 | _LOGGER.debug("Skipping duplicate endpoint %s", all_endpoints[key]) 318 | # ignore duplicate endpoints 319 | continue 320 | 321 | _LOGGER.debug("Querying endpoint %s", all_endpoints[key]) 322 | 323 | queried_endpoints.append(all_endpoints[key]) 324 | 325 | fub = key.split("_")[1] 326 | endpoint_info = await self._get_varinfo(fub, all_endpoints[key]) 327 | 328 | unique_key = ( 329 | "eta_" 330 | + self._host.replace(".", "_") 331 | + "_" 332 | + key.lower().replace(" ", "_") 333 | ) 334 | 335 | if ( 336 | self._is_float_sensor(endpoint_info) 337 | or self._is_switch(endpoint_info) 338 | or self._is_text_sensor(endpoint_info) 339 | or ( 340 | # the ETA API is not very consistent and some sensors show different units in their `varinfo` and `var` endpoints 341 | # all of those sensors have an empty unit in `varinfo` and have `DEFAULT` as their type 342 | # i.e. the Volllaststunden sensor shows up with an empty unit in `varinfo`, but with seconds in `var` 343 | endpoint_info["unit"] == "" 344 | and endpoint_info["endpoint_type"] == "DEFAULT" 345 | ) 346 | ): 347 | value, unit = await self.get_data(all_endpoints[key]) 348 | endpoint_info["value"] = value 349 | if ( 350 | unit != endpoint_info["unit"] 351 | and endpoint_info["unit"] != CUSTOM_UNIT_MINUTES_SINCE_MIDNIGHT 352 | # update the unit of the sensor if they are different, but only if we didn't assign a custom unit to the sensor 353 | ): 354 | _LOGGER.debug( 355 | "Correcting unit for sensor from '%s' to '%s'", 356 | endpoint_info["unit"], 357 | unit, 358 | ) 359 | endpoint_info["unit"] = unit 360 | 361 | if self._is_writable(endpoint_info): 362 | _LOGGER.debug("Adding as writable sensor") 363 | # this is checked separately because all writable sensors are registered as both a sensor entity and a number entity 364 | # add a suffix to the unique id to make sure it is still unique in case the sensor is selected in the writable list and in the sensor list 365 | writable_dict[unique_key + "_writable"] = endpoint_info 366 | 367 | if self._is_float_sensor(endpoint_info): 368 | _LOGGER.debug("Adding as float sensor") 369 | float_dict[unique_key] = endpoint_info 370 | elif self._is_switch(endpoint_info): 371 | _LOGGER.debug("Adding as switch") 372 | self._parse_switch_values(endpoint_info) 373 | switches_dict[unique_key] = endpoint_info 374 | elif self._is_text_sensor(endpoint_info): 375 | _LOGGER.debug("Adding as text sensor") 376 | text_dict[unique_key] = endpoint_info 377 | else: 378 | _LOGGER.debug("Not adding endpoint: Unknown type") 379 | 380 | except Exception: 381 | _LOGGER.debug("Invalid endpoint", exc_info=True) 382 | 383 | def _is_writable(self, endpoint_info: ETAEndpoint): 384 | # TypedDict does not support isinstance(), 385 | # so we have to manually check if we hace the correct dict type 386 | # based on the presence of a known key 387 | return ( 388 | endpoint_info["valid_values"] is not None 389 | and "scaled_min_value" in endpoint_info["valid_values"] 390 | ) 391 | 392 | def _is_text_sensor(self, endpoint_info: ETAEndpoint): 393 | return endpoint_info["unit"] == "" and endpoint_info["endpoint_type"] == "TEXT" 394 | 395 | def _is_float_sensor(self, endpoint_info: ETAEndpoint): 396 | return endpoint_info["unit"] in self._float_sensor_units 397 | 398 | def _is_switch(self, endpoint_info: ETAEndpoint): 399 | valid_values = endpoint_info["valid_values"] 400 | if valid_values is None: 401 | return False 402 | if len(valid_values) != 2: 403 | return False 404 | if not all( 405 | k in ("Ein", "Aus", "On", "Off", "Ja", "Nein", "Yes", "No") 406 | for k in valid_values 407 | ): 408 | return False 409 | return True 410 | 411 | def _parse_unit(self, data): 412 | unit = data["@unit"] 413 | if unit == "": 414 | if ( 415 | "validValues" in data 416 | and data["validValues"] is not None 417 | and "min" in data["validValues"] 418 | and "max" in data["validValues"] 419 | and "#text" in data["validValues"]["min"] 420 | and int(data["@scaleFactor"]) == 1 421 | and int(data["@decPlaces"]) == 0 422 | ): 423 | _LOGGER.debug("Found time endpoint") 424 | min_value = int(data["validValues"]["min"]["#text"]) 425 | max_value = int(data["validValues"]["max"]["#text"]) 426 | if min_value == 0 and max_value == 24 * 60 - 1: 427 | # time endpoints have a min value of 0 and max value of 1439 428 | # it may be better to parse the strValue and check if it is in the format "00:00" 429 | unit = CUSTOM_UNIT_MINUTES_SINCE_MIDNIGHT 430 | return unit 431 | 432 | def _parse_varinfo(self, data): 433 | _LOGGER.debug("Parsing varinfo %s", data) 434 | valid_values = None 435 | unit = self._parse_unit(data) 436 | if ( 437 | "validValues" in data 438 | and data["validValues"] is not None 439 | and "value" in data["validValues"] 440 | ): 441 | values = data["validValues"]["value"] 442 | valid_values = dict( 443 | zip( 444 | [k["@strValue"] for k in values], 445 | [int(v["#text"]) for v in values], 446 | strict=False, 447 | ) 448 | ) 449 | elif ( 450 | "validValues" in data 451 | and data["validValues"] is not None 452 | and "min" in data["validValues"] 453 | and "#text" in data["validValues"]["min"] 454 | and unit in self._writable_sensor_units 455 | ): 456 | min_value = data["validValues"]["min"]["#text"] 457 | max_value = data["validValues"]["max"]["#text"] 458 | scale_factor = int(data["@scaleFactor"]) 459 | dec_places = int(data["@decPlaces"]) 460 | 461 | min_value = round(float(min_value) / scale_factor, dec_places) 462 | max_value = round(float(max_value) / scale_factor, dec_places) 463 | valid_values = ETAValidWritableValues( 464 | scaled_min_value=min_value, 465 | scaled_max_value=max_value, 466 | scale_factor=scale_factor, 467 | dec_places=dec_places, 468 | ) 469 | 470 | return ETAEndpoint( 471 | valid_values=valid_values, 472 | friendly_name=data["@fullName"], 473 | unit=unit, 474 | endpoint_type=data["type"], 475 | ) 476 | 477 | async def _get_varinfo(self, fub, uri): 478 | data = await self._get_request("/user/varinfo/" + str(uri)) 479 | text = await data.text() 480 | data = xmltodict.parse(text)["eta"]["varInfo"]["variable"] 481 | endpoint_info = self._parse_varinfo(data) 482 | endpoint_info["url"] = uri 483 | endpoint_info["friendly_name"] = f"{fub} > {endpoint_info['friendly_name']}" 484 | return endpoint_info 485 | 486 | def _parse_switch_state(self, data): 487 | return int(data["#text"]) 488 | 489 | async def get_switch_state(self, uri): 490 | data = await self._get_request("/user/var/" + str(uri)) 491 | text = await data.text() 492 | data = xmltodict.parse(text)["eta"]["value"] 493 | return self._parse_switch_state(data) 494 | 495 | async def set_switch_state(self, uri, state): 496 | payload = {"value": state} 497 | uri = "/user/var/" + str(uri) 498 | data = await self.post_request(uri, payload) 499 | text = await data.text() 500 | data = xmltodict.parse(text)["eta"] 501 | if "success" in data: 502 | return True 503 | 504 | _LOGGER.error( 505 | "ETA Integration - could not set state of switch. Got invalid result: %s", 506 | text, 507 | ) 508 | return False 509 | 510 | async def write_endpoint(self, uri, value, begin=None, end=None): 511 | payload = {"value": value} 512 | if begin is not None: 513 | payload["begin"] = begin 514 | payload["end"] = end 515 | uri = "/user/var/" + str(uri) 516 | data = await self.post_request(uri, payload) 517 | text = await data.text() 518 | data = xmltodict.parse(text)["eta"] 519 | if "success" in data: 520 | return True 521 | if "error" in data: 522 | _LOGGER.error( 523 | "ETA Integration - could not set write value to endpoint. Terminal returned: %s", 524 | data["error"], 525 | ) 526 | return False 527 | 528 | _LOGGER.error( 529 | "ETA Integration - could not set write value to endpoint. Got invalid result: %s", 530 | text, 531 | ) 532 | return False 533 | 534 | def _parse_errors(self, data): 535 | errors = [] 536 | if isinstance(data, dict): 537 | data = [ 538 | data, 539 | ] 540 | 541 | for fub in data: 542 | fub_name = fub.get("@name", "") 543 | fub_errors = fub.get("error", []) 544 | if isinstance(fub_errors, dict): 545 | fub_errors = [ 546 | fub_errors, 547 | ] 548 | errors.extend( 549 | ETAError( 550 | msg=error["@msg"], 551 | priority=error["@priority"], 552 | time=datetime.strptime(error["@time"], "%Y-%m-%d %H:%M:%S") 553 | if error.get("@time", "") != "" 554 | else datetime.now(), 555 | text=error["#text"], 556 | fub=fub_name, 557 | host=self._host, 558 | port=self._port, 559 | ) 560 | for error in fub_errors 561 | ) 562 | 563 | return errors 564 | 565 | async def get_errors(self): 566 | data = await self._get_request("/user/errors") 567 | text = await data.text() 568 | data = xmltodict.parse(text)["eta"]["errors"]["fub"] 569 | return self._parse_errors(data) 570 | -------------------------------------------------------------------------------- /custom_components/eta_webservices/config_flow.py: -------------------------------------------------------------------------------- 1 | """Adds config flow for Blueprint.""" 2 | 3 | import copy 4 | import logging 5 | import voluptuous as vol 6 | from homeassistant.config_entries import ConfigFlow, OptionsFlow, CONN_CLASS_CLOUD_POLL 7 | from homeassistant.core import callback 8 | from homeassistant.helpers import selector 9 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 10 | from homeassistant.const import CONF_HOST, CONF_PORT 11 | from homeassistant.helpers.entity_registry import ( 12 | async_entries_for_config_entry, 13 | async_get, 14 | ) 15 | import homeassistant.helpers.config_validation as cv 16 | from .api import ETAEndpoint, EtaAPI 17 | from .const import ( 18 | DOMAIN, 19 | FLOAT_DICT, 20 | SWITCHES_DICT, 21 | TEXT_DICT, 22 | WRITABLE_DICT, 23 | CHOSEN_FLOAT_SENSORS, 24 | CHOSEN_SWITCHES, 25 | CHOSEN_TEXT_SENSORS, 26 | CHOSEN_WRITABLE_SENSORS, 27 | FORCE_LEGACY_MODE, 28 | ENABLE_DEBUG_LOGGING, 29 | INVISIBLE_UNITS, 30 | OPTIONS_UPDATE_SENSOR_VALUES, 31 | OPTIONS_ENUMERATE_NEW_ENDPOINTS, 32 | ADVANCED_OPTIONS_IGNORE_DECIMAL_PLACES_RESTRICTION, 33 | ) 34 | 35 | _LOGGER = logging.getLogger(__name__) 36 | 37 | 38 | class EtaFlowHandler(ConfigFlow, domain=DOMAIN): 39 | """Config flow for Eta.""" 40 | 41 | VERSION = 5 42 | CONNECTION_CLASS = CONN_CLASS_CLOUD_POLL 43 | 44 | def __init__(self) -> None: 45 | """Initialize.""" 46 | self._errors = {} 47 | self.data = {} 48 | self._old_logging_level = logging.NOTSET 49 | 50 | async def async_step_user(self, user_input=None): 51 | """Handle a flow initialized by the user.""" 52 | self._errors = {} 53 | 54 | # Uncomment the next 2 lines if only a single instance of the integration is allowed: 55 | # if self._async_current_entries(): 56 | # return self.async_abort(reason="single_instance_allowed") 57 | 58 | if user_input is not None: 59 | platform_entries = self._async_current_entries() 60 | for entry in platform_entries: 61 | if entry.data.get(CONF_HOST, "") == user_input[CONF_HOST]: 62 | return self.async_abort(reason="single_instance_allowed") 63 | valid = await self._test_url(user_input[CONF_HOST], user_input[CONF_PORT]) 64 | if valid == 1: 65 | is_correct_api_version = await self._is_correct_api_version( 66 | user_input[CONF_HOST], user_input[CONF_PORT] 67 | ) 68 | if not is_correct_api_version: 69 | self._errors["base"] = "wrong_api_version" 70 | elif user_input[FORCE_LEGACY_MODE]: 71 | self._errors["base"] = "legacy_mode_selected" 72 | 73 | if user_input[ENABLE_DEBUG_LOGGING]: 74 | self._old_logging_level = _LOGGER.parent.getEffectiveLevel() 75 | _LOGGER.parent.setLevel(logging.DEBUG) 76 | 77 | self.data = user_input 78 | 79 | ( 80 | self.data[FLOAT_DICT], 81 | self.data[SWITCHES_DICT], 82 | self.data[TEXT_DICT], 83 | self.data[WRITABLE_DICT], 84 | ) = await self._get_possible_endpoints( 85 | user_input[CONF_HOST], 86 | user_input[CONF_PORT], 87 | user_input[FORCE_LEGACY_MODE], 88 | ) 89 | 90 | return await self.async_step_select_entities() 91 | else: 92 | self._errors["base"] = ( 93 | "no_eta_endpoint" if valid == 0 else "unknown_host" 94 | ) 95 | 96 | return await self._show_config_form_user(user_input) 97 | 98 | user_input = {} 99 | # Provide defaults for form 100 | user_input[CONF_HOST] = "0.0.0.0" 101 | user_input[CONF_PORT] = "8080" 102 | 103 | return await self._show_config_form_user(user_input) 104 | 105 | async def async_step_select_entities(self, user_input: dict = None): 106 | """Second step in config flow to add a repo to watch.""" 107 | if user_input is not None: 108 | # add chosen entities to data 109 | self.data[CHOSEN_FLOAT_SENSORS] = user_input.get(CHOSEN_FLOAT_SENSORS, []) 110 | self.data[CHOSEN_SWITCHES] = user_input.get(CHOSEN_SWITCHES, []) 111 | self.data[CHOSEN_TEXT_SENSORS] = user_input.get(CHOSEN_TEXT_SENSORS, []) 112 | self.data[CHOSEN_WRITABLE_SENSORS] = user_input.get( 113 | CHOSEN_WRITABLE_SENSORS, [] 114 | ) 115 | 116 | # Restore old logging level 117 | if self._old_logging_level != logging.NOTSET: 118 | _LOGGER.parent.setLevel(self._old_logging_level) 119 | 120 | # User is done, create the config entry. 121 | return self.async_create_entry( 122 | title=f"ETA at {self.data[CONF_HOST]}", data=self.data 123 | ) 124 | 125 | return await self._show_config_form_endpoint() 126 | 127 | @staticmethod 128 | @callback 129 | def async_get_options_flow(config_entry): 130 | return EtaOptionsFlowHandler() 131 | 132 | async def _show_config_form_user(self, user_input): # pylint: disable=unused-argument 133 | """Show the configuration form to edit host and port data.""" 134 | return self.async_show_form( 135 | step_id="user", 136 | data_schema=vol.Schema( 137 | { 138 | vol.Required(CONF_HOST, default=user_input[CONF_HOST]): str, 139 | vol.Required(CONF_PORT, default=user_input[CONF_PORT]): str, 140 | vol.Required(FORCE_LEGACY_MODE, default=False): cv.boolean, 141 | vol.Required(ENABLE_DEBUG_LOGGING, default=False): cv.boolean, 142 | } 143 | ), 144 | errors=self._errors, 145 | ) 146 | 147 | async def _show_config_form_endpoint(self): 148 | """Show the configuration form to select which endpoints should become entities.""" 149 | sensors_dict: dict[str, ETAEndpoint] = self.data[FLOAT_DICT] 150 | switches_dict: dict[str, ETAEndpoint] = self.data[SWITCHES_DICT] 151 | text_dict: dict[str, ETAEndpoint] = self.data[TEXT_DICT] 152 | writable_dict: dict[str, ETAEndpoint] = self.data[WRITABLE_DICT] 153 | 154 | return self.async_show_form( 155 | step_id="select_entities", 156 | data_schema=vol.Schema( 157 | { 158 | vol.Optional(CHOSEN_FLOAT_SENSORS): selector.SelectSelector( 159 | selector.SelectSelectorConfig( 160 | options=[ 161 | selector.SelectOptionDict( 162 | value=key, 163 | label=f"{sensors_dict[key]['friendly_name']} ({sensors_dict[key]['value']} {sensors_dict[key]['unit'] if sensors_dict[key]['unit'] not in INVISIBLE_UNITS else ''})", 164 | ) 165 | for key in sensors_dict 166 | ], 167 | mode=selector.SelectSelectorMode.DROPDOWN, 168 | multiple=True, 169 | ) 170 | ), 171 | vol.Optional(CHOSEN_SWITCHES): selector.SelectSelector( 172 | selector.SelectSelectorConfig( 173 | options=[ 174 | selector.SelectOptionDict( 175 | value=key, 176 | label=f"{switches_dict[key]['friendly_name']} ({switches_dict[key]['value']})", 177 | ) 178 | for key in switches_dict 179 | ], 180 | mode=selector.SelectSelectorMode.DROPDOWN, 181 | multiple=True, 182 | ) 183 | ), 184 | vol.Optional(CHOSEN_TEXT_SENSORS): selector.SelectSelector( 185 | selector.SelectSelectorConfig( 186 | options=[ 187 | selector.SelectOptionDict( 188 | value=key, 189 | label=f"{text_dict[key]['friendly_name']} ({text_dict[key]['value']})", 190 | ) 191 | for key in text_dict 192 | ], 193 | mode=selector.SelectSelectorMode.DROPDOWN, 194 | multiple=True, 195 | ) 196 | ), 197 | vol.Optional(CHOSEN_WRITABLE_SENSORS): selector.SelectSelector( 198 | selector.SelectSelectorConfig( 199 | options=[ 200 | selector.SelectOptionDict( 201 | value=key, 202 | label=f"{writable_dict[key]['friendly_name']} ({writable_dict[key]['value']} {writable_dict[key]['unit'] if writable_dict[key]['unit'] not in INVISIBLE_UNITS else ''})", 203 | ) 204 | for key in writable_dict 205 | ], 206 | mode=selector.SelectSelectorMode.DROPDOWN, 207 | multiple=True, 208 | ) 209 | ), 210 | } 211 | ), 212 | errors=self._errors, 213 | ) 214 | 215 | async def _get_possible_endpoints(self, host, port, force_legacy_mode): 216 | session = async_get_clientsession(self.hass) 217 | eta_client = EtaAPI(session, host, port) 218 | float_dict = {} 219 | switches_dict = {} 220 | text_dict = {} 221 | writable_dict = {} 222 | await eta_client.get_all_sensors( 223 | force_legacy_mode, float_dict, switches_dict, text_dict, writable_dict 224 | ) 225 | 226 | _LOGGER.debug( 227 | "Queried sensors: Number of float sensors: %i, Number of switches: %i, Number of text sensors: %i, Number of writable sensors: %i", 228 | len(float_dict), 229 | len(switches_dict), 230 | len(text_dict), 231 | len(writable_dict), 232 | ) 233 | 234 | return float_dict, switches_dict, text_dict, writable_dict 235 | 236 | async def _test_url(self, host, port): 237 | """Return true if host port is valid.""" 238 | session = async_get_clientsession(self.hass) 239 | eta_client = EtaAPI(session, host, port) 240 | 241 | try: 242 | does_endpoint_exist = await eta_client.does_endpoint_exists() 243 | except: 244 | return -1 245 | return 1 if does_endpoint_exist else 0 246 | 247 | async def _is_correct_api_version(self, host, port): 248 | session = async_get_clientsession(self.hass) 249 | eta_client = EtaAPI(session, host, port) 250 | 251 | return await eta_client.is_correct_api_version() 252 | 253 | 254 | class EtaOptionsFlowHandler(OptionsFlow): 255 | """Blueprint config flow options handler.""" 256 | 257 | @property 258 | def config_entry(self): 259 | return self.hass.config_entries.async_get_entry(self.handler) 260 | 261 | def __init__(self) -> None: 262 | """Initialize HACS options flow.""" 263 | self.data = {} 264 | self._errors = {} 265 | self.update_sensor_values = True 266 | self.enumerate_new_endpoints = False 267 | self.unavailable_sensors: dict = {} 268 | 269 | async def _get_possible_endpoints(self, host, port, force_legacy_mode): 270 | session = async_get_clientsession(self.hass) 271 | eta_client = EtaAPI(session, host, port) 272 | float_dict = {} 273 | switches_dict = {} 274 | text_dict = {} 275 | writable_dict = {} 276 | await eta_client.get_all_sensors( 277 | force_legacy_mode, float_dict, switches_dict, text_dict, writable_dict 278 | ) 279 | 280 | return float_dict, switches_dict, text_dict, writable_dict 281 | 282 | async def async_step_init(self, user_input=None): 283 | if user_input is not None: 284 | self.update_sensor_values = user_input[OPTIONS_UPDATE_SENSOR_VALUES] 285 | self.enumerate_new_endpoints = user_input[OPTIONS_ENUMERATE_NEW_ENDPOINTS] 286 | return await self._update_data_structures() 287 | 288 | return await self._show_initial_option_screen() 289 | 290 | async def _show_initial_option_screen(self): 291 | """Show the initial option form.""" 292 | return self.async_show_form( 293 | step_id="init", 294 | data_schema=vol.Schema( 295 | { 296 | vol.Required( 297 | OPTIONS_UPDATE_SENSOR_VALUES, default=True 298 | ): cv.boolean, 299 | vol.Required( 300 | OPTIONS_ENUMERATE_NEW_ENDPOINTS, default=False 301 | ): cv.boolean, 302 | } 303 | ), 304 | errors=self._errors, 305 | ) 306 | 307 | async def _update_sensor_values(self): 308 | session = async_get_clientsession(self.hass) 309 | eta_client = EtaAPI(session, self.data[CONF_HOST], self.data[CONF_PORT]) 310 | 311 | for entity in list(self.data[FLOAT_DICT].keys()): 312 | try: 313 | self.data[FLOAT_DICT][entity]["value"], _ = await eta_client.get_data( 314 | self.data[FLOAT_DICT][entity]["url"] 315 | ) 316 | except Exception: 317 | _LOGGER.exception( 318 | "Exception while updating the value for endpoint '%s' (%s)", 319 | self.data[FLOAT_DICT][entity]["friendly_name"], 320 | self.data[FLOAT_DICT][entity]["url"], 321 | ) 322 | self._errors["base"] = "value_update_error" 323 | 324 | for entity in list(self.data[SWITCHES_DICT].keys()): 325 | try: 326 | ( 327 | self.data[SWITCHES_DICT][entity]["value"], 328 | _, 329 | ) = await eta_client.get_data(self.data[SWITCHES_DICT][entity]["url"]) 330 | except Exception: 331 | _LOGGER.exception( 332 | "Exception while updating the value for endpoint '%s' (%s)", 333 | self.data[SWITCHES_DICT][entity]["friendly_name"], 334 | self.data[SWITCHES_DICT][entity]["url"], 335 | ) 336 | self._errors["base"] = "value_update_error" 337 | for entity in list(self.data[TEXT_DICT].keys()): 338 | try: 339 | self.data[TEXT_DICT][entity]["value"], _ = await eta_client.get_data( 340 | self.data[TEXT_DICT][entity]["url"] 341 | ) 342 | except Exception: 343 | _LOGGER.exception( 344 | "Exception while updating the value for endpoint '%s' (%s)", 345 | self.data[TEXT_DICT][entity]["friendly_name"], 346 | self.data[TEXT_DICT][entity]["url"], 347 | ) 348 | self._errors["base"] = "value_update_error" 349 | for entity in list(self.data[WRITABLE_DICT].keys()): 350 | try: 351 | ( 352 | self.data[WRITABLE_DICT][entity]["value"], 353 | _, 354 | ) = await eta_client.get_data(self.data[WRITABLE_DICT][entity]["url"]) 355 | except Exception: 356 | _LOGGER.exception( 357 | "Exception while updating the value for endpoint '%s' (%s)", 358 | self.data[WRITABLE_DICT][entity]["friendly_name"], 359 | self.data[WRITABLE_DICT][entity]["url"], 360 | ) 361 | self._errors["base"] = "value_update_error" 362 | 363 | def _handle_new_sensors( 364 | self, 365 | new_float_sensors: dict, 366 | new_switches: dict, 367 | new_text_sensors: dict, 368 | new_writable_sensors: dict, 369 | ): 370 | added_sensor_count = 0 371 | # Add newly detected sensors to the lists of available sensors 372 | for key, value in new_float_sensors.items(): 373 | if key not in self.data[FLOAT_DICT]: 374 | added_sensor_count += 1 375 | self.data[FLOAT_DICT][key] = value 376 | 377 | for key, value in new_switches.items(): 378 | if key not in self.data[SWITCHES_DICT]: 379 | added_sensor_count += 1 380 | self.data[SWITCHES_DICT][key] = value 381 | 382 | for key, value in new_text_sensors.items(): 383 | if key not in self.data[TEXT_DICT]: 384 | added_sensor_count += 1 385 | self.data[TEXT_DICT][key] = value 386 | 387 | for key, value in new_writable_sensors.items(): 388 | if key not in self.data[WRITABLE_DICT]: 389 | added_sensor_count += 1 390 | self.data[WRITABLE_DICT][key] = value 391 | 392 | return added_sensor_count 393 | 394 | def _handle_deleted_sensors( 395 | self, 396 | new_float_sensors: dict, 397 | new_switches: dict, 398 | new_text_sensors: dict, 399 | new_writable_sensors: dict, 400 | ): 401 | deleted_sensor_count = 0 402 | # Delete sensors which are no longer available 403 | for key in list(self.data[FLOAT_DICT].keys()): 404 | # Loop over a copy of the keys of the dict to be able to delete items in-place 405 | if key not in new_float_sensors: 406 | deleted_sensor_count += 1 407 | if key in self.data[CHOSEN_FLOAT_SENSORS]: 408 | # Remember deleted chosen sensors to be able to show them to the user later 409 | self.data[CHOSEN_FLOAT_SENSORS].remove(key) 410 | self.unavailable_sensors[key] = self.data[FLOAT_DICT][key] 411 | del self.data[FLOAT_DICT][key] 412 | 413 | for key in list(self.data[SWITCHES_DICT].keys()): 414 | # Loop over a copy of the keys of the dict to be able to delete items in-place 415 | if key not in new_switches: 416 | deleted_sensor_count += 1 417 | if key in self.data[CHOSEN_SWITCHES]: 418 | # Remember deleted chosen sensors to be able to show them to the user later 419 | self.data[CHOSEN_SWITCHES].remove(key) 420 | self.unavailable_sensors[key] = self.data[SWITCHES_DICT][key] 421 | del self.data[SWITCHES_DICT][key] 422 | 423 | for key in list(self.data[TEXT_DICT].keys()): 424 | # Loop over a copy of the keys of the dict to be able to delete items in-place 425 | if key not in new_text_sensors: 426 | deleted_sensor_count += 1 427 | if key in self.data[CHOSEN_TEXT_SENSORS]: 428 | # Remember deleted chosen sensors to be able to show them to the user later 429 | self.data[CHOSEN_TEXT_SENSORS].remove(key) 430 | self.unavailable_sensors[key] = self.data[TEXT_DICT][key] 431 | del self.data[TEXT_DICT][key] 432 | 433 | for key in list(self.data[WRITABLE_DICT].keys()): 434 | # Loop over a copy of the keys of the dict to be able to delete items in-place 435 | if key not in new_writable_sensors: 436 | deleted_sensor_count += 1 437 | if key in self.data[CHOSEN_WRITABLE_SENSORS]: 438 | # Remember deleted chosen sensors to be able to show them to the user later 439 | self.data[CHOSEN_WRITABLE_SENSORS].remove(key) 440 | self.unavailable_sensors[key] = self.data[WRITABLE_DICT][key] 441 | del self.data[WRITABLE_DICT][key] 442 | 443 | return deleted_sensor_count 444 | 445 | def _handle_sensor_value_updates_from_enumeration( 446 | self, 447 | new_float_sensors: dict, 448 | new_switches: dict, 449 | new_text_sensors: dict, 450 | new_writable_sensors: dict, 451 | ): 452 | try: 453 | for key in self.data[FLOAT_DICT]: 454 | self.data[FLOAT_DICT][key]["value"] = new_float_sensors[key]["value"] 455 | for key in self.data[SWITCHES_DICT]: 456 | self.data[SWITCHES_DICT][key]["value"] = new_switches[key]["value"] 457 | for key in self.data[TEXT_DICT]: 458 | self.data[TEXT_DICT][key]["value"] = new_text_sensors[key]["value"] 459 | for key in self.data[WRITABLE_DICT]: 460 | self.data[WRITABLE_DICT][key]["value"] = new_writable_sensors[key][ 461 | "value" 462 | ] 463 | except Exception: 464 | _LOGGER.exception("Exception while updating sensor values") 465 | 466 | async def _update_data_structures(self): 467 | # Make a copy of the data structure to make sure we don't alter the original data 468 | for key in [ 469 | CONF_HOST, 470 | CONF_PORT, 471 | FLOAT_DICT, 472 | SWITCHES_DICT, 473 | TEXT_DICT, 474 | WRITABLE_DICT, 475 | CHOSEN_FLOAT_SENSORS, 476 | CHOSEN_SWITCHES, 477 | CHOSEN_TEXT_SENSORS, 478 | CHOSEN_WRITABLE_SENSORS, 479 | FORCE_LEGACY_MODE, 480 | ]: 481 | self.data[key] = copy.copy( 482 | self.hass.data[DOMAIN][self.config_entry.entry_id][key] 483 | ) 484 | # ADVANCED_OPTIONS_IGNORE_DECIMAL_PLACES_RESTRICTION can be unset, so we have to handle it separately 485 | self.data[ADVANCED_OPTIONS_IGNORE_DECIMAL_PLACES_RESTRICTION] = self.hass.data[ 486 | DOMAIN 487 | ][self.config_entry.entry_id].get( 488 | ADVANCED_OPTIONS_IGNORE_DECIMAL_PLACES_RESTRICTION, [] 489 | ) 490 | 491 | if self.enumerate_new_endpoints: 492 | _LOGGER.info("Discovering new endpoints") 493 | ( 494 | new_float_sensors, 495 | new_switches, 496 | new_text_sensors, 497 | new_writable_sensors, 498 | ) = await self._get_possible_endpoints( 499 | self.data[CONF_HOST], self.data[CONF_PORT], self.data[FORCE_LEGACY_MODE] 500 | ) 501 | 502 | added_sensor_count = self._handle_new_sensors( 503 | new_float_sensors, new_switches, new_text_sensors, new_writable_sensors 504 | ) 505 | _LOGGER.info("Added %i new sensors", added_sensor_count) 506 | 507 | deleted_sensor_count = self._handle_deleted_sensors( 508 | new_float_sensors, new_switches, new_text_sensors, new_writable_sensors 509 | ) 510 | _LOGGER.info("Deleted %i unavailable sensors", deleted_sensor_count) 511 | 512 | self._handle_sensor_value_updates_from_enumeration( 513 | new_float_sensors, new_switches, new_text_sensors, new_writable_sensors 514 | ) 515 | _LOGGER.info("Updated sensor values") 516 | 517 | elif self.update_sensor_values: 518 | # Update the current sensor values if requested and if we did not already re-enumerate the whole list of sensors 519 | await self._update_sensor_values() 520 | 521 | return await self.async_step_user() 522 | 523 | async def async_step_user(self, user_input=None): 524 | """Manage the options.""" 525 | entity_registry = async_get(self.hass) 526 | entries = async_entries_for_config_entry( 527 | entity_registry, self.config_entry.entry_id 528 | ) 529 | 530 | entity_map_sensors = { 531 | e.unique_id: e for e in entries if e.unique_id in self.data[FLOAT_DICT] 532 | } 533 | entity_map_switches = { 534 | e.unique_id: e for e in entries if e.unique_id in self.data[SWITCHES_DICT] 535 | } 536 | entity_map_text_sensors = { 537 | e.unique_id: e for e in entries if e.unique_id in self.data[TEXT_DICT] 538 | } 539 | entity_map_writable_sensors = { 540 | e.unique_id: e for e in entries if e.unique_id in self.data[WRITABLE_DICT] 541 | } 542 | 543 | if user_input is not None: 544 | removed_entities = [ 545 | entity_map_sensors[entity_id] 546 | for entity_id in entity_map_sensors 547 | if entity_id not in user_input[CHOSEN_FLOAT_SENSORS] 548 | ] 549 | removed_entities.extend( 550 | [ 551 | entity_map_switches[entity_id] 552 | for entity_id in entity_map_switches 553 | if entity_id not in user_input[CHOSEN_SWITCHES] 554 | ] 555 | ) 556 | removed_entities.extend( 557 | [ 558 | entity_map_text_sensors[entity_id] 559 | for entity_id in entity_map_text_sensors 560 | if entity_id not in user_input[CHOSEN_TEXT_SENSORS] 561 | ] 562 | ) 563 | removed_entities.extend( 564 | [ 565 | entity_map_writable_sensors[entity_id] 566 | for entity_id in entity_map_writable_sensors 567 | if entity_id not in user_input[CHOSEN_WRITABLE_SENSORS] 568 | ] 569 | ) 570 | for e in removed_entities: 571 | # Unregister from HA 572 | entity_registry.async_remove(e.entity_id) 573 | 574 | data = { 575 | CHOSEN_FLOAT_SENSORS: user_input[CHOSEN_FLOAT_SENSORS], 576 | CHOSEN_SWITCHES: user_input[CHOSEN_SWITCHES], 577 | CHOSEN_TEXT_SENSORS: user_input[CHOSEN_TEXT_SENSORS], 578 | CHOSEN_WRITABLE_SENSORS: user_input[CHOSEN_WRITABLE_SENSORS], 579 | FLOAT_DICT: self.data[FLOAT_DICT], 580 | SWITCHES_DICT: self.data[SWITCHES_DICT], 581 | TEXT_DICT: self.data[TEXT_DICT], 582 | WRITABLE_DICT: self.data[WRITABLE_DICT], 583 | CONF_HOST: self.data[CONF_HOST], 584 | CONF_PORT: self.data[CONF_PORT], 585 | ADVANCED_OPTIONS_IGNORE_DECIMAL_PLACES_RESTRICTION: self.data[ 586 | ADVANCED_OPTIONS_IGNORE_DECIMAL_PLACES_RESTRICTION 587 | ], 588 | FORCE_LEGACY_MODE: self.data[FORCE_LEGACY_MODE], 589 | } 590 | 591 | # If the user selected at least one writable sensor, show 592 | # an additional options page to configure advanced settings. 593 | if len(data[CHOSEN_WRITABLE_SENSORS]) > 0: 594 | # store interim data and show extra options step 595 | self.data = data 596 | return await self.async_step_advanced_options() 597 | 598 | return self.async_create_entry(title="", data=data) 599 | 600 | return await self._show_config_form_endpoint( 601 | list(entity_map_sensors.keys()), 602 | list(entity_map_switches.keys()), 603 | list(entity_map_text_sensors.keys()), 604 | list(entity_map_writable_sensors.keys()), 605 | ) 606 | 607 | async def async_step_advanced_options(self, user_input=None): 608 | """Handle the advanced options step (only if writable sensors are selected for now).""" 609 | 610 | if user_input is not None: 611 | self.data[ADVANCED_OPTIONS_IGNORE_DECIMAL_PLACES_RESTRICTION] = user_input[ 612 | ADVANCED_OPTIONS_IGNORE_DECIMAL_PLACES_RESTRICTION 613 | ] 614 | 615 | return self.async_create_entry(title="", data=self.data) 616 | 617 | return await self._show_advanced_options_screen() 618 | 619 | async def _show_advanced_options_screen(self): 620 | """Show the extra options form for writable sensors.""" 621 | 622 | return self.async_show_form( 623 | step_id="advanced_options", 624 | data_schema=vol.Schema( 625 | { 626 | vol.Optional( 627 | ADVANCED_OPTIONS_IGNORE_DECIMAL_PLACES_RESTRICTION, 628 | default=self.data.get( 629 | ADVANCED_OPTIONS_IGNORE_DECIMAL_PLACES_RESTRICTION, [] 630 | ), 631 | ): selector.SelectSelector( 632 | selector.SelectSelectorConfig( 633 | options=[ 634 | selector.SelectOptionDict( 635 | value=key, 636 | label=f"{self.data[WRITABLE_DICT][key]['friendly_name']} ({self.data[WRITABLE_DICT][key]['value']} {self.data[WRITABLE_DICT][key]['unit'] if self.data[WRITABLE_DICT][key]['unit'] not in INVISIBLE_UNITS else ''})", 637 | ) 638 | for key in self.data[CHOSEN_WRITABLE_SENSORS] 639 | ], 640 | mode=selector.SelectSelectorMode.DROPDOWN, 641 | multiple=True, 642 | ) 643 | ), 644 | } 645 | ), 646 | errors=self._errors, 647 | ) 648 | 649 | async def _show_config_form_endpoint( 650 | self, 651 | current_chosen_sensors, 652 | current_chosen_switches, 653 | current_chosen_text_sensors, 654 | current_chosen_writable_sensors, 655 | ): 656 | """Show the configuration form to select which endpoints should become entities.""" 657 | if len(self.unavailable_sensors) > 0: 658 | self._errors["base"] = "unavailable_sensors" 659 | 660 | schema = { 661 | vol.Optional( 662 | CHOSEN_FLOAT_SENSORS, default=current_chosen_sensors 663 | ): selector.SelectSelector( 664 | selector.SelectSelectorConfig( 665 | options=[ 666 | selector.SelectOptionDict( 667 | value=key, 668 | label=f"{self.data[FLOAT_DICT][key]['friendly_name']} ({self.data[FLOAT_DICT][key]['value']} {self.data[FLOAT_DICT][key]['unit'] if self.data[FLOAT_DICT][key]['unit'] not in INVISIBLE_UNITS else ''})", 669 | ) 670 | for key in self.data[FLOAT_DICT] 671 | ], 672 | mode=selector.SelectSelectorMode.DROPDOWN, 673 | multiple=True, 674 | ) 675 | ), 676 | vol.Optional( 677 | CHOSEN_SWITCHES, default=current_chosen_switches 678 | ): selector.SelectSelector( 679 | selector.SelectSelectorConfig( 680 | options=[ 681 | selector.SelectOptionDict( 682 | value=key, 683 | label=f"{self.data[SWITCHES_DICT][key]['friendly_name']} ({self.data[SWITCHES_DICT][key]['value']})", 684 | ) 685 | for key in self.data[SWITCHES_DICT] 686 | ], 687 | mode=selector.SelectSelectorMode.DROPDOWN, 688 | multiple=True, 689 | ) 690 | ), 691 | vol.Optional( 692 | CHOSEN_TEXT_SENSORS, default=current_chosen_text_sensors 693 | ): selector.SelectSelector( 694 | selector.SelectSelectorConfig( 695 | options=[ 696 | selector.SelectOptionDict( 697 | value=key, 698 | label=f"{self.data[TEXT_DICT][key]['friendly_name']} ({self.data[TEXT_DICT][key]['value']})", 699 | ) 700 | for key in self.data[TEXT_DICT] 701 | ], 702 | mode=selector.SelectSelectorMode.DROPDOWN, 703 | multiple=True, 704 | ) 705 | ), 706 | vol.Optional( 707 | CHOSEN_WRITABLE_SENSORS, default=current_chosen_writable_sensors 708 | ): selector.SelectSelector( 709 | selector.SelectSelectorConfig( 710 | options=[ 711 | selector.SelectOptionDict( 712 | value=key, 713 | label=f"{self.data[WRITABLE_DICT][key]['friendly_name']} ({self.data[WRITABLE_DICT][key]['value']} {self.data[WRITABLE_DICT][key]['unit'] if self.data[WRITABLE_DICT][key]['unit'] not in INVISIBLE_UNITS else ''})", 714 | ) 715 | for key in self.data[WRITABLE_DICT] 716 | ], 717 | mode=selector.SelectSelectorMode.DROPDOWN, 718 | multiple=True, 719 | ) 720 | ), 721 | } 722 | 723 | if len(self.unavailable_sensors) > 0: 724 | # Add list of unavailable sensors to the schema if necessary 725 | unavailable_sensor_keys = "\n\n".join( 726 | [ 727 | f"{value['friendly_name']}\n ({key})" 728 | for key, value in self.unavailable_sensors.items() 729 | ] 730 | ) 731 | schema.update( 732 | { 733 | vol.Optional( 734 | "unavailable_sensors", default=unavailable_sensor_keys 735 | ): selector.TextSelector( 736 | selector.TextSelectorConfig( 737 | multiline=True, 738 | read_only=True, 739 | ) 740 | ), 741 | } 742 | ) 743 | 744 | return self.async_show_form( 745 | step_id="user", 746 | data_schema=vol.Schema(schema), 747 | errors=self._errors, 748 | ) 749 | --------------------------------------------------------------------------------