from __future__ import annotations import logging import re from abc import ABC, abstractmethod from home_connect_async import Appliance, Events from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import CONF_NAME_TEMPLATE, CONF_NAME_TEMPLATE_DEFAULT, DOMAIN, DEFAULT_SETTINGS, CONF_ENTITY_SETTINGS, CONF_APPLIANCE_SETTINGS _LOGGER = logging.getLogger(__name__) def is_boolean_enum(values:list[str]) -> bool: """ Check if the list of enum values represents a boolean on/off option""" if not values or len(values) != 2: return False for v in values: v = v.lower() if not v.endswith(".off") and not v.endswith(".on"): return False return True class EntityBase(ABC): """Base class with common methods for all the entities """ #_attr_has_entity_name = True should_poll = False _appliance: Appliance = None def __init__(self, appliance:Appliance, key:str, conf:Configuration, hc_obj=None) -> None: """Initialize the sensor.""" self._appliance = appliance self._homeconnect = appliance._homeconnect self._key = key self._conf = conf self.entity_id = f'home_connect.{self.unique_id}' self._hc_obj = hc_obj def get_entity_setting(self, option, default=None): """ Gets the specified configuration option for the entity """ return self._conf.get_entity_setting(self._key, option, default) def has_entity_setting(self, option, default=None) -> bool: """ Checks if the specified configuration option exists for the entity """ return self._conf.has_entity_setting(self._key, option) @property def haId(self) -> str: """ The haID of the appliance """ return self._appliance.haId.lower().replace('-','_') @property def device_info(self): """Return information to link this entity with the correct device.""" return { "identifiers": {(DOMAIN, self.haId)}, "name": self._appliance.name, "manufacturer": self._appliance.brand, "model": self._appliance.vib, } @property def device_class(self) -> str: """ Return the device class, if defined """ if self._conf: return self._conf.get_entity_setting(self._key, "class") return None @property def unique_id(self) -> str: """" The unique ID oif the entity """ return f"{self.haId}_{self._key.lower().replace('.','_')}" @property def name_ext(self) -> str|None: """Provide the suffix of the name, can be be overridden by sub-classes to provide a custom or translated display name.""" return None @property def name(self) -> str: """" The name of the entity """ # haId = self._appliance.haId if self._conf and CONF_APPLIANCE_SETTINGS in self._conf and self._conf[CONF_APPLIANCE_SETTINGS] \ and self.haId in self._conf[CONF_APPLIANCE_SETTINGS] and CONF_NAME_TEMPLATE in self._conf[CONF_APPLIANCE_SETTINGS][self.haId]: template = self._conf[CONF_APPLIANCE_SETTINGS][self.haId][CONF_NAME_TEMPLATE] elif self._conf and CONF_NAME_TEMPLATE in self._conf and self._conf[CONF_NAME_TEMPLATE]: template = self._conf[CONF_NAME_TEMPLATE] else: template = CONF_NAME_TEMPLATE_DEFAULT appliance_name = self._appliance.name if self._appliance.name else self._appliance.type name = self.name_ext if self.name_ext else self.pretty_enum(self._key) return template.replace("$brand", self._appliance.brand).replace("$appliance", appliance_name).replace("$name", name) # This property is important to let HA know if this entity is online or not. # If an entity is offline (return False), the UI will reflect this. @property def available(self) -> bool: """Availability of the entity.""" return self._appliance.connected @property def program_option_available(self) -> bool: """ Helper to be used for program options controls """ return ( self._appliance.connected and self._appliance.is_available_option(self._key) and ( "BSH.Common.Status.RemoteControlActive" not in self._appliance.status or self._appliance.status["BSH.Common.Status.RemoteControlActive"].value ) ) async def async_added_to_hass(self): """Run when this Entity has been added to HA.""" events = [Events.CONNECTION_CHANGED, Events.DATA_CHANGED, Events.PROGRAM_SELECTED] if self._key: events.append(self._key) self._appliance.register_callback(self.async_on_update, events) async def async_will_remove_from_hass(self): """Entity being removed from hass.""" events = [Events.CONNECTION_CHANGED, Events.DATA_CHANGED, Events.PROGRAM_SELECTED] if self._key: events.append(self._key) self._appliance.deregister_callback(self.async_on_update, events) @abstractmethod async def async_on_update(self, appliance:Appliance, key:str, value) -> None: pass def pretty_enum(self, val:str) -> str: """Extract display string from a Home Connect Enum string.""" name = val.split('.')[-1] parts = re.findall('[A-Z0-9]+[^A-Z]*', name) return' '.join(parts) class InteractiveEntityBase(EntityBase): """ Base class for interactive entities (select, switch and number) """ async def async_added_to_hass(self): await super().async_added_to_hass() if self._key != "BSH.Common.Status.RemoteControlActive": self._appliance.register_callback(self.async_on_update, "BSH.Common.Status.RemoteControlActive") async def async_will_remove_from_hass(self): await super().async_will_remove_from_hass() if self._key != "BSH.Common.Status.RemoteControlActive": self._appliance.deregister_callback(self.async_on_update, "BSH.Common.Status.RemoteControlActive") class EntityManager(): """Helper class for managing entity registration. Duplication might happen because there is a race condition between the task that loads data from the Home Connect service and the initialization of the platforms. This class prevents that from happening. """ def __init__(self, async_add_entities:AddEntitiesCallback, platform:str): self._existing_ids = set() self._pending_entities:dict[str, Entity] = {} self._entity_appliance_map = {} self._async_add_entities = async_add_entities self._platform = platform def add(self, entity:Entity) -> None: """Add a new entity unless it already exists.""" if entity and (entity.unique_id not in self._existing_ids) and (entity.unique_id not in self._pending_entities): self._pending_entities[entity.unique_id] = entity def register(self) -> None: """ register the pending entities with Home Assistant """ new_ids = set(self._pending_entities.keys()) new_entities = list(self._pending_entities.values()) for entity in new_entities: if entity.haId not in self._entity_appliance_map: self._entity_appliance_map[entity.haId] = set() self._entity_appliance_map[entity.haId].add(entity.unique_id) if len(new_ids)>0: _LOGGER.debug("Registering new entities for platform=%s: %s", self._platform, new_ids) _LOGGER.debug("Already registered entities for platform=%s: %s", self._platform, self._existing_ids) self._async_add_entities(new_entities) self._existing_ids |= new_ids self._pending_entities = {} def remove_appliance(self, appliance:Appliance): """ Remove an appliance and all its registered entities """ if appliance.haId in self._entity_appliance_map: self._existing_ids -= self._entity_appliance_map[appliance.haId] del self._entity_appliance_map[appliance.haId] class Configuration(dict): """ A class to handle both global config coming from configuration.yaml and the local config of each entity """ _global_config:dict = None def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.update(self.__merge(self, DEFAULT_SETTINGS, overwrite=False)) if Configuration._global_config: self.update(self.__merge(self, Configuration._global_config, overwrite=False)) def __merge(self, destination:dict, source:dict, overwrite:bool=True ): for key, value in source.items(): if isinstance(value, dict): # get node or create one node = destination.setdefault(key, {}) self.__merge(node, value, overwrite) else: if key not in destination or overwrite: destination[key] = value return destination def get_entity_setting(self, key:str, option:str, default=None): """ Retrun an entity config setting or None if it doesn't exist """ if CONF_ENTITY_SETTINGS in self and self[CONF_ENTITY_SETTINGS] and key in self[CONF_ENTITY_SETTINGS] and option in self[CONF_ENTITY_SETTINGS][key]: return self[CONF_ENTITY_SETTINGS][key][option] return default def has_entity_setting(self, key:str, option:str) -> bool: """Checks if the entity config setting exist """ if CONF_ENTITY_SETTINGS in self and self[CONF_ENTITY_SETTINGS] and key in self[CONF_ENTITY_SETTINGS] and option in self[CONF_ENTITY_SETTINGS][key]: return True return False def set_entity_setting(self, key:str, option:str, value): """Return an entity config setting or None if it doesn't exist.""" if CONF_ENTITY_SETTINGS not in self: self[CONF_ENTITY_SETTINGS] = {} if key not in self[CONF_ENTITY_SETTINGS]: self[CONF_ENTITY_SETTINGS][key] = {} self[CONF_ENTITY_SETTINGS][key][option] = value def get_entity_settings(self, key:str): """Return an entity config setting or None if it doesn't exist.""" if CONF_ENTITY_SETTINGS in self and key in self[CONF_ENTITY_SETTINGS]: return self[key] return None def get_config(self, extra_conf:dict=None): """Return a new config object which is the merging of the current one with the extra configuration""" c = Configuration(self) if extra_conf: c.update(extra_conf) return c @classmethod def set_global_config(cls, global_config:dict): """Set the global config once as a static member that will be appended automatically to each config object.""" cls._global_config = global_config @classmethod def get_global_config(cls): """Return the global config""" return cls._global_config if cls._global_config else {}