262 lines
11 KiB
Python
262 lines
11 KiB
Python
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 {} |