338 lines
14 KiB
Python
338 lines
14 KiB
Python
from __future__ import annotations
|
|
import logging
|
|
import re
|
|
from abc import ABC, abstractmethod
|
|
|
|
from home_connect_async import Appliance, Events
|
|
from homeassistant.core import HomeAssistant
|
|
from homeassistant.helpers.entity import Entity
|
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
|
from homeassistant.helpers import entity_registry as er
|
|
|
|
from .const import CONF_NAME_TEMPLATE, CONF_NAME_TEMPLATE_DEFAULT, DOMAIN, DEFAULT_SETTINGS, CONF_ENTITY_SETTINGS, CONF_APPLIANCE_SETTINGS
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
def find_delayed_operation_option(appliance:Appliance, conf:"Configuration"):
|
|
""" Return the first option marked as 'DelayedOperation' found on the appliance, looking first in the available program options and falling back to the currently selected program's options. Returns None if no such option exists. """
|
|
if appliance.available_programs:
|
|
for program in appliance.available_programs.values():
|
|
if program.options:
|
|
for option in program.options.values():
|
|
if conf.get_entity_setting(option.key, "type") == "DelayedOperation":
|
|
return option
|
|
if appliance.selected_program and appliance.selected_program.options:
|
|
for option in appliance.selected_program.options.values():
|
|
if conf.get_entity_setting(option.key, "type") == "DelayedOperation":
|
|
return option
|
|
return None
|
|
|
|
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
|
|
|
|
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: Configuration = conf
|
|
self._haid:str = ""
|
|
self._safe_haid: str = ""
|
|
self._unique_id:str = ""
|
|
self.entity_id = f'{self._platform_domain()}.{self.unique_id}'
|
|
self._hc_obj = hc_obj
|
|
|
|
def _platform_domain(self) -> str:
|
|
"""Return the HA platform domain (sensor, switch, ...) derived from the MRO."""
|
|
for cls in type(self).__mro__:
|
|
mod = getattr(cls, "__module__", "")
|
|
if mod.startswith("homeassistant.components."):
|
|
return mod.split(".")[2]
|
|
return DOMAIN
|
|
|
|
|
|
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)
|
|
|
|
@staticmethod
|
|
def get_safe_haID(hass: HomeAssistant, appliance: Appliance) -> str:
|
|
""" Returns a haID that doesn't start with a digit by adding the brand as a prefix if needed """
|
|
haid = appliance.haId.lower().replace('-', '_')
|
|
if haid[0].isdigit():
|
|
# if the haID starts with a digit and there aren't entities that use that haid already then add the brand as a prefix to the haId
|
|
# This is to avoid issues with using such entities in templates
|
|
ent_reg = er.async_get(hass)
|
|
all_entities_ids = ent_reg.entities.keys()
|
|
existing_entities = [eid for eid in all_entities_ids if f".{haid}" in eid]
|
|
if not existing_entities:
|
|
haid = appliance.brand.lower() + "_" + haid
|
|
else:
|
|
_LOGGER.debug("Not adding brand prefix to haID %s because there are existing entities without it", haid)
|
|
|
|
return haid
|
|
|
|
@property
|
|
def haId(self) -> str:
|
|
""" The haID of the appliance """
|
|
if not self._haid:
|
|
self._haid = self._appliance.haId.lower().replace('-','_')
|
|
return self._haid
|
|
|
|
@property
|
|
def safe_haId(self) -> str:
|
|
""" The safe haID of the appliance """
|
|
if not self._safe_haid:
|
|
self._safe_haid = EntityBase.get_safe_haID(self._conf.hass, self._appliance)
|
|
return self._safe_haid
|
|
|
|
@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|None:
|
|
""" 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 of the entity """
|
|
|
|
if not self._unique_id:
|
|
self._unique_id = f"{self.safe_haId}_{self._key.lower().replace('.','_')}"
|
|
return self._unique_id
|
|
|
|
@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 (
|
|
self._appliance.status is not None
|
|
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 = None
|
|
_hass:HomeAssistant
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
# __merge mutates self in place and returns self; no further .update needed.
|
|
self.__merge(self, DEFAULT_SETTINGS, overwrite=False)
|
|
if Configuration._global_config:
|
|
self.__merge(self, Configuration._global_config, overwrite=False)
|
|
|
|
def __merge(self, destination:dict, source:dict, overwrite:bool=True ):
|
|
for key, value in source.items():
|
|
# Configuration instances are per-entry runtime storage, not config
|
|
# to be merged. Skipping them keeps __merge robust even if a future
|
|
# change re-introduces aliasing between _global_config and the
|
|
# integration's hass.data[DOMAIN] (the original source of the
|
|
# RecursionError that this guard defends against).
|
|
if isinstance(value, Configuration):
|
|
continue
|
|
if isinstance(value, dict):
|
|
# get node or create one
|
|
node = destination.setdefault(key, {})
|
|
if node is not value:
|
|
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=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
|
|
|
|
@property
|
|
def hass(self) -> HomeAssistant:
|
|
"""Return the Home Assistant instance."""
|
|
return Configuration._hass
|
|
|
|
@classmethod
|
|
def set_global_config(cls, hass:HomeAssistant, 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
|
|
cls._hass = hass
|
|
|
|
@classmethod
|
|
def get_global_config(cls):
|
|
"""Return the global config"""
|
|
return cls._global_config if cls._global_config else {} |