diff --git a/changes.md b/changes.md index 0bec7b42..c0834a2c 100644 --- a/changes.md +++ b/changes.md @@ -1,7 +1,11 @@ # Changes and Feature List -## Version 1.3.7 Import Cleanup -* Trial to remove import warnings (Reported Issue: https://github.com/grimmpp/home-assistant-eltako/issues/61) +## Version 1.3.7 Restore Device States after HA Restart +* Trial to remove import warnings + Reported Issue: https://github.com/grimmpp/home-assistant-eltako/issues/61 +* 🐞 Removed entity_id bug from GatewayConnectionState 🐞 => Requires removing and adding gateway again ❗ +* Added state cache of device entities. When restarting HA entities like temperature sensors will show previouse state/value after restart. + Reported Feature: https://github.com/grimmpp/home-assistant-eltako/issues/63 ## Version 1.3.6 Dependencies fixed for 1.3.5 * 🐞 Wrong dependency in manifest 🐞 diff --git a/custom_components/eltako/binary_sensor.py b/custom_components/eltako/binary_sensor.py index bd4e05e5..71892b56 100644 --- a/custom_components/eltako/binary_sensor.py +++ b/custom_components/eltako/binary_sensor.py @@ -1,12 +1,13 @@ """Support for Eltako binary sensors.""" from __future__ import annotations +from typing import Literal, final from eltakobus.util import AddressExpression, b2a, b2s from eltakobus.eep import * from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant import config_entries -from homeassistant.const import CONF_DEVICE_CLASS +from homeassistant.const import CONF_DEVICE_CLASS, STATE_ON, STATE_OFF from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity import DeviceInfo @@ -33,13 +34,13 @@ async def async_setup_entry( platform = Platform.BINARY_SENSOR - for platform in [Platform.BINARY_SENSOR, Platform.SENSOR]: - if platform in config: - for entity_config in config[platform]: + for platform_id in [Platform.BINARY_SENSOR, Platform.SENSOR]: + if platform_id in config: + for entity_config in config[platform_id]: try: dev_conf = config_helpers.DeviceConf(entity_config, [CONF_DEVICE_CLASS, CONF_INVERT_SIGNAL]) if dev_conf.eep.eep_string in CONF_EEP_SUPPORTED_BINARY_SENSOR: - entities.append(EltakoBinarySensor(platform, gateway, dev_conf.id, dev_conf.name, dev_conf.eep, + entities.append(EltakoBinarySensor(platform_id, gateway, dev_conf.id, dev_conf.name, dev_conf.eep, dev_conf.get(CONF_DEVICE_CLASS), dev_conf.get(CONF_INVERT_SIGNAL))) except Exception as e: @@ -52,9 +53,29 @@ async def async_setup_entry( # dev_id validation not possible because there can be bus sensors as well as decentralized sensors. log_entities_to_be_added(entities, platform) async_add_entities(entities) + + +class AbstractBinarySensor(EltakoEntity, RestoreEntity, BinarySensorEntity): + def load_value_initially(self, latest_state:State): + try: + if 'unknown' == latest_state.state: + self._attr_is_on = None + else: + if latest_state.state in ['on', 'off']: + self._attr_is_on = 'on' == latest_state.state + else: + self._attr_is_on = None + + except Exception as e: + self._attr_is_on = None + raise e + + self.schedule_update_ha_state() -class EltakoBinarySensor(EltakoEntity, BinarySensorEntity): + LOGGER.debug(f"[binary_sensor {self.dev_id}] value initially loaded: [is_on: {self.is_on}, state: {self.state}]") + +class EltakoBinarySensor(AbstractBinarySensor): """Representation of Eltako binary sensors such as wall switches. Supported EEPs (EnOcean Equipment Profiles): @@ -69,16 +90,7 @@ def __init__(self, platform: str, gateway: EnOceanGateway, dev_id: AddressExpres super().__init__(platform, gateway, dev_id, dev_name, dev_eep) self.invert_signal = invert_signal self._attr_device_class = device_class - - @property - def last_received_signal(self): - """Return timestamp of last received signal.""" - return self._attr_last_received_signal - - @property - def data(self): - """Return telegram data for rocker switch.""" - return self._attr_data + def value_changed(self, msg: ESP2Message): """Fire an event with the data that have changed. @@ -229,17 +241,17 @@ def value_changed(self, msg: ESP2Message): }, ) -class GatewayConnectionState(EltakoEntity, BinarySensorEntity): +class GatewayConnectionState(AbstractBinarySensor): """Protocols last time when message received""" def __init__(self, platform: str, gateway: EnOceanGateway): - super().__init__(platform, gateway, gateway.base_id, "Connected" ) + key = "Gateway_Connection_State" - self._attr_unique_id = f"{self.identifier}_Last Received Message - Gateway "+str(gateway.dev_id) + self._attr_icon = "mdi:connection" + self._attr_name = "Connected" + + super().__init__(platform, gateway, gateway.base_id, dev_name="Connected", description_key=key) self.gateway.set_connection_state_changed_handler(self.async_value_changed) - self.icon = "mdi:connection" - self.name = "Connected" - self.has_entity_name = True @property def device_info(self) -> DeviceInfo: diff --git a/custom_components/eltako/button.py b/custom_components/eltako/button.py index 563b7d41..5f8f9734 100644 --- a/custom_components/eltako/button.py +++ b/custom_components/eltako/button.py @@ -76,17 +76,16 @@ def __init__(self, platform: str, gateway: EnOceanGateway, dev_id: AddressExpres _dev_name = dev_name if _dev_name == "": _dev_name = "temperature-controller-teach-in-button" - super().__init__(platform, gateway, dev_id, _dev_name, dev_eep) self.entity_description = ButtonEntityDescription( key="teach_in_button", name="Send teach-in telegram from "+sender_id.plain_address().hex(), icon="mdi:button-cursor", device_class=ButtonDeviceClass.UPDATE, - has_entity_name= True, ) - self._attr_unique_id = f"{self.identifier}_{self.entity_description.key}" self.sender_id = sender_id + super().__init__(platform, gateway, dev_id, _dev_name, dev_eep) + async def async_press(self) -> None: """ Handle the button press. @@ -101,15 +100,14 @@ class GatewayReconnectButton(EltakoEntity, ButtonEntity): """Button for reconnecting serial bus""" def __init__(self, platform: str, gateway: EnOceanGateway): - super().__init__(platform, gateway, gateway.base_id, gateway.dev_name, None) self.entity_description = ButtonEntityDescription( key="gateway_" + str(gateway.dev_id) + "Serial Reconnection", name="Reconnect Gateway "+str(gateway.dev_id), icon="mdi:button-pointer", device_class=ButtonDeviceClass.UPDATE, - has_entity_name= True, ) - self._attr_unique_id = f"{self.identifier}_{self.entity_description.key}" + + super().__init__(platform, gateway, gateway.base_id, gateway.dev_name, None) @property def device_info(self) -> DeviceInfo: diff --git a/custom_components/eltako/climate.py b/custom_components/eltako/climate.py index 05cb026c..0f426080 100644 --- a/custom_components/eltako/climate.py +++ b/custom_components/eltako/climate.py @@ -17,10 +17,9 @@ ClimateEntityFeature ) from homeassistant import config_entries -from homeassistant.const import CONF_ID, CONF_NAME, Platform, TEMP_CELSIUS, CONF_TEMPERATURE_UNIT, Platform +from homeassistant.const import Platform, CONF_TEMPERATURE_UNIT, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.typing import ConfigType from .gateway import EnOceanGateway @@ -83,13 +82,13 @@ async def async_setup_entry( async_add_entities(entities) -def validate_ids_of_climate(entities:[EltakoEntity]): +def validate_ids_of_climate(entities:list[EltakoEntity]): for e in entities: e.validate_dev_id() e.validate_sender_id() if hasattr(e, "cooling_sender_id"): e.validate_sender_id(e.cooling_sender_id) -class ClimateController(EltakoEntity, ClimateEntity): +class ClimateController(EltakoEntity, ClimateEntity, RestoreEntity): """Representation of an Eltako heating and cooling actor.""" _update_frequency = 55 # sec @@ -146,6 +145,38 @@ def __init__(self, platform: str, gateway: EnOceanGateway, dev_id: AddressExpres self._update_task = asyncio.ensure_future(self._wrapped_update(), loop=self._loop) + def load_value_initially(self, latest_state:State): + # LOGGER.debug(f"[climate {self.dev_id}] eneity unique_id: {self.unique_id}") + # LOGGER.debug(f"[climate {self.dev_id}] latest state - state: {latest_state.state}") + # LOGGER.debug(f"[climate {self.dev_id}] latest state - attributes: {latest_state.attributes}") + + try: + self.hvac_modes = [] + for m_str in latest_state.attributes.get('hvac_modes', []): + for m_enum in HVACMode: + if m_str == m_enum.value: + self.hvac_modes.append(m_enum) + + self._attr_current_temperature = latest_state.attributes.get('current_temperature', None) + self._attr_target_temperature = latest_state.attributes.get('temperature', None) + + self._attr_hvac_mode = None + for m_enum in HVACMode: + if latest_state.state == m_enum.value: + self._attr_hvac_mode = m_enum + break + + except Exception as e: + self._attr_hvac_mode = None + self._attr_current_temperature = None + self._attr_target_temperature = None + raise e + + self.schedule_update_ha_state() + + LOGGER.debug(f"[climate {self.dev_id}] value initially loaded: [state: {self.state}, modes: [{self.hvac_modes}], current temp: {self.current_temperature}, target temp: {self.target_temperature}]") + + async def _wrapped_update(self, *args) -> None: while True: try: diff --git a/custom_components/eltako/config_helpers.py b/custom_components/eltako/config_helpers.py index 481fb932..71efd391 100644 --- a/custom_components/eltako/config_helpers.py +++ b/custom_components/eltako/config_helpers.py @@ -165,7 +165,7 @@ def get_gateway_name(dev_name:str, dev_type:str, dev_id: int, base_id:AddressExp return f"{dev_name} - {dev_type} (Id: {dev_id}, BaseId: {format_address(base_id)})" def format_address(address: AddressExpression, separator:str='-') -> str: - return b2a(address[0], '-').upper() + return b2a(address[0], separator).upper() def get_device_name(dev_name: str, dev_id: AddressExpression, general_config: dict) -> str: if general_config[CONF_SHOW_DEV_ID_IN_DEV_NAME]: diff --git a/custom_components/eltako/cover.py b/custom_components/eltako/cover.py index 5f6f63fe..e5b034f5 100644 --- a/custom_components/eltako/cover.py +++ b/custom_components/eltako/cover.py @@ -8,7 +8,7 @@ from homeassistant import config_entries from homeassistant.components.cover import CoverEntity, CoverEntityFeature, ATTR_POSITION -from homeassistant.const import CONF_DEVICE_CLASS, Platform +from homeassistant.const import CONF_DEVICE_CLASS, Platform, STATE_OPEN, STATE_OPENING, STATE_CLOSED, STATE_CLOSING from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -54,7 +54,7 @@ async def async_setup_entry( log_entities_to_be_added(entities, platform) async_add_entities(entities) -class EltakoCover(EltakoEntity, CoverEntity): +class EltakoCover(EltakoEntity, CoverEntity, RestoreEntity): """Representation of an Eltako cover device.""" def __init__(self, platform:str, gateway: EnOceanGateway, dev_id: AddressExpression, dev_name: str, dev_eep: EEP, sender_id: AddressExpression, sender_eep: EEP, device_class: str, time_closes, time_opens): @@ -66,8 +66,8 @@ def __init__(self, platform:str, gateway: EnOceanGateway, dev_id: AddressExpress self._attr_device_class = device_class self._attr_is_opening = False self._attr_is_closing = False - self._attr_is_closed = False - self._attr_current_cover_position = 100 + self._attr_is_closed = None # means undefined state + self._attr_current_cover_position = None self._time_closes = time_closes self._time_opens = time_opens @@ -76,6 +76,46 @@ def __init__(self, platform:str, gateway: EnOceanGateway, dev_id: AddressExpress if time_closes is not None and time_opens is not None: self._attr_supported_features |= CoverEntityFeature.SET_POSITION + + def load_value_initially(self, latest_state:State): + # LOGGER.debug(f"[cover {self.dev_id}] latest state: {latest_state.state}") + # LOGGER.debug(f"[cover {self.dev_id}] latest state attributes: {latest_state.attributes}") + try: + if 'unknown' == latest_state.state: + self._attr_current_cover_position = None + else: + self._attr_current_cover_position = latest_state.attributes['current_position'] + + if latest_state.state == STATE_OPEN: + self._attr_is_opening = False + self._attr_is_closing = False + self._attr_is_closed = False + self._attr_current_cover_position = 100 + elif latest_state.state == STATE_CLOSED: + self._attr_is_opening = False + self._attr_is_closing = False + self._attr_is_closed = True + self._attr_current_cover_position = 0 + elif latest_state.state == STATE_CLOSING: + self._attr_is_opening = False + self._attr_is_closing = True + self._attr_is_closed = False + elif latest_state.state == STATE_OPENING: + self._attr_is_opening = True + self._attr_is_closing = False + self._attr_is_closed = False + + except Exception as e: + self._attr_current_cover_position = None + self._attr_is_opening = None + self._attr_is_closing = None + self._attr_is_closed = None # means undefined state + raise e + + self.schedule_update_ha_state() + LOGGER.debug(f"[cover {self.dev_id}] value initially loaded: [is_opening: {self.is_opening}, is_closing: {self.is_closing}, is_closed: {self.is_closed}, current_possition: {self.current_cover_position}, state: {self.state}]") + + def open_cover(self, **kwargs: Any) -> None: """Open the cover.""" if self._time_opens is not None: @@ -91,12 +131,15 @@ def open_cover(self, **kwargs: Any) -> None: #TODO: ... setting state should be comment out # Don't set state instead wait for response from actor so that real state of light is displayed. - self._attr_is_opening = True - self._attr_is_closing = False - + if self.general_settings[CONF_FAST_STATUS_CHANGE]: + self._attr_is_opening = True + self._attr_is_closing = False + self._attr_is_closed = False + self.schedule_update_ha_state() + def close_cover(self, **kwargs: Any) -> None: """Close cover.""" if self._time_closes is not None: @@ -112,12 +155,15 @@ def close_cover(self, **kwargs: Any) -> None: #TODO: ... setting state should be comment out # Don't set state instead wait for response from actor so that real state of light is displayed. - self._attr_is_closing = True - self._attr_is_opening = False if self.general_settings[CONF_FAST_STATUS_CHANGE]: + self._attr_is_closing = True + self._attr_is_opening = False + self._attr_is_closed = False + self.schedule_update_ha_state() + def set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" if self._time_closes is None or self._time_opens is None: @@ -150,14 +196,16 @@ def set_cover_position(self, **kwargs: Any) -> None: msg = H5_3F_7F(time, command, 1).encode_message(address) self.send_message(msg) - if direction == "up": - self._attr_is_opening = True - self._attr_is_closing = False - elif direction == "down": - self._attr_is_closing = True - self._attr_is_opening = False - if self.general_settings[CONF_FAST_STATUS_CHANGE]: + if direction == "up": + self._attr_is_opening = True + self._attr_is_closing = False + self._attr_is_closed = None + elif direction == "down": + self._attr_is_closing = True + self._attr_is_opening = False + self._attr_is_closed = None + self.schedule_update_ha_state() @@ -169,10 +217,11 @@ def stop_cover(self, **kwargs: Any) -> None: msg = H5_3F_7F(0, 0x00, 1).encode_message(address) self.send_message(msg) - self._attr_is_closing = False - self._attr_is_opening = False - if self.general_settings[CONF_FAST_STATUS_CHANGE]: + self._attr_is_closing = False + self._attr_is_opening = False + self._attr_is_closed = None + self.schedule_update_ha_state() @@ -183,11 +232,14 @@ def value_changed(self, msg): except Exception as e: LOGGER.warning("Could not decode message: %s", str(e)) return - + if self.dev_eep in [G5_3F_7F]: + LOGGER.debug(f"[cover {self.dev_id}] G5_3F_7F - {decoded.__dict__}") + if decoded.state == 0x02: # down self._attr_is_closing = True self._attr_is_opening = False + self._attr_is_closed = False elif decoded.state == 0x50: # closed self._attr_is_opening = False self._attr_is_closing = False @@ -203,18 +255,31 @@ def value_changed(self, msg): self._attr_is_closed = False self._attr_current_cover_position = 100 elif decoded.time is not None and decoded.direction is not None and self._time_closes is not None and self._time_opens is not None: + time_in_seconds = decoded.time / 10.0 if decoded.direction == 0x01: # up self._attr_current_cover_position = min(self._attr_current_cover_position + int(time_in_seconds / self._time_opens * 100.0), 100) + self._attr_is_opening = True + self._attr_is_closing = False + self._attr_is_closed = None else: # down self._attr_current_cover_position = max(self._attr_current_cover_position - int(time_in_seconds / self._time_closes * 100.0), 0) + self._attr_is_opening = False + self._attr_is_closing = True + self._attr_is_closed = None - if self._attr_current_cover_position == 0: - self._attr_is_closed = True + if self._attr_current_cover_position == 0: + self._attr_is_closed = True + self._attr_is_opening = False + self._attr_is_closing = False + elif self._attr_current_cover_position == 100: + self._attr_is_closed = False + self._attr_is_opening = False + self._attr_is_closing = False - self._attr_is_closing = False - self._attr_is_opening = False + LOGGER.debug(f"[cover {self.dev_id}] state: {self.state}, opening: {self.is_opening}, closing: {self.is_closing}, closed: {self.is_closed}, position: {self.current_cover_position}") + self.schedule_update_ha_state() diff --git a/custom_components/eltako/datetime.py b/custom_components/eltako/datetime.py index 15f7110d..b32d81ec 100644 --- a/custom_components/eltako/datetime.py +++ b/custom_components/eltako/datetime.py @@ -1,15 +1,7 @@ import datetime -from homeassistant.components.datetime import ( - DateTimeEntity -) - -from homeassistant.components.sensor import ( - PLATFORM_SCHEMA, - SensorDeviceClass, - SensorEntity, - SensorEntityDescription, - SensorStateClass, -) + +from homeassistant.components.datetime import DateTimeEntity +from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import Platform from homeassistant import config_entries @@ -47,24 +39,40 @@ async def async_setup_entry( log_entities_to_be_added(entities, platform) async_add_entities(entities) + + class GatewayLastReceivedMessage(EltakoEntity, DateTimeEntity): """Protocols last time when message received""" def __init__(self, platform: str, gateway: EnOceanGateway): - super().__init__(platform, gateway, gateway.base_id, gateway.dev_name, None) self.entity_description = EntityDescription( key="Last Message Received", name="Last Message Received", icon="mdi:button-cursor", device_class=SensorDeviceClass.DATE, - has_entity_name= True, ) - self._attr_unique_id = f"{self.identifier}_{self.entity_description.key}" self.gateway.set_last_message_received_handler(self.set_value) + super().__init__(platform, gateway, gateway.base_id, gateway.dev_name, None) + + def load_value_initially(self, latest_state:State): + try: + if 'unknown' == latest_state.state: + self._attr_native_value = None + else: + # e.g.: 2024-02-12T23:32:44+00:00 + self._attr_native_value = datetime.strptime(latest_state.state, '%Y-%m-%dT%H:%M:%S%z:%f') + + except Exception as e: + self._attr_native_value = None + raise e + + self.schedule_update_ha_state() + LOGGER.debug(f"[datetime {self.dev_id}] value initially loaded: [native_value: {self.native_value}, state: {self.state}]") + @property def device_info(self) -> DeviceInfo: - """Return the device info.""" + """Return the device info.""" return DeviceInfo( identifiers={(DOMAIN, self.gateway.serial_path)}, name= self.gateway.dev_name, diff --git a/custom_components/eltako/device.py b/custom_components/eltako/device.py index 51bc03ff..559fa3c6 100644 --- a/custom_components/eltako/device.py +++ b/custom_components/eltako/device.py @@ -1,16 +1,17 @@ """Representation of an Eltako device.""" +from datetime import datetime + from eltakobus.message import ESP2Message, EltakoWrappedRPS, EltakoWrapped1BS, EltakoWrapped4BS, RPSMessage, Regular4BSMessage, Regular1BSMessage -from eltakobus.error import ParseError from eltakobus.util import AddressExpression from eltakobus.eep import EEP -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.entity_platform import DATA_ENTITY_PLATFORM from homeassistant.helpers.entity import DeviceInfo -from homeassistant.const import Platform - from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send from homeassistant.helpers.entity import Entity +from homeassistant.const import Platform from .const import * from .gateway import EnOceanGateway @@ -19,10 +20,14 @@ class EltakoEntity(Entity): """Parent class for all entities associated with the Eltako component.""" - _attr_has_entity_name = True - - def __init__(self, platform: str, gateway: EnOceanGateway, dev_id: AddressExpression, dev_name: str="Device", dev_eep: EEP=None): + + + def __init__(self, platform: str, gateway: EnOceanGateway, dev_id: AddressExpression, dev_name: str="Device", dev_eep: EEP=None, description_key:str=None): """Initialize the device.""" + self._attr_has_entity_name = True + self._attr_should_poll = True + + self._attr_ha_platform = platform self._attr_gateway = gateway self.hass = self.gateway.hass self.general_settings = self.gateway.general_settings @@ -31,14 +36,28 @@ def __init__(self, platform: str, gateway: EnOceanGateway, dev_id: AddressExpres self._attr_dev_eep = dev_eep self.listen_to_addresses = [] self.listen_to_addresses.append(self.dev_id[0]) - self._attr_identifier = self._get_identifier(self.gateway, self.dev_id) - self._attr_unique_id = self.identifier - self._attr_platform = platform - self.entity_id = f"{platform}.{self.unique_id}" + self.description_key = description_key + self._attr_unique_id = EltakoEntity._get_identifier(self.gateway, self.dev_id, self._get_description_key()) + self.entity_id = f"{self._attr_ha_platform}.{self._attr_unique_id}" @classmethod - def _get_identifier(cls, gateway: EnOceanGateway, dev_id: AddressExpression) -> str: - return f"{DOMAIN}_gw{gateway.dev_id}_{config_helpers.format_address(dev_id)}" + def _get_identifier(cls, gateway: EnOceanGateway, dev_id: AddressExpression, description_key:str=None) -> str: + if description_key is None: + description_key = '' + else: + description_key = '_'+description_key + + return f"{DOMAIN}_gw{gateway.dev_id}_{config_helpers.format_address(dev_id)}{description_key}".replace('-', '_').lower() + + def _get_description_key(self, description_key:str=None): + if description_key is not None: + self.description_key = description_key + + if hasattr(self, 'entity_description') and self.entity_description is not None: + if self.description_key is None: + self.description_key = self.entity_description.key + + return self.description_key @property def device_info(self) -> DeviceInfo: @@ -52,10 +71,46 @@ def device_info(self) -> DeviceInfo: model=self.dev_eep.eep_string, via_device=(DOMAIN, self.gateway.serial_path), ) + + + async def async_added_to_hass(self) -> None: + """Call when entity about to be added to hass.""" + await super().async_added_to_hass() + + # Register callbacks. + event_id = config_helpers.get_bus_event_type(self.gateway.base_id, SIGNAL_RECEIVE_MESSAGE) + self.async_on_remove( + async_dispatcher_connect( + self.hass, event_id, self._message_received_callback + ) + ) + + # load initial value + if isinstance(self, RestoreEntity): + + # check if value is not set + is_value_available = getattr(self, '_attr_native_value', None) + if is_value_available is None: + is_value_available = getattr(self, '_attr_is_on', None) + + # update values + if is_value_available is None: + latest_state:State = await self.async_get_last_state() + if latest_state is not None: + self.load_value_initially(latest_state) + + + def load_value_initially(self, latest_state:State): + """This function is implemented in the concrete devices classes""" + LOGGER.warn(f"[{self._attr_ha_platform} {self.dev_id}] DOES NOT HAVE AN IMPLEMENTATION FOR: load_value_initially()") + LOGGER.debug(f"[{self._attr_ha_platform} {self.dev_id}] latest state - state: {latest_state.state}") + LOGGER.debug(f"[{self._attr_ha_platform} {self.dev_id}] latest state - attributes: {latest_state.attributes}") + def validate_dev_id(self) -> bool: return self.gateway.validate_dev_id(self.dev_id, self.dev_name) + def validate_sender_id(self, sender_id=None) -> bool: if sender_id is None: @@ -66,15 +121,6 @@ def validate_sender_id(self, sender_id=None) -> bool: return self.gateway.validate_sender_id(self.sender_id, self.dev_name) return True - async def async_added_to_hass(self): - """Register callbacks.""" - event_id = config_helpers.get_bus_event_type(self.gateway.base_id, SIGNAL_RECEIVE_MESSAGE) - self.async_on_remove( - async_dispatcher_connect( - self.hass, event_id, self._message_received_callback - ) - ) - @property def dev_name(self) -> str: """Return the name of device.""" @@ -100,28 +146,20 @@ def dev_id(self) -> AddressExpression: """Return the id of device.""" return self._attr_dev_id + # @property + # def identifier(self) -> str: + # """Return the identifier of device.""" + # return EltakoEntity._get_identifier(self.gateway, self.dev_id, self.description_key) + @property - def identifier(self) -> str: - """Return the identifier of device.""" - description_key = "" - if hasattr(self, 'entity_description') and hasattr(self.entity_description, 'key'): - description_key = f"_{self.entity_description.key}" - return self._attr_identifier + description_key + def unique_id(self) -> str: + """Return the unique id of device""" + return EltakoEntity._get_identifier(self.gateway, self.dev_id, self.description_key) def _message_received_callback(self, msg: ESP2Message) -> None: """Handle incoming messages.""" msg_types = [EltakoWrappedRPS, EltakoWrapped1BS, EltakoWrapped4BS, RPSMessage, Regular1BSMessage, Regular4BSMessage] - # for mt in msg_types: - # try: - # msg = mt.parse(msg.serialize()) - # except ParseError as pe: - # if 'bin test' in self.dev_name: - # LOGGER.error(pe) - # else: - # if msg.address in self.listen_to_addresses: - # self.value_changed(msg) - # return if type(msg) in msg_types: if msg.address in self.listen_to_addresses: @@ -137,13 +175,13 @@ def send_message(self, msg: ESP2Message): dispatcher_send(self.hass, event_id, msg) -def validate_actuators_dev_and_sender_id(entities:[EltakoEntity]): +def validate_actuators_dev_and_sender_id(entities:list[EltakoEntity]): """Only call it for actuators.""" for e in entities: e.validate_dev_id() e.validate_sender_id() -def log_entities_to_be_added(entities:[EltakoEntity], platform:Platform) -> None: +def log_entities_to_be_added(entities:list[EltakoEntity], platform:Platform) -> None: for e in entities: temp_eep = "" if e.dev_eep: diff --git a/custom_components/eltako/eltako_integration_init.py b/custom_components/eltako/eltako_integration_init.py index 350f035b..ca4325fd 100644 --- a/custom_components/eltako/eltako_integration_init.py +++ b/custom_components/eltako/eltako_integration_init.py @@ -2,7 +2,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.reload import async_reload_integration_platforms from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er, device_registry as dr, entity_platform as pl from .const import * from .schema import CONFIG_SCHEMA @@ -45,6 +47,7 @@ def migrate_old_gateway_descriptions(hass: HomeAssistant): for key in migration_dict: hass.data[DATA_ELTAKO][key] = migration_dict[key] + def get_gateway_from_hass(hass: HomeAssistant, config_entry: ConfigEntry) -> EnOceanGateway: # Migrage existing gateway configs / ESP2 was removed in the name @@ -52,6 +55,7 @@ def get_gateway_from_hass(hass: HomeAssistant, config_entry: ConfigEntry) -> EnO return hass.data[DATA_ELTAKO][config_entry.data[CONF_GATEWAY_DESCRIPTION]] + def set_gateway_to_hass(hass: HomeAssistant, gateway_enity: EnOceanGateway) -> None: # Migrage existing gateway configs / ESP2 was removed in the name @@ -62,6 +66,7 @@ def set_gateway_to_hass(hass: HomeAssistant, gateway_enity: EnOceanGateway) -> N def get_device_config_for_gateway(hass: HomeAssistant, config_entry: ConfigEntry, gateway: EnOceanGateway) -> ConfigType: return config_helpers.get_device_config(hass.data[DATA_ELTAKO][ELTAKO_CONFIG], gateway.dev_id) + async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up an Eltako gateway for the given entry.""" LOGGER.info(f"[{LOG_PREFIX}] Start gateway setup.") @@ -125,7 +130,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b await usb_gateway.async_setup() set_gateway_to_hass(hass, usb_gateway) - + hass.data[DATA_ELTAKO][DATA_ENTITIES] = {} for platform in PLATFORMS: hass.async_create_task( diff --git a/custom_components/eltako/gateway.py b/custom_components/eltako/gateway.py index b21470ac..42758dfd 100644 --- a/custom_components/eltako/gateway.py +++ b/custom_components/eltako/gateway.py @@ -123,17 +123,13 @@ def _init_bus(self): self._bus.set_status_changed_handler(self._fire_connection_state_changed_event) - # def get_device_info(self) -> DeviceInfo: - # """Return the device info.""" - # device_registry = dr.async_get(self.hass) - # return device_registry.async_get(self.config_entry_id) def _register_device(self) -> None: device_registry = dr.async_get(self.hass) device_registry.async_get_or_create( config_entry_id=self.config_entry_id, identifiers={(DOMAIN, self.serial_path)}, - connections={(CONF_MAC, config_helpers.format_address(self.base_id))}, + # connections={(CONF_MAC, config_helpers.format_address(self.base_id))}, manufacturer=MANUFACTURER, name= self.dev_name, model=self.model, diff --git a/custom_components/eltako/light.py b/custom_components/eltako/light.py index 4d496d5c..6dcea986 100644 --- a/custom_components/eltako/light.py +++ b/custom_components/eltako/light.py @@ -59,7 +59,31 @@ async def async_setup_entry( async_add_entities(entities) -class EltakoDimmableLight(EltakoEntity, LightEntity): +class AbstractLightEntity(EltakoEntity, LightEntity, RestoreEntity): + + def load_value_initially(self, latest_state:State): + # LOGGER.debug(f"[{self._attr_ha_platform} {self.dev_id}] latest state - state: {latest_state.state}") + # LOGGER.debug(f"[{self._attr_ha_platform} {self.dev_id}] latest state - attributes: {latest_state.attributes}") + try: + if 'unknown' == latest_state.state: + self._attr_is_on = None + else: + if latest_state.state in ['on', 'off']: + self._attr_is_on = 'on' == latest_state.state + else: + self._attr_is_on = None + + self._attr_brightness = latest_state.attributes.get('brightness', None) + + except Exception as e: + self._attr_is_on = None + raise e + + self.schedule_update_ha_state() + + LOGGER.debug(f"[light {self.dev_id}] value initially loaded: [is_on: {self.is_on}, brightness: {self.brightness}, state: {self.state}]") + +class EltakoDimmableLight(AbstractLightEntity): """Representation of an Eltako light source.""" _attr_color_mode = ColorMode.BRIGHTNESS @@ -68,29 +92,24 @@ class EltakoDimmableLight(EltakoEntity, LightEntity): def __init__(self, platform:str, gateway: EnOceanGateway, dev_id: AddressExpression, dev_name: str, dev_eep: EEP, sender_id: AddressExpression, sender_eep: EEP): """Initialize the Eltako light source.""" super().__init__(platform, gateway, dev_id, dev_name, dev_eep) - self._on_state = False - self._attr_brightness = 50 self._sender_id = sender_id self._sender_eep = sender_eep - @property - def is_on(self): - """If light is on.""" - return self._on_state def turn_on(self, **kwargs: Any) -> None: """Turn the light source on or sets a specific dimmer value.""" - self._attr_brightness = kwargs.get(ATTR_BRIGHTNESS, 255) + brightness = kwargs.get(ATTR_BRIGHTNESS, 255) address, _ = self._sender_id if self._sender_eep == A5_38_08: - dimming = CentralCommandDimming(int(self.brightness / 255.0 * 100.0), 0, 1, 0, 0, 1) + dimming = CentralCommandDimming(int(brightness / 255.0 * 100.0), 0, 1, 0, 0, 1) msg = A5_38_08(command=0x02, dimming=dimming).encode_message(address) self.send_message(msg) if self.general_settings[CONF_FAST_STATUS_CHANGE]: - self._on_state = True + self._attr_brightness = brightness + self._attr_is_on = True self.schedule_update_ha_state() @@ -105,7 +124,7 @@ def turn_off(self, **kwargs: Any) -> None: if self.general_settings[CONF_FAST_STATUS_CHANGE]: self._attr_brightness = 0 - self._on_state = False + self._attr_is_on = False self.schedule_update_ha_state() @@ -117,7 +136,7 @@ def value_changed(self, msg): """ try: if msg.org == 0x07: - decoded = self.dev_eep.decode_message(msg) + decoded:A5_38_08 = self.dev_eep.decode_message(msg) elif msg.org == 0x05: LOGGER.debug("[Dimmable Light] Ignore on/off message with org=0x05") return @@ -131,7 +150,7 @@ def value_changed(self, msg): if decoded.switching.learn_button != 1: return - self._on_state = decoded.switching.switching_command + self._attr_is_on = decoded.switching.switching_command elif decoded.command == 0x02: if decoded.dimming.learn_button != 1: return @@ -141,14 +160,14 @@ def value_changed(self, msg): elif decoded.dimming.dimming_range == 1: self._attr_brightness = decoded.dimming.dimming_value - self._on_state = decoded.dimming.switching_command + self._attr_is_on = decoded.dimming.switching_command else: return self.schedule_update_ha_state() -class EltakoSwitchableLight(EltakoEntity, LightEntity): +class EltakoSwitchableLight(AbstractLightEntity): """Representation of an Eltako light source.""" _attr_color_mode = ColorMode.ONOFF @@ -157,14 +176,9 @@ class EltakoSwitchableLight(EltakoEntity, LightEntity): def __init__(self, platform: str, gateway: EnOceanGateway, dev_id: AddressExpression, dev_name: str, dev_eep: EEP, sender_id: AddressExpression, sender_eep: EEP): """Initialize the Eltako light source.""" super().__init__(platform, gateway, dev_id, dev_name, dev_eep) - self._on_state = False self._sender_id = sender_id self._sender_eep = sender_eep - @property - def is_on(self): - """If light is on.""" - return self._on_state def turn_on(self, **kwargs: Any) -> None: """Turn the light source on or sets a specific dimmer value.""" @@ -176,7 +190,7 @@ def turn_on(self, **kwargs: Any) -> None: self.send_message(msg) if self.general_settings[CONF_FAST_STATUS_CHANGE]: - self._on_state = True + self._attr_is_on = True self.schedule_update_ha_state() @@ -190,7 +204,7 @@ def turn_off(self, **kwargs: Any) -> None: self.send_message(msg) if self.general_settings[CONF_FAST_STATUS_CHANGE]: - self._on_state = False + self._attr_is_on = False self.schedule_update_ha_state() @@ -203,5 +217,5 @@ def value_changed(self, msg): return if self.dev_eep in [M5_38_08]: - self._on_state = decoded.state + self._attr_is_on = decoded.state == True self.schedule_update_ha_state() diff --git a/custom_components/eltako/sensor.py b/custom_components/eltako/sensor.py index ae7e8c05..2e595eeb 100644 --- a/custom_components/eltako/sensor.py +++ b/custom_components/eltako/sensor.py @@ -6,23 +6,18 @@ from eltakobus.util import AddressExpression, b2s from eltakobus.eep import * -from eltakobus.message import ESP2Message, Regular4BSMessage +from eltakobus.message import ESP2Message -from decimal import Decimal, InvalidOperation as DecimalInvalidOperation from . import config_helpers from homeassistant.components.sensor import ( - PLATFORM_SCHEMA, SensorDeviceClass, SensorEntity, SensorEntityDescription, SensorStateClass, ) from homeassistant.const import ( - CONF_DEVICE_CLASS, - CONF_ID, - CONF_NAME, PERCENTAGE, STATE_CLOSED, STATE_OPEN, @@ -35,8 +30,6 @@ UnitOfVolumeFlowRate, Platform, PERCENTAGE, - CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - CONCENTRATION_PARTS_PER_BILLION, CONF_LANGUAGE, UnitOfElectricPotential, ) @@ -409,28 +402,46 @@ def __init__(self, platform: str, gateway: EnOceanGateway, self._attr_state_class = description.state_class super().__init__(platform, gateway, dev_id, dev_name, dev_eep) - #self._attr_unique_id = f"{self.identifier}_{description.key}" - # self.entity_id = f"{platform}.{self.unique_id}_{description.key}" self._attr_native_value = None @property def name(self): """Return the default name for the sensor.""" return self.entity_description.name - - async def async_added_to_hass(self) -> None: - """Call when entity about to be added to hass.""" - # If not None, we got an initial value. - await super().async_added_to_hass() - if self._attr_native_value is not None: - return - - if (state := await self.async_get_last_state()) is not None: - self._attr_native_value = state.state - def value_changed(self, msg): - """Update the internal state of the sensor.""" + def load_value_initially(self, latest_state:State): + LOGGER.debug(f"[{self._attr_ha_platform} {self.dev_id}] eneity unique_id: {self.unique_id}") + LOGGER.debug(f"[{self._attr_ha_platform} {self.dev_id}] latest state - state: {latest_state.state}") + LOGGER.debug(f"[{self._attr_ha_platform} {self.dev_id}] latest state - attributes: {latest_state.attributes}") + try: + if 'unknown' == latest_state.state: + self._attr_is_on = None + else: + if latest_state.attributes.get('state_class', None) == 'measurement': + if latest_state.state.count('.') + latest_state.state.count(',') == 1: + self._attr_native_value = float(latest_state.state) + elif latest_state.state.count('.') == 0 and latest_state.state.count(',') == 0: + self._attr_native_value = int(latest_state.state) + else: + self._attr_native_value = None + + elif latest_state.attributes.get('state_class', None) == 'total_increasing': + self._attr_native_value = int(latest_state.state) + + elif latest_state.attributes.get('device_class', None) == 'device_class': + # e.g.: 2024-02-12T23:32:44+00:00 + self._attr_native_value = datetime.strptime(latest_state.state, '%Y-%m-%dT%H:%M:%S%z:%f') + + except Exception as e: + if hasattr(self, '_attr_is_on'): + self._attr_is_on = None + elif hasattr(self, '_attr_native_value'): + self._attr_native_value = None + raise e + + self.schedule_update_ha_state() + LOGGER.debug(f"[{self._attr_ha_platform} {self.dev_id} ({type(self).__name__})] value initially loaded: [native_value: {self.native_value}, state: {self.state}]") class EltakoPirSensor(EltakoSensor): """Occupancy Sensor""" @@ -636,7 +647,6 @@ def __init__(self, platform: str, gateway: EnOceanGateway, dev_id: AddressExpres _dev_name = DEFAULT_DEVICE_NAME_THERMOMETER super().__init__(platform, gateway, dev_id, _dev_name, dev_eep, description) - def value_changed(self, msg: ESP2Message): """Update the internal state of the sensor.""" try: @@ -767,9 +777,7 @@ def __init__(self, platform: str, gateway: EnOceanGateway): has_entity_name= True, ) ) - self.has_entity_name = True self._attr_name = "Last Message Received" - self._attr_unique_id = f"{self.identifier}_{self.entity_description.key}" self.gateway.set_last_message_received_handler(self.async_value_changed) @property @@ -810,16 +818,14 @@ def __init__(self, platform: str, gateway: EnOceanGateway): key="Received Messages per Session", name="Received Messages per Session", state_class=SensorStateClass.TOTAL_INCREASING, - device_class=SensorDeviceClass.VOLUME, - native_unit_of_measurement="Messages", - unit_of_measurement="", - has_entity_name= True, + # device_class=SensorDeviceClass.VOLUME, + # native_unit_of_measurement="Messages", # => raises error message + unit_of_measurement="Messages", + suggested_unit_of_measurement="Messages", icon="mdi:chart-line", ) ) - self.has_entity_name = True - self._attr_name="Received Messages per Session", - self._attr_unique_id = f"{self.identifier}_{self.entity_description.key}" + self._attr_name="Received Messages per Session" self.gateway.set_received_message_count_handler(self.async_value_changed) @property @@ -863,10 +869,8 @@ def __init__(self, platform: str, gateway: EnOceanGateway, dev_id: AddressExpres has_entity_name= True, ) ) - self.has_entity_name = True self._attr_name = key self._attr_native_value = value - self._attr_unique_id = f"{self.identifier}_{self.entity_description.key}" def value_changed(self, value) -> None: pass @@ -912,10 +916,8 @@ def __init__(self, platform: str, gateway: EnOceanGateway, dev_id: AddressExpres ) ) self.convert_event_function = convert_event_function - self.has_entity_name = True self._attr_name = key self._attr_native_value = '' - self._attr_unique_id = f"{self.identifier}_{self.entity_description.key}" self.listen_to_addresses.clear() LOGGER.debug(f"[{platform}] [{EventListenerInfoField.__name__}] [{b2s(dev_id[0])}] [{key}] Register event: {event_id}") diff --git a/custom_components/eltako/switch.py b/custom_components/eltako/switch.py index 52f39dba..de9d248b 100644 --- a/custom_components/eltako/switch.py +++ b/custom_components/eltako/switch.py @@ -7,13 +7,11 @@ from eltakobus.eep import * from homeassistant import config_entries -from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchEntity -from homeassistant.const import CONF_ID, CONF_NAME, Platform +from homeassistant.components.switch import SwitchEntity +from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType from . import config_helpers, get_gateway_from_hass, get_device_config_for_gateway from .config_helpers import DeviceConf @@ -52,7 +50,7 @@ async def async_setup_entry( async_add_entities(entities) -class EltakoSwitch(EltakoEntity, SwitchEntity): +class EltakoSwitch(EltakoEntity, SwitchEntity, RestoreEntity): """Representation of an Eltako switch device.""" def __init__(self, platform:str, gateway: EnOceanGateway, dev_id: AddressExpression, dev_name: str, dev_eep: EEP, sender_id: AddressExpression, sender_eep: EEP): @@ -60,12 +58,24 @@ def __init__(self, platform:str, gateway: EnOceanGateway, dev_id: AddressExpress super().__init__(platform, gateway, dev_id, dev_name, dev_eep) self._sender_id = sender_id self._sender_eep = sender_eep - self._on_state = False - @property - def is_on(self): - """Return whether the switch is on or off.""" - return self._on_state + def load_value_initially(self, latest_state:State): + try: + if 'unknown' == latest_state.state: + self._attr_is_on = None + else: + if latest_state.state in ['on', 'off']: + self._attr_is_on = 'on' == latest_state.state + else: + self._attr_is_on = None + + except Exception as e: + self._attr_is_on = None + raise e + + self.schedule_update_ha_state() + + LOGGER.debug(f"[switch {self.dev_id}] value initially loaded: [is_on: {self.is_on}, state: {self.state}]") def turn_on(self, **kwargs: Any) -> None: """Turn on the switch.""" @@ -91,7 +101,7 @@ def turn_on(self, **kwargs: Any) -> None: self.send_message(msg) if self.general_settings[CONF_FAST_STATUS_CHANGE]: - self._on_state = True + self._attr_is_on = True self.schedule_update_ha_state() @@ -119,7 +129,7 @@ def turn_off(self, **kwargs: Any) -> None: self.send_message(msg) if self.general_settings[CONF_FAST_STATUS_CHANGE]: - self._on_state = False + self._attr_is_on = False self.schedule_update_ha_state() @@ -132,7 +142,7 @@ def value_changed(self, msg: ESP2Message): return if self.dev_eep in [M5_38_08]: - self._on_state = decoded.state + self._attr_is_on = decoded.state self.schedule_update_ha_state() elif self.dev_eep in [F6_02_01, F6_02_02]: @@ -143,5 +153,5 @@ def value_changed(self, msg: ESP2Message): button_filter |= self.dev_id[1] is not None and self.dev_id[1] == 'right' and decoded.rocker_first_action == 3 if button_filter and decoded.energy_bow: - self._on_state = not self._on_state + self._attr_is_on = not self._attr_is_on self.schedule_update_ha_state() diff --git a/tests/mocks.py b/tests/mocks.py index 88584e95..09ce35f2 100644 --- a/tests/mocks.py +++ b/tests/mocks.py @@ -1,6 +1,7 @@ from typing import Any from custom_components.eltako.config_helpers import * +from custom_components.eltako.gateway import EnOceanGateway class BusMock(): def __init__(self): @@ -24,11 +25,25 @@ class HassMock(): def __init__(self) -> None: self.bus = BusMock() + +class ConfigEntryMock(): + + def __init__(self): + self.entry_id = "entity_id" -class GatewayMock(): +class GatewayMock(EnOceanGateway): def __init__(self, general_settings:dict=DEFAULT_GENERAL_SETTINGS, dev_id: int=123, base_id:AddressExpression=AddressExpression.parse('FF-AA-80-00')): - self.hass = HassMock() - self.general_settings = general_settings - self.base_id = base_id - self.dev_id = dev_id + hass = HassMock() + gw_type = GatewayDeviceType.GatewayEltakoFAM14 + + super().__init__(general_settings, hass, dev_id, gw_type, 'SERIAL_PATH', 56700, base_id, "MyFAM14", ConfigEntryMock()) + + def set_status_changed_handler(self): + pass + +class LatestStateMock(): + def __init__(self, state:str=None, attributes:dict[str:str]={}): + self.state = state + self.attributes = attributes + diff --git a/tests/test_binary_sensor.py b/tests/test_binary_sensor.py index de9a7394..d5afef82 100644 --- a/tests/test_binary_sensor.py +++ b/tests/test_binary_sensor.py @@ -122,3 +122,30 @@ def test_occupancy_sensor(self): msg.data = b'\x00\x96\x0A\x09' bs.value_changed(msg) self.assertEqual(bs._attr_is_on, False) + + def test_initial_loading_on(self): + bs = self.create_binary_sensor() + bs._attr_is_on = None + + bs.load_value_initially(LatestStateMock('on')) + self.assertTrue(bs._attr_is_on) + self.assertTrue(bs.is_on) + self.assertEquals(bs.state, 'on') + + def test_initial_loading_off(self): + bs = self.create_binary_sensor() + bs._attr_is_on = None + + bs.load_value_initially(LatestStateMock('off')) + self.assertFalse(bs._attr_is_on) + self.assertFalse(bs.is_on) + self.assertEquals(bs.state, 'off') + + def test_initial_loading_None(self): + bs = self.create_binary_sensor() + bs._attr_is_on = True + + bs.load_value_initially(LatestStateMock('bla')) + self.assertIsNone(bs._attr_is_on) + self.assertIsNone(bs.is_on) + self.assertIsNone(bs.state) \ No newline at end of file diff --git a/tests/test_climate.py b/tests/test_climate.py index 754d3ba9..7a904aae 100644 --- a/tests/test_climate.py +++ b/tests/test_climate.py @@ -12,6 +12,7 @@ # mock update of Home Assistant Entity.schedule_update_ha_state = mock.Mock(return_value=None) +ClimateController.schedule_update_ha_state = mock.Mock(return_value=None) EltakoEntity.send_message = mock.Mock(return_value=None) # EltakoBinarySensor.hass.bus.fire is mocked by class HassMock @@ -19,9 +20,8 @@ class EventDataMock(): def __init__(self,d): self.data = d -def create_climate_entity(thermostat:DeviceConf=None, cooling_switch:DeviceConf=None): - gw = GatewayMock() - gw.dev_id = 12345 +def create_climate_entity(thermostat:DeviceConf=None, cooling_switch:DeviceConf=None): + gw = GatewayMock(dev_id=12345) dev_id = AddressExpression.parse("00-00-00-01") # heating cooling actuator dev_name = "Room 1" dev_eep = A5_10_06 @@ -38,8 +38,8 @@ class TestClimate(unittest.TestCase): def test_climate_temp_actuator(self): cc = create_climate_entity() - self.assertEquals(cc.identifier, 'eltako_gw12345_00-00-00-01') - self.assertEquals(cc.entity_id, 'climate.eltako_gw12345_00-00-00-01') + self.assertEquals(cc.unique_id, 'eltako_gw12345_00_00_00_01') + self.assertEquals(cc.entity_id, 'climate.eltako_gw12345_00_00_00_01') self.assertEquals(cc.dev_name, 'Room 1') self.assertEquals(cc.temperature_unit, '°C') self.assertEquals(cc.cooling_sender, None) @@ -66,8 +66,8 @@ def test_climate_thermostat(self): CONF_EEP: 'A5-10-06', }) cc = create_climate_entity(thermostat) - self.assertEquals(cc.identifier, 'eltako_gw12345_00-00-00-01') - self.assertEquals(cc.entity_id, 'climate.eltako_gw12345_00-00-00-01') + self.assertEquals(cc.unique_id, 'eltako_gw12345_00_00_00_01') + self.assertEquals(cc.entity_id, 'climate.eltako_gw12345_00_00_00_01') self.assertEquals(cc.dev_name, 'Room 1') self.assertEquals(cc.temperature_unit, '°C') self.assertEquals(cc.cooling_sender, None) @@ -96,8 +96,8 @@ def test_climate_cooling_switch(self): CONF_EEP: 'A5-10-06', }) cc = create_climate_entity(cooling_switch=cooling_switch) - self.assertEquals(cc.identifier, 'eltako_gw12345_00-00-00-01') - self.assertEquals(cc.entity_id, 'climate.eltako_gw12345_00-00-00-01') + self.assertEquals(cc.unique_id, 'eltako_gw12345_00_00_00_01') + self.assertEquals(cc.entity_id, 'climate.eltako_gw12345_00_00_00_01') self.assertEquals(cc.dev_name, 'Room 1') self.assertEquals(cc.temperature_unit, '°C') self.assertEquals(cc.cooling_sender, None) @@ -116,6 +116,31 @@ def test_climate_cooling_switch(self): # self.assertEquals(cc._actuator_mode, A5_10_06.Heater_Mode.NORMAL); # self.assertEquals( round(cc.current_temperature), current_temperature) # self.assertEquals( round(cc.target_temperature), target_temp) + + + def test_initial_loading(self): + cc = create_climate_entity() + + cc.load_value_initially(LatestStateMock('heat', + attributes={'hvac_modes': ['heat', 'off'], + 'min_temp': 17, + 'max_temp': 25, + 'current_temperature': 19.8, + 'temperature': 22.5, + 'friendly_name': 'Bad Room', + 'supported_features': 385})) + self.assertEqual(cc.current_temperature, 19.8) + self.assertEqual(cc.target_temperature, 22.5) + self.assertEqual(cc.state, 'heat') + + + def test_initial_loading_None(self): + cc = create_climate_entity() + + cc.load_value_initially(LatestStateMock(None)) + self.assertEqual(cc.current_temperature, None) + self.assertEqual(cc.target_temperature, None) + self.assertEqual(cc.state, None) class TestClimateAsync(unittest.IsolatedAsyncioTestCase): @@ -126,8 +151,8 @@ async def test_climate_cooling_switch(self): CONF_SWITCH_BUTTON: 0x50 }) cc = create_climate_entity(cooling_switch=cooling_switch) - self.assertEquals(cc.identifier, 'eltako_gw12345_00-00-00-01') - self.assertEquals(cc.entity_id, 'climate.eltako_gw12345_00-00-00-01') + self.assertEquals(cc.unique_id, 'eltako_gw12345_00_00_00_01') + self.assertEquals(cc.entity_id, 'climate.eltako_gw12345_00_00_00_01') self.assertEquals(cc.dev_name, 'Room 1') self.assertEquals(cc.temperature_unit, '°C') self.assertEquals(cc.cooling_sender, None) @@ -143,5 +168,4 @@ async def test_climate_cooling_switch(self): msg = F6_02_01(3, 1, 0, 0).encode_message(b'\xFF\xFF\xFF\x01') # cc.value_changed(msg) await cc.async_handle_event(EventDataMock({'switch_address': cooling_switch.id, 'data': cooling_switch[CONF_SWITCH_BUTTON]})) - self.assertEquals(cc.hvac_mode, HVACMode.COOL) - + self.assertEquals(cc.hvac_mode, HVACMode.COOL) \ No newline at end of file diff --git a/tests/test_cover.py b/tests/test_cover.py index 98874176..66fddd9b 100644 --- a/tests/test_cover.py +++ b/tests/test_cover.py @@ -19,7 +19,9 @@ def mock_send_message(self, msg): self.last_sent_command = msg def create_cover(self) -> EltakoCover: - gateway = GatewayMock() + settings = DEFAULT_GENERAL_SETTINGS + settings[CONF_FAST_STATUS_CHANGE] = True + gateway = GatewayMock(settings) dev_id = AddressExpression.parse('00-00-00-01') dev_name = 'device name' device_class = "shutter" @@ -36,15 +38,10 @@ def create_cover(self) -> EltakoCover: ec = EltakoCover(Platform.COVER, gateway, dev_id, dev_name, dev_eep, sender_id, sender_eep, device_class, time_closes, time_opens) ec.send_message = self.mock_send_message - ec._attr_is_closing = False - ec._attr_is_opening = False - self._attr_is_closed = False - self._attr_current_cover_position = 100 - self.assertEqual(ec._attr_is_closing, False) self.assertEqual(ec._attr_is_opening, False) - self.assertEqual(ec._attr_is_closed, False) - self.assertEqual(ec._attr_current_cover_position, 100) + self.assertEqual(ec._attr_is_closed, None) + self.assertEqual(ec._attr_current_cover_position, None) return ec @@ -148,4 +145,58 @@ def test_set_cover_position(self): ec._attr_current_cover_position = 100 ec.set_cover_position(position=100) self.assertEqual(self.last_sent_command, None) - self.last_sent_command = None \ No newline at end of file + self.last_sent_command = None + + + + def test_initial_loading_opening(self): + ec = self.create_cover() + ec._attr_is_closed = None + self.assertEqual(ec.is_closed, None) + self.assertEqual(ec.state, None) + + ec.load_value_initially(LatestStateMock('opening', {'current_position': 55})) + self.assertEqual(ec.is_closed, False) + self.assertEqual(ec.is_opening, True) + self.assertEqual(ec.is_closing, False) + self.assertEqual(ec.state, 'opening') + self.assertEqual(ec.current_cover_position, 55) + + def test_initial_loading_closing(self): + ec = self.create_cover() + ec._attr_is_closed = None + self.assertEqual(ec.is_closed, None) + self.assertEqual(ec.state, None) + + ec.load_value_initially(LatestStateMock('closing', {'current_position': 33})) + self.assertEqual(ec.is_closed, False) + self.assertEqual(ec.is_opening, False) + self.assertEqual(ec.is_closing, True) + self.assertEqual(ec.state, 'closing') + self.assertEqual(ec.current_cover_position, 33) + + def test_initial_loading_open(self): + ec = self.create_cover() + ec._attr_is_closed = None + self.assertEqual(ec.is_closed, None) + self.assertEqual(ec.state, None) + + ec.load_value_initially(LatestStateMock('open', {'current_position': 100})) + self.assertEqual(ec.is_closed, False) + self.assertEqual(ec.is_opening, False) + self.assertEqual(ec.is_closing, False) + self.assertEqual(ec.state, 'open') + self.assertEqual(ec.current_cover_position, 100) + + def test_initial_loading_closed(self): + ec = self.create_cover() + ec._attr_is_closed = None + self.assertEqual(ec.is_closed, None) + self.assertEqual(ec.state, None) + + ec.load_value_initially(LatestStateMock('closed', {'current_position': 0})) + self.assertEqual(ec.is_closed, True) + self.assertEqual(ec.is_opening, False) + self.assertEqual(ec.is_closing, False) + self.assertEqual(ec.state, 'closed') + self.assertEqual(ec.current_cover_position, 0) \ No newline at end of file diff --git a/tests/test_device_entity.py b/tests/test_device_entity.py new file mode 100644 index 00000000..405577c5 --- /dev/null +++ b/tests/test_device_entity.py @@ -0,0 +1,40 @@ +import unittest +import os +from mocks import * +from unittest import mock, IsolatedAsyncioTestCase, TestCase +from homeassistant.helpers.entity import Entity +from homeassistant.const import Platform +from custom_components.eltako.cover import EltakoCover +from custom_components.eltako.device import EltakoEntity +from eltakobus import * + +from custom_components.eltako import config_helpers +from homeassistant import core + +# mock update of Home Assistant +Entity.schedule_update_ha_state = mock.Mock(return_value=None) +# EltakoEntity.send_message = mock.Mock(return_value=None) + +class TestEntityProperties(unittest.TestCase): + + + def test_entity_properties(self): + pl = Platform.BINARY_SENSOR + gw = GatewayMock() + address = AddressExpression.parse('FE-34-21-01') + name = "Switch" + + ee = EltakoEntity(pl, gw, address, name, F6_02_01) + + self.assertEqual(ee.dev_name, config_helpers.get_device_name(name, address, gw.general_settings)) + + self.assertEqual(len(ee.listen_to_addresses),1) + self.assertEqual(ee.listen_to_addresses[0], b'\xfe4!\x01') + + self.assertEqual(ee.dev_name, 'Switch') + self.assertEqual(ee.unique_id, 'eltako_gw123_fe_34_21_01') + self.assertEqual(ee.entity_id, 'binary_sensor.eltako_gw123_fe_34_21_01') + + self.assertTrue( core.valid_domain(ee._attr_ha_platform) ) + self.assertTrue( core.valid_entity_id(ee.entity_id) ) + self.assertTrue( core.validate_state(ee.state)) diff --git a/tests/test_dimmable_light.py b/tests/test_dimmable_light.py new file mode 100644 index 00000000..0eb35cf8 --- /dev/null +++ b/tests/test_dimmable_light.py @@ -0,0 +1,180 @@ +import unittest +from custom_components.eltako.sensor import * +from unittest import mock +from mocks import * + +from homeassistant.helpers.entity import Entity +from homeassistant.components.light import ATTR_BRIGHTNESS + +from eltakobus import * +from custom_components.eltako.light import EltakoDimmableLight + + +# mock update of Home Assistant +Entity.schedule_update_ha_state = mock.Mock(return_value=None) +# EltakoEntity.send_message = mock.Mock(return_value=None) + +class TestDimmableLight(unittest.TestCase): + + def mock_send_message(self, msg: ESP2Message): + self.last_sent_command = msg + + def create_switchable_light(self) -> EltakoDimmableLight: + settings = DEFAULT_GENERAL_SETTINGS + settings[CONF_FAST_STATUS_CHANGE] = True + gateway = GatewayMock(settings) + dev_id = AddressExpression.parse('00-00-00-01') + dev_name = 'device name' + eep_string = 'A5-38-08' + + sender_id = AddressExpression.parse('00-00-B0-01') + sender_eep_string = 'A5-38-08' + + dev_eep = EEP.find(eep_string) + sender_eep = EEP.find(sender_eep_string) + + light = EltakoDimmableLight(Platform.LIGHT, gateway, dev_id, dev_name, dev_eep, sender_id, sender_eep) + return light + + def test_switchable_light_value_changed(self): + light = self.create_switchable_light() + light._attr_is_on = None + self.assertEquals(light.is_on, None) + self.assertIsNone(light.state) + + # status update message from relay + #8b 05 70 00 00 00 00 00 00 01 30 + on_msg = Regular4BSMessage(address=b'\x00\x00\x00\x01', status=b'\x00', data=b'\x02\x64\x00\x09') + # 8b 05 50 00 00 00 00 00 00 01 30 + off_msg = Regular4BSMessage(address=b'\x00\x00\x00\x01', status=b'\x00', data=b'\x02\x00\x00\x08') + + dimmed_msg = Regular4BSMessage(address=b'\x00\x00\x00\x01', status=b'\x00', data=b'\x02\x2d\x00\x09') + + light.value_changed(on_msg) + self.assertEquals(light.is_on, True) + self.assertEquals(light.brightness, 255) + self.assertEquals(light.state, 'on') + + light.value_changed(on_msg) + self.assertEquals(light.is_on, True) + self.assertEquals(light.brightness, 255) + self.assertEquals(light.state, 'on') + + light.value_changed(off_msg) + self.assertEquals(light.is_on, False) + self.assertEquals(light.brightness, 0) + self.assertEquals(light.state, 'off') + + light.value_changed(off_msg) + self.assertEquals(light.is_on, False) + self.assertEquals(light.brightness, 0) + self.assertEquals(light.state, 'off') + + light.value_changed(on_msg) + self.assertEquals(light.is_on, True) + self.assertEquals(light.brightness, 255) + self.assertEquals(light.state, 'on') + + light.value_changed(dimmed_msg) + self.assertEquals(light.is_on, True) + self.assertEquals(light.brightness, 114) + self.assertEquals(light.state, 'on') + + light._attr_is_on = None + self.assertEquals(light.is_on, None) + self.assertIsNone(light.state) + + + + def test_switchable_light_trun_on(self): + light = self.create_switchable_light() + light.send_message = self.mock_send_message + + # test if command is sent to eltako bus + light.turn_on() + self.assertEqual(light.brightness, 255) + self.assertEqual( + self.last_sent_command.body, + b'k\x07\x02d\x00\t\x00\x00\xb0\x01\x00') + + def test_switchable_light_trun_off(self): + light = self.create_switchable_light() + light.send_message = self.mock_send_message + + # test if command is sent to eltako bus + light.turn_off() + self.assertEqual(light.brightness, 0) + self.assertEqual( + self.last_sent_command.body, + b'k\x07\x02\x00\x00\x08\x00\x00\xb0\x01\x00') + + def test_dim_light(self): + light = self.create_switchable_light() + light.send_message = self.mock_send_message + + light.turn_on(brightness=100) + self.assertEqual(light.brightness, 100) + self.assertEqual( + self.last_sent_command.body, + b"k\x07\x02'\x00\t\x00\x00\xb0\x01\x00") + + + def test_initial_loading_on(self): + sl = self.create_switchable_light() + sl._attr_is_on = None + + sl.load_value_initially(LatestStateMock('on')) + self.assertTrue(sl._attr_is_on) + self.assertTrue(sl.is_on) + self.assertEqual(sl.brightness, None) + self.assertEqual(sl.state, 'on') + + def test_initial_loading_on_with_brightness(self): + sl = self.create_switchable_light() + sl._attr_is_on = None + + sl.load_value_initially(LatestStateMock('on', {'brightness': 100})) + self.assertTrue(sl._attr_is_on) + self.assertTrue(sl.is_on) + self.assertEqual(sl.brightness, 100) + self.assertEqual(sl.state, 'on') + + def test_initial_loading_dimmed(self): + sl = self.create_switchable_light() + sl._attr_is_on = None + + sl.load_value_initially(LatestStateMock('on', {'brightness': 100})) + self.assertTrue(sl._attr_is_on) + self.assertTrue(sl.is_on) + self.assertEqual(sl.brightness, 100) + self.assertEquals(sl.state, 'on') + + def test_initial_loading_off(self): + sl = self.create_switchable_light() + sl._attr_is_on = None + + sl.load_value_initially(LatestStateMock('off')) + self.assertFalse(sl._attr_is_on) + self.assertFalse(sl.is_on) + self.assertEqual(sl.brightness, None) + self.assertEquals(sl.state, 'off') + + def test_initial_loading_off_with_brightness(self): + sl = self.create_switchable_light() + sl._attr_is_on = None + + sl.load_value_initially(LatestStateMock('off', {'brightness': 0})) + self.assertFalse(sl._attr_is_on) + self.assertFalse(sl.is_on) + self.assertEqual(sl.brightness, 0) + self.assertEquals(sl.state, 'off') + + def test_initial_loading_None(self): + sl = self.create_switchable_light() + sl._attr_is_on = True + + sl.load_value_initially(LatestStateMock('bla')) + self.assertIsNone(sl._attr_is_on) + self.assertIsNone(sl.is_on) + self.assertIsNone(sl.brightness) + self.assertIsNone(sl.state) \ No newline at end of file diff --git a/tests/test_switch.py b/tests/test_switch.py index 89ada14a..42ec7166 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -34,7 +34,10 @@ def create_switch(self, sender_eep_string:str) -> EltakoSwitch: def test_switch_value_changed_with_sender_epp_A5_38_08(self): switch = self.create_switch('A5-38-08') switch.send_message = self.mock_send_message - switch._on_state = False + switch._attr_is_on = None + self.assertEqual(switch._attr_is_on, None) + self.assertEqual(switch.is_on, None) + self.assertEqual(switch.state, None) # status update message from relay #8b 05 70 00 00 00 00 00 00 01 30 @@ -43,19 +46,24 @@ def test_switch_value_changed_with_sender_epp_A5_38_08(self): off_msg = RPSMessage(address=b'\x00\x00\x00\x01', status=b'\x30', data=b'\x50', outgoing=False) switch.value_changed(on_msg) - self.assertEqual(switch._on_state, True) + self.assertEqual(switch.is_on, True) + self.assertEqual(switch.state, 'on') switch.value_changed(on_msg) - self.assertEqual(switch._on_state, True) + self.assertEqual(switch.is_on, True) + self.assertEqual(switch.state, 'on') switch.value_changed(off_msg) - self.assertEqual(switch._on_state, False) + self.assertEqual(switch.is_on, False) + self.assertEqual(switch.state, 'off') switch.value_changed(off_msg) - self.assertEqual(switch._on_state, False) + self.assertEqual(switch.is_on, False) + self.assertEqual(switch.state, 'off') switch.value_changed(on_msg) - self.assertEqual(switch._on_state, True) + self.assertEqual(switch.is_on, True) + self.assertEqual(switch.state, 'on') switch.turn_on() self.assertEqual(len(self.last_sent_command), 1) @@ -81,19 +89,24 @@ def test_switch_value_changed_with_sender_epp_F6_02_01(self): off_msg = RPSMessage(address=b'\x00\x00\x00\x01', status=b'\x30', data=b'\x50', outgoing=False) switch.value_changed(on_msg) - self.assertEqual(switch._on_state, True) + self.assertEqual(switch.is_on, True) + self.assertEqual(switch.state, 'on') switch.value_changed(on_msg) - self.assertEqual(switch._on_state, True) + self.assertEqual(switch.is_on, True) + self.assertEqual(switch.state, 'on') switch.value_changed(off_msg) - self.assertEqual(switch._on_state, False) + self.assertEqual(switch.is_on, False) + self.assertEqual(switch.state, 'off') switch.value_changed(off_msg) - self.assertEqual(switch._on_state, False) + self.assertEqual(switch.is_on, False) + self.assertEqual(switch.state, 'off') switch.value_changed(on_msg) - self.assertEqual(switch._on_state, True) + self.assertEqual(switch.is_on, True) + self.assertEqual(switch.state, 'on') self.last_sent_command = [] switch.turn_on() @@ -112,4 +125,31 @@ def test_switch_value_changed_with_sender_epp_F6_02_01(self): self.assertEqual(self.last_sent_command[0].data[0], 0x30) #off self.assertEqual(self.last_sent_command[1].status, 0x30) self.assertEqual(self.last_sent_command[1].data[0], 0x20) - self.last_sent_command = [] \ No newline at end of file + self.last_sent_command = [] + + def test_initial_loading_on(self): + switch = self.create_switch('F6-02-01') + switch._attr_is_on = None + + switch.load_value_initially(LatestStateMock('on')) + self.assertTrue(switch._attr_is_on) + self.assertTrue(switch.is_on) + self.assertEquals(switch.state, 'on') + + def test_initial_loading_off(self): + switch = self.create_switch('F6-02-01') + switch._attr_is_on = None + + switch.load_value_initially(LatestStateMock('off')) + self.assertFalse(switch._attr_is_on) + self.assertFalse(switch.is_on) + self.assertEquals(switch.state, 'off') + + def test_initial_loading_None(self): + switch = self.create_switch('F6-02-01') + switch._attr_is_on = True + + switch.load_value_initially(LatestStateMock('bla')) + self.assertIsNone(switch._attr_is_on) + self.assertIsNone(switch.is_on) + self.assertIsNone(switch.state) \ No newline at end of file diff --git a/tests/test_light.py b/tests/test_switchable_light.py similarity index 59% rename from tests/test_light.py rename to tests/test_switchable_light.py index c5b95c89..fc3a0eec 100644 --- a/tests/test_light.py +++ b/tests/test_switchable_light.py @@ -11,7 +11,7 @@ Entity.schedule_update_ha_state = mock.Mock(return_value=None) # EltakoEntity.send_message = mock.Mock(return_value=None) -class TestLight(unittest.TestCase): +class TestSwitchableLight(unittest.TestCase): def mock_send_message(self, msg: ESP2Message): self.last_sent_command = msg @@ -33,7 +33,9 @@ def create_switchable_light(self) -> EltakoSwitchableLight: def test_switchable_light_value_changed(self): light = self.create_switchable_light() - light._on_state = False + light._attr_is_on = None + self.assertEquals(light.is_on, None) + self.assertIsNone(light.state) # status update message from relay #8b 05 70 00 00 00 00 00 00 01 30 @@ -42,19 +44,29 @@ def test_switchable_light_value_changed(self): off_msg = RPSMessage(address=b'\x00\x00\x00\x01', status=b'\x30', data=b'\x50', outgoing=False) light.value_changed(on_msg) - self.assertEqual(light._on_state, True) + self.assertEquals(light.is_on, True) + self.assertEquals(light.state, 'on') light.value_changed(on_msg) - self.assertEqual(light._on_state, True) + self.assertEquals(light.is_on, True) + self.assertEquals(light.state, 'on') light.value_changed(off_msg) - self.assertEqual(light._on_state, False) + self.assertEquals(light.is_on, False) + self.assertEquals(light.state, 'off') light.value_changed(off_msg) - self.assertEqual(light._on_state, False) + self.assertEquals(light.is_on, False) + self.assertEquals(light.state, 'off') light.value_changed(on_msg) - self.assertEqual(light._on_state, True) + self.assertEquals(light.is_on, True) + self.assertEquals(light.state, 'on') + + light._attr_is_on = None + self.assertEquals(light.is_on, None) + self.assertIsNone(light.state) + def test_switchable_light_trun_on(self): @@ -76,3 +88,30 @@ def test_switchable_light_trun_off(self): self.assertEqual( self.last_sent_command.body, b'k\x07\x01\x00\x00\x08\x00\x00\xb0\x01\x00') + + def test_initial_loading_on(self): + sl = self.create_switchable_light() + sl._attr_is_on = None + + sl.load_value_initially(LatestStateMock('on')) + self.assertTrue(sl._attr_is_on) + self.assertTrue(sl.is_on) + self.assertEquals(sl.state, 'on') + + def test_initial_loading_off(self): + sl = self.create_switchable_light() + sl._attr_is_on = None + + sl.load_value_initially(LatestStateMock('off')) + self.assertFalse(sl._attr_is_on) + self.assertFalse(sl.is_on) + self.assertEquals(sl.state, 'off') + + def test_initial_loading_None(self): + sl = self.create_switchable_light() + sl._attr_is_on = True + + sl.load_value_initially(LatestStateMock('bla')) + self.assertIsNone(sl._attr_is_on) + self.assertIsNone(sl.is_on) + self.assertIsNone(sl.state) \ No newline at end of file