Initial Home Assistant Configuration

This commit is contained in:
root
2025-09-11 10:47:34 +03:00
commit ac8b542e1b
2360 changed files with 41412 additions and 0 deletions
@@ -0,0 +1,424 @@
"""The Home Connect New integration."""
from __future__ import annotations
import asyncio
import copy
import logging
import aiohttp
from datetime import datetime
import voluptuous as vol
from home_connect_async import Appliance, HomeConnect, Events, ConditionalLogger
from homeassistant.components.application_credentials import ClientCredential, async_import_client_credential
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, Platform
from homeassistant.core import Event, HomeAssistant, HomeAssistantError
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers import storage
from homeassistant.helpers.typing import ConfigType
from . import api, config_flow
from .common import Configuration
from .const import *
from .services import Services
_LOGGER = logging.getLogger(__name__)
HC_CONFIG_SCHEMA = vol.Schema(
{
# vol.Optional(CONF_CLIENT_ID): cv.string,
# vol.Optional(CONF_CLIENT_SECRET): cv.string,
# vol.Optional(CONF_API_HOST, default=DEFAULT_API_HOST): str,
# vol.Optional(CONF_CACHE, default=False): vol.Coerce(bool),
# vol.Optional(CONF_LANG, default=CONF_LANG_DEFAULT): str,
# vol.Optional(CONF_TRANSLATION_MODE, default="local"): str,
# vol.Optional(CONF_SENSORS_TRANSLATION, default=None): vol.Any(str, None),
# vol.Optional(CONF_NAME_TEMPLATE, default=CONF_NAME_TEMPLATE_DEFAULT): str,
# vol.Optional(CONF_LOG_MODE, default=0): int,
# vol.Optional(CONF_SSE_TIMEOUT, default=CONF_SSE_TIMEOUT_DEFAULT): int,
vol.Optional(CONF_ENTITY_SETTINGS, default={}): vol.Any(dict, None),
vol.Optional(CONF_APPLIANCE_SETTINGS, default={}): vol.Any(dict, None)
}
)
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: HC_CONFIG_SCHEMA
},
extra=vol.ALLOW_EXTRA,
)
OPTIONS_SCHEMA = vol.Schema(
{
vol.Optional(CONF_LANG, default=CONF_LANG_DEFAULT): vol.Coerce(str),
vol.Optional(CONF_TRANSLATION_MODE, default="local"): vol.Coerce(str),
vol.Optional(CONF_DELAYED_OPS, default=CONF_DELAYED_OPS_DEFAULT): vol.Coerce(str),
vol.Optional(CONF_NAME_TEMPLATE, default=CONF_NAME_TEMPLATE_DEFAULT): vol.Coerce(str),
vol.Optional(CONF_LOG_MODE, default=0): vol.Coerce(int),
vol.Optional(CONF_SSE_TIMEOUT, default=CONF_SSE_TIMEOUT_DEFAULT): vol.Coerce(int),
vol.Optional(CONF_ENTITY_SETTINGS, default={}): vol.Any(dict, None),
vol.Optional(CONF_APPLIANCE_SETTINGS, default={}): vol.Any(dict, None)
},
extra=vol.REMOVE_EXTRA
)
PLATFORMS = [Platform.SENSOR, Platform.BINARY_SENSOR, Platform.SELECT, Platform.NUMBER, Platform.BUTTON, Platform.SWITCH, Platform.TIME]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Home Connect New component."""
if DOMAIN not in config:
# hass.data[DOMAIN] = HC_CONFIG_SCHEMA({})
hass.data[DOMAIN] = { "global": {} }
return True
# The config now contains configuration coming from the configuration.yaml file
Configuration.set_global_config(config[DOMAIN])
hass.data[DOMAIN] = { "global": {} }
# migrate OAuth credentials from configuration.yaml to credentials manager
# if (CONF_CLIENT_ID in config[DOMAIN] and CONF_CLIENT_SECRET in config[DOMAIN]):
# await async_import_client_credential(
# hass,
# DOMAIN,
# ClientCredential(
# config[DOMAIN][CONF_CLIENT_ID],
# config[DOMAIN][CONF_CLIENT_SECRET],
# ),
# )
return True
async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Set up Home Connect Alt from a config entry."""
# all_entries = hass.config_entries.async_entries(DOMAIN)
# save the options to compare later to see if they have changed
hass.data[DOMAIN][f"{config_entry.entry_id}_options"] = copy.deepcopy(dict(config_entry.options))
options = OPTIONS_SCHEMA(copy.deepcopy(dict(config_entry.options)))
conf = Configuration(options)
hass.data[DOMAIN][config_entry.entry_id] = conf
if CONF_API_HOST in config_entry.data:
api_host = config_entry.data[CONF_API_HOST]
hass.data[DOMAIN]["global"][CONF_API_HOST] = api_host
else:
api_host = hass.data[DOMAIN]["global"].get(CONF_API_HOST, DEFAULT_API_HOST)
conf["primary_config_entry"] = get_primary_config_entry(hass) == config_entry.entry_id
_LOGGER.debug(f"Config entry {config_entry.entry_id} is {'primary' if conf['primary_config_entry'] else 'secondary'}")
_LOGGER.debug(f"OAuth2={config_entry.data.get('auth_implementation','')} api_host={config_entry.data.get(CONF_API_HOST,'')}")
_LOGGER.debug(f"options: {config_entry.options}")
implementation = await config_entry_oauth2_flow.async_get_config_entry_implementation(hass, config_entry)
session = config_entry_oauth2_flow.OAuth2Session(hass, config_entry, implementation)
try:
await session.async_ensure_token_valid()
except aiohttp.ClientResponseError as ex:
_LOGGER.debug("API error: %s (%s)", ex.code, ex.message)
if ex.code in (400, 401, 403):
raise ConfigEntryAuthFailed("Token not valid, trigger renewal") from ex
raise ConfigEntryNotReady from ex
lang = conf[CONF_LANG] # if conf[CONF_LANG] != "" else None
#use_cache = conf[CONF_CACHE]
logmode = conf[CONF_LOG_MODE] if conf[CONF_LOG_MODE] else ConditionalLogger.LogMode.REQUESTS
# If using an aiohttp-based API lib
auth = api.AsyncConfigEntryAuth(
aiohttp_client.async_get_clientsession(hass), session, api_host
)
# homeconnect:HomeConnect = None
# if use_cache:
# homeconnect = await async_load_from_cache(hass, auth, lang)
# if not homeconnect:
# # Create normally if failed to create from cache
# try:
# homeconnect = await HomeConnect.async_create(auth, delayed_load=True, lang=lang)
# _LOGGER.debug("The HomeConnect object was created from scratch (without cache)")
# except HomeConnectError as ex:
# _LOGGER.warning("Failed to create the HomeConnect object", exc_info=ex)
# return False
ConditionalLogger.mode(logmode)
disabled_appliances = [haid for haid in conf[CONF_APPLIANCE_SETTINGS] if "disabled" in conf[CONF_APPLIANCE_SETTINGS][haid] and conf[CONF_APPLIANCE_SETTINGS][haid]["disabled"] ] if conf[CONF_APPLIANCE_SETTINGS] else []
homeconnect = await HomeConnect.async_create(auth, delayed_load=True, lang=lang, disabled_appliances=disabled_appliances, sse_timeout=conf[CONF_SSE_TIMEOUT])
services = register_services(hass, homeconnect)
conf.update({ "homeconnect": homeconnect, "services": services, "auth": auth })
#region internal event handlers
# async def async_delayed_update_cache(delay:float = 0):
# asyncio.sleep(delay)
# await async_save_to_cache(hass, homeconnect)
async def on_config_entry_update(hass:HomeAssistant, new_entry:ConfigEntry):
if dict(new_entry.options) != hass.data[DOMAIN][f"{config_entry.entry_id}_options"]:
_LOGGER.debug("Config entry updated, reloading the integration: %s", str(new_entry.options))
await hass.config_entries.async_reload(new_entry.entry_id)
async def on_data_loaded(homeconnect:HomeConnect):
# Save the state of the HomeConnect object to cache
# if use_cache:
# await async_save_to_cache(hass, homeconnect)
# else:
# _LOGGER.debug("Not saving to cache, it is disabled")
homeconnect.register_callback(on_device_removed, Events.DEPAIRED)
#homeconnect.register_callback(on_device_added, [Events.PAIRED, Events.DATA_CHANGED] )
homeconnect.subscribe_for_updates()
async def on_data_load_error(homeconnect:HomeConnect, ex:Exception):
_LOGGER.error("Failed to load data for the HomeConnect object", exc_info=ex)
# async def on_device_added(appliance:Appliance, event:str):
# if use_cache:
# await async_save_to_cache(hass, homeconnect)
# else:
# _LOGGER.debug("Not saving to cache, it is disabled")
async def on_device_removed(appliance:Appliance):
devreg = dr.async_get(hass)
device = devreg.async_get_device({(DOMAIN, appliance.haId.lower().replace('-','_'))})
devreg.async_remove_device(device.id)
# We need to wait for the appliance to be removed from the HomeConnect data
# this is not 100% fail-safe but good enough for a cache
# asyncio.create_task(async_delayed_update_cache(30))
#endregion
# Add event listener to reload the integration when the config entry options change (because the user edited them in the UI)
# config_entry.async_on_unload(config_entry.add_update_listener(lambda hass, entry: hass.config_entries.async_reload(entry.entry_id)))
config_entry.async_on_unload(config_entry.add_update_listener(on_config_entry_update))
# Setup all the callback listeners before starting to load the data
#hass.config_entries.async_setup_platforms(entry, PLATFORMS)
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
register_events_publisher(hass, homeconnect)
# Continue loading the HomeConnect data model and set the callback to be notified when done
homeconnect.start_load_data_task(on_complete=on_data_loaded, on_error= on_data_load_error)
return True
def get_primary_config_entry(hass:HomeAssistant) -> str:
""" Return the ID of the "first" config_entry for the integration """
all_entries = hass.config_entries.async_entries(DOMAIN)
for entry in all_entries:
if CONF_API_HOST in entry.data:
return entry.entry_id
return all_entries[0].entry_id
async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Unload a config entry."""
conf = hass.data[DOMAIN]
homeconnect:HomeConnect = conf[config_entry.entry_id]['homeconnect']
homeconnect.close()
# await async_save_to_cache(hass, None)
unload_ok = await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
if unload_ok:
hass.data[DOMAIN].pop(config_entry.entry_id)
return unload_ok
async def async_load_from_cache(hass:HomeAssistant, auth:api.AsyncConfigEntryAuth, lang:str|None) -> HomeConnect | None:
""" Helper function to load cached Home Connect data for storage """
cache = storage.Store(hass, version=1, key=f"{DOMAIN}_cache", private=True)
try:
refresh = HomeConnect.RefreshMode.ALL
json_data = None
cached_data = await cache.async_load()
if cached_data:
json_data = cached_data.get('json_data')
last_update = datetime.fromisoformat(cached_data['last_update'])
delta = (datetime.now()-last_update).total_seconds()
if delta < 60:
refresh = HomeConnect.RefreshMode.NOTHING
elif delta < 3600*24*30:
refresh = HomeConnect.RefreshMode.DYNAMIC_ONLY
homeconnect:HomeConnect = await HomeConnect.async_create(auth, json_data=json_data, refresh=refresh, delayed_load=True, lang=lang)
_LOGGER.debug("Loaded HomeConnect from cache")
return homeconnect
except Exception as ex:
# If there is any exception when creating the object from cache then clear the cache and continue
await cache.async_remove()
_LOGGER.debug("Exception while loading HomeConnect from cache, clearing cache and continuing", exc_info=ex)
return None
async def async_save_to_cache(hass:HomeAssistant, homeconnect:HomeConnect, cache:storage.Store=None) -> None:
""" Helper function to save the Home Connect data to Home Assistant storage """
try:
if not cache:
cache = storage.Store(hass, version=1, key=f"{DOMAIN}_cache", private=True)
if homeconnect:
cached_data = {
'last_update': datetime.now().isoformat(),
'json_data': homeconnect.to_json()
}
await cache.async_save(cached_data)
_LOGGER.debug("Saved HomeConnect to cache")
else:
await cache.async_remove()
_LOGGER.debug("Cleared HomeConnect from cache")
except Exception as ex:
_LOGGER.debug("Exception when saving HomeConnect to cache", exc_info=ex)
def register_services(hass:HomeAssistant, homeconnect:HomeConnect) -> Services:
""" Register the services offered by this integration """
services = Services(hass, homeconnect)
select_program_schema = vol.Schema(
{
vol.Required('device_id'): cv.string,
vol.Required('program_key'): cv.string,
vol.Optional('validate', default=True): cv.boolean,
vol.Optional('options'): vol.Schema(
[
{
vol.Required('key'): cv.string,
vol.Required('value'): vol.Any(str, int, float, bool)
}
]
)
}
)
hass.services.async_register(DOMAIN, "select_program", services.async_select_program, schema=select_program_schema)
start_program_schema = vol.Schema(
{
vol.Required('device_id'): cv.string,
vol.Optional('program_key'): cv.string,
vol.Optional('validate', default=True): cv.boolean,
vol.Optional('options'): vol.Schema(
[
{
vol.Required('key'): cv.string,
vol.Required('value'): vol.Any(str, int, float, bool)
}
]
)
}
)
hass.services.async_register(DOMAIN, "start_program", services.async_start_program, schema=start_program_schema)
stop_program_schema = vol.Schema(
{
vol.Required('device_id'): cv.string
}
)
hass.services.async_register(DOMAIN, "stop_program", services.async_stop_program, schema=stop_program_schema)
pause_program_schema = vol.Schema(
{
vol.Required('device_id'): cv.string
}
)
hass.services.async_register(DOMAIN, "pause_program", services.async_pause_program, schema=pause_program_schema)
resume_program_schema = vol.Schema(
{
vol.Required('device_id'): cv.string
}
)
hass.services.async_register(DOMAIN, "resume_program", services.async_resume_program, schema=resume_program_schema)
set_program_option_schema = vol.Schema(
{
vol.Required('device_id'): cv.string,
vol.Required('key'): cv.string,
vol.Required('value'): vol.Any(str, int, float, bool)
}
)
hass.services.async_register(DOMAIN, "set_program_option", services.async_set_program_option, schema=set_program_option_schema)
apply_setting_schema = vol.Schema(
{
vol.Required('device_id'): cv.string,
vol.Required('key'): cv.string,
vol.Required('value'): vol.Any(str, int, float, bool)
}
)
hass.services.async_register(DOMAIN, "apply_setting", services.async_apply_setting, schema=apply_setting_schema)
run_command_schema = vol.Schema(
{
vol.Required('device_id'): cv.string,
vol.Required('key'): cv.string,
vol.Required('value'): vol.Any(str, int, float, bool)
}
)
hass.services.async_register(DOMAIN, "run_command", services.async_run_command, schema=run_command_schema)
return services
def register_events_publisher(hass:HomeAssistant, homeconnect:HomeConnect):
""" Register for publishing events that are offered by this integration """
device_reg = dr.async_get(hass)
last_event = { 'key': None, 'value': None} # Used to filter out duplicate events
async def async_handle_event(appliance:Appliance, key:str, value:str):
if key != last_event['key'] or value != last_event['value']:
last_event['key'] = key
last_event['value'] = value
device = device_reg.async_get_device({(DOMAIN, appliance.haId.lower().replace('-','_'))})
event_data = {
"device_id": device.id,
"key": key,
"value": value
}
if key in ("BSH.Common.Status.OperationState", "BSH.Common.Event.ProgramFinished"):
# delay the firing of the event to deal with event handling race condition
_LOGGER.debug("Creating delayed_publish_event task")
asyncio.create_task(delayed_publish_event(event_data, 1))
else:
hass.bus.async_fire(f"{DOMAIN}_event", event_data)
_LOGGER.debug("Published event to Home Assistant event bus: %s = %s", key, str(value))
else:
_LOGGER.debug("Skipped publishing of duplicate event to Home Assistant event bus: %s = %s", key, str(value))
async def delayed_publish_event(event_data, timeout):
_LOGGER.debug(f"Sleeping for {timeout} seconds to delay event publishing of {event_data}")
await asyncio.sleep(timeout)
hass.bus.async_fire(f"{DOMAIN}_event", event_data)
_LOGGER.debug("Published event to Home Assistant event bus after delay: %s = %s", event_data["key"], str(event_data["value"]))
def register_appliance(appliance:Appliance):
for event in PUBLISHED_EVENTS:
appliance.register_callback(async_handle_event, event)
homeconnect.register_callback(register_appliance, [Events.PAIRED, Events.CONNECTED])
for appliance in homeconnect.appliances.values():
register_appliance(appliance)
class HomeConnectOauth2Impl(config_entry_oauth2_flow.LocalOAuth2Implementation):
"""" Implement that OAuth2 class """
@property
def name(self) -> str:
"""Name of the implementation."""
return "Home Connect Appliances"
+29
View File
@@ -0,0 +1,29 @@
"""API for Home Connect New bound to Home Assistant OAuth."""
import home_connect_async
from aiohttp import ClientSession
from homeassistant.helpers import config_entry_oauth2_flow
# TODO the following two API examples are based on our suggested best practices
# for libraries using OAuth2 with requests or aiohttp. Delete the one you won't use.
# For more info see the docs at https://developers.home-assistant.io/docs/api_lib_auth/#oauth2.
class AsyncConfigEntryAuth(home_connect_async.AbstractAuth):
"""Provide Home Connect New authentication tied to an OAuth2 based config entry."""
def __init__(
self,
websession: ClientSession,
oauth_session: config_entry_oauth2_flow.OAuth2Session,
host: str,
) -> None:
"""Initialize Home Connect New auth."""
super().__init__(websession, host)
self._oauth_session = oauth_session
async def async_get_access_token(self) -> str:
"""Return a valid access token."""
if not self._oauth_session.valid_token:
await self._oauth_session.async_ensure_token_valid()
return self._oauth_session.token["access_token"]
@@ -0,0 +1,26 @@
"""application_credentials platform for Google Assistant SDK."""
from homeassistant.components.application_credentials import AuthorizationServer
from homeassistant.core import HomeAssistant
from .const import CONF_API_HOST, DEFAULT_API_HOST, DOMAIN
async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer:
"""Return authorization server."""
api_host = hass.data[DOMAIN]["global"].get(CONF_API_HOST, DEFAULT_API_HOST) if DOMAIN in hass.data and "global" in hass.data[DOMAIN] else DEFAULT_API_HOST
return AuthorizationServer(
f"{api_host}/security/oauth/authorize",
f"{api_host}/security/oauth/token",
)
async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, str]:
"""Return description placeholders for the credentials dialog."""
return {
"more_info_url": (
"https://github.com/ekutner/home-connect-hass"
),
}
@@ -0,0 +1,177 @@
import logging
from home_connect_async import Appliance, HomeConnect, Events
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType
from .common import Configuration, EntityBase, EntityManager
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass:HomeAssistant , config_entry:ConfigType, async_add_entities:AddEntitiesCallback) -> None:
"""Add sensors for passed config_entry in HA."""
#auth = hass.data[DOMAIN][config_entry.entry_id]
#homeconnect:HomeConnect = hass.data[DOMAIN]['homeconnect']
entry_conf:Configuration = hass.data[DOMAIN][config_entry.entry_id]
homeconnect:HomeConnect = entry_conf["homeconnect"]
entity_manager = EntityManager(async_add_entities, "Binary Sensor")
def add_appliance(appliance:Appliance) -> None:
conf:Configuration = entry_conf.get_config()
for (key, status) in appliance.status.items():
device = None
if isinstance(status.value, bool) or conf.get_entity_setting(key, "type") == "Boolean": # should be a binary sensor if it has a boolean value
device = StatusBinarySensor(appliance, key, conf)
entity_manager.add(device)
if appliance.selected_program and appliance.selected_program.options:
for option in appliance.selected_program.options.values():
if isinstance(option.value, bool) or conf.get_entity_setting(option.key, "type") == "Boolean":
device = ProgramOptionBinarySensor(appliance, option.key, conf)
entity_manager.add(device)
if appliance.active_program and appliance.active_program.options:
for option in appliance.active_program.options.values():
if isinstance(option.value, bool) or conf.get_entity_setting(option.key, "type") == "Boolean":
device = ProgramOptionBinarySensor(appliance, option.key, conf)
entity_manager.add(device)
if appliance.settings:
for setting in appliance.settings.values():
if setting.type == "Boolean" or isinstance(setting.value, bool) or conf.get_entity_setting(setting.key, "type") == "Boolean":
device = SettingsBinarySensor(appliance, setting.key, conf)
entity_manager.add(device)
entity_manager.add(ConnectionBinarySensor(appliance, "Connected", conf))
# if len(new_entities)>0:
# entity_manager.register_entities(new_entities, async_add_entities)
entity_manager.register()
def remove_appliance(appliance:Appliance) -> None:
entity_manager.remove_appliance(appliance)
homeconnect.register_callback(add_appliance, [Events.PAIRED, Events.DATA_CHANGED, Events.PROGRAM_STARTED, Events.PROGRAM_SELECTED])
homeconnect.register_callback(remove_appliance, Events.DEPAIRED)
for appliance in homeconnect.appliances.values():
add_appliance(appliance)
class ProgramOptionBinarySensor(EntityBase, BinarySensorEntity):
""" Program option binary sensor """
@property
def device_class(self) -> str:
return f"{DOMAIN}__options"
@property
def name_ext(self) -> str:
current_program = self._appliance.get_applied_program()
if current_program and current_program.options and self._key in current_program.options:
return current_program.options[self._key].name
return None
@property
def icon(self) -> str:
return self.get_entity_setting('icon', 'mdi:office-building-cog')
@property
def available(self) -> bool:
current_program = self._appliance.get_applied_program()
return super().available and current_program and current_program.options and self._key in current_program.options
@property
def is_on(self):
current_program = self._appliance.get_applied_program()
if current_program and current_program.options and self._key in current_program.options:
return current_program.options[self._key].value
return None
async def async_on_update(self, appliance:Appliance, key:str, value) -> None:
self.async_write_ha_state()
class ActivityOptionBinarySensor(ProgramOptionBinarySensor):
""" Special active program sensor """
@property
def available(self) -> bool:
return self._appliance.active_program and self._key in self._appliance.active_program.options
@property
def name_ext(self) -> str:
if self._appliance.active_program and (self._key in self._appliance.active_program.options):
return self._appliance.active_program.options[self._key].name
return None
class StatusBinarySensor(EntityBase, BinarySensorEntity):
""" Status binary sensor """
@property
def icon(self) -> str:
return self.get_entity_setting('icon', 'mdi:gauge-full')
@property
def name_ext(self) -> str:
if self._key in self._appliance.status:
status = self._appliance.status[self._key]
if status:
return status.name
return None
@property
def is_on(self) -> bool:
if self._key in self._appliance.status:
if self.has_entity_setting("on_state"):
return self._appliance.status[self._key].value == self.get_entity_setting("on_state")
return self._appliance.status[self._key].value
return None
async def async_on_update(self, appliance:Appliance, key:str, value) -> None:
self.async_write_ha_state()
class SettingsBinarySensor(EntityBase, BinarySensorEntity):
""" Status sensor """
@property
def device_class(self) -> str:
return f"{DOMAIN}__settings"
@property
def name_ext(self) -> str:
if self._key in self._appliance.settings:
setting = self._appliance.settings[self._key]
if setting:
return setting.name
return None
@property
def icon(self) -> str:
return self.get_entity_setting('icon', 'mdi:tune')
@property
def is_on(self):
if self._key in self._appliance.settings:
if self.has_entity_setting("on_state"):
return self._appliance.settings[self._key].value == self.get_entity_setting("on_state")
return self._appliance.settings[self._key].value
return None
async def async_on_update(self, appliance:Appliance, key:str, value) -> None:
self.async_write_ha_state()
class ConnectionBinarySensor(EntityBase, BinarySensorEntity):
""" Appliance connected state binary sensor """
@property
def available(self) -> bool:
return True
@property
def is_on(self) -> bool:
return self._appliance.connected
async def async_on_update(self, appliance:Appliance, key:str, value) -> None:
self.async_write_ha_state()
@@ -0,0 +1,334 @@
import json
import logging
from home_connect_async import Appliance, HomeConnect, HomeConnectError, Events
from homeassistant.components.button import ButtonEntity
from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType
from .common import Configuration, EntityBase, EntityManager
from .const import DOMAIN, HOME_CONNECT_DEVICE
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass:HomeAssistant , config_entry:ConfigType, async_add_entities:AddEntitiesCallback) -> None:
""" Add buttons for passed config_entry in HA """
#homeconnect:HomeConnect = hass.data[DOMAIN]['homeconnect']
entry_conf:Configuration = hass.data[DOMAIN][config_entry.entry_id]
homeconnect:HomeConnect = entry_conf["homeconnect"]
entity_manager = EntityManager(async_add_entities, "Button")
def add_appliance(appliance:Appliance) -> None:
conf = entry_conf.get_config()
if appliance.available_programs:
entity_manager.add(StartButton(appliance, None, conf))
entity_manager.add(StopButton(appliance, None, conf))
if appliance.commands:
for command in appliance.commands.values():
# The "BSH.Common.Command.AcknowledgeEvent" command is used to acknowledge the ProgramFinished state
if command.key not in ["BSH.Common.Command.PauseProgram", "BSH.Common.Command.ResumeProgram", "BSH.Common.Command.AcknowledgeEvent"]:
button = CommandButton(appliance, command.key, conf, hc_obj=command)
entity_manager.add(button)
entity_manager.register()
def remove_appliance(appliance:Appliance) -> None:
entity_manager.remove_appliance(appliance)
# First add the integration button
button_name_suffix = "" if entry_conf["primary_config_entry"] else "_"+config_entry.entry_id
async_add_entities([HomeConnectRefreshButton(homeconnect, button_name_suffix), HomeConnectDebugButton(homeconnect, button_name_suffix)])
# Subscribe for events and register existing appliances
homeconnect.register_callback(add_appliance, [Events.PAIRED, Events.DATA_CHANGED, Events.PROGRAM_STARTED, Events.PROGRAM_SELECTED])
homeconnect.register_callback(remove_appliance, Events.DEPAIRED)
for appliance in homeconnect.appliances.values():
add_appliance(appliance)
class StartButton(EntityBase, ButtonEntity):
""" Class for buttons that start the selected program """
@property
def unique_id(self) -> str:
return f'{self.haId}_start_pause'
@property
def name_ext(self) -> str:
match self.translation_key:
case "pause_program":
return "Pause"
case "resume_program":
return "Resume"
return "Start"
@property
def translation_key(self) -> str:
op_state = self._appliance.status.get("BSH.Common.Status.OperationState")
if op_state and op_state.value == "BSH.Common.EnumType.OperationState.Run" \
and "BSH.Common.Command.PauseProgram" in self._appliance.commands:
return "pause_program"
if op_state and op_state.value == "BSH.Common.EnumType.OperationState.Pause" \
and "BSH.Common.Command.ResumeProgram" in self._appliance.commands:
return "resume_program"
return "start_program"
@property
def available(self) -> bool:
op_state = self._appliance.status.get("BSH.Common.Status.OperationState")
return super().available and op_state and \
(
(
op_state.value in ["BSH.Common.EnumType.OperationState.Ready", "BSH.Common.EnumType.OperationState.Inactive" ]
and (
"BSH.Common.Status.RemoteControlStartAllowed" not in self._appliance.status or
self._appliance.status["BSH.Common.Status.RemoteControlStartAllowed"].value
)
and (
(self._appliance.selected_program or self._appliance.startonly_program)
and not self._appliance.active_program
# and self._appliance.available_programs and
# self._appliance.selected_program.key in self._appliance.available_programs
)
)
or (
op_state.value == "BSH.Common.EnumType.OperationState.Run"
and "BSH.Common.Command.PauseProgram" in self._appliance.commands
)
or (
op_state.value == "BSH.Common.EnumType.OperationState.Pause"
and "BSH.Common.Command.ResumeProgram" in self._appliance.commands
)
)
@property
def icon(self) -> str:
if "BSH.Common.Command.PauseProgram" in self._appliance.commands \
and "BSH.Common.Status.OperationState" in self._appliance.status \
and self._appliance.status["BSH.Common.Status.OperationState"].value == "BSH.Common.EnumType.OperationState.Run":
return "mdi:pause"
return "mdi:play"
async def async_press(self) -> None:
""" Handle button press """
try:
op_state = self._appliance.status.get("BSH.Common.Status.OperationState")
if op_state and op_state.value in ["BSH.Common.EnumType.OperationState.Ready", "BSH.Common.EnumType.OperationState.Inactive" ]:
await self._appliance.async_start_program()
elif op_state and op_state.value == "BSH.Common.EnumType.OperationState.Run":
await self._appliance.async_pause_active_program()
elif op_state and op_state.value == "BSH.Common.EnumType.OperationState.Pause":
await self._appliance.async_resume_paused_program()
except HomeConnectError as ex:
if ex.error_description:
raise HomeAssistantError(f"Failed to start the selected program: {ex.error_description} ({ex.code})")
raise HomeAssistantError(f"Failed to start the selected program ({ex.code})")
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,
Events.PROGRAM_STARTED,
Events.PROGRAM_FINISHED,
"BSH.Common.Status.*",
"BSH.Common.Setting.PowerState"
]
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,
Events.PROGRAM_STARTED,
Events.PROGRAM_FINISHED,
"BSH.Common.Status.*",
"BSH.Common.Setting.PowerState"
]
self._appliance.deregister_callback(self.async_on_update, events)
async def async_on_update(self, appliance:Appliance, key:str, value) -> None:
self.async_write_ha_state()
class StopButton(EntityBase, ButtonEntity):
""" Class for buttons that start the selected program """
@property
def unique_id(self) -> str:
return f'{self.haId}_stop'
@property
def name_ext(self) -> str:
return "Stop"
@property
def translation_key(self) -> str:
return "stop_program"
@property
def available(self) -> bool:
return super().available \
and self._appliance.active_program \
and (
"BSH.Common.Status.RemoteControlStartAllowed" not in self._appliance.status or
self._appliance.status["BSH.Common.Status.RemoteControlStartAllowed"].value
)
@property
def icon(self) -> str:
return "mdi:stop"
async def async_press(self) -> None:
""" Handle button press """
try:
await self._appliance.async_stop_active_program()
except HomeConnectError as ex:
if ex.error_description:
raise HomeAssistantError(f"Failed to stop the selected program: {ex.error_description} ({ex.code})")
raise HomeAssistantError(f"Failed to stop the selected program ({ex.code})")
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,
Events.PROGRAM_STARTED,
Events.PROGRAM_FINISHED,
"BSH.Common.Status.*",
"BSH.Common.Setting.PowerState"
]
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,
Events.PROGRAM_STARTED,
Events.PROGRAM_FINISHED,
"BSH.Common.Status.*",
"BSH.Common.Setting.PowerState"
]
self._appliance.deregister_callback(self.async_on_update, events)
async def async_on_update(self, appliance:Appliance, key:str, value) -> None:
self.async_write_ha_state()
class CommandButton(EntityBase, ButtonEntity):
""" Class for running a HC command """
@property
def name_ext(self) -> str|None:
return self._hc_obj.name
@property
def icon(self) -> str:
return self.get_entity_setting('icon', "mdi:button-pointer")
@property
def available(self) -> bool:
return super().available and self._appliance.commands and self._key in self._appliance.commands
async def async_press(self) -> None:
""" Handle button press """
try:
await self._appliance.async_send_command(self._key, True)
except HomeConnectError as ex:
if ex.error_description:
raise HomeAssistantError(f"Failed to stop the selected program: {ex.error_description} ({ex.code})")
raise HomeAssistantError(f"Failed to stop the selected program ({ex.code})")
async def async_on_update(self, appliance:Appliance, key:str, value) -> None:
self.async_write_ha_state()
class HomeConnectRefreshButton(ButtonEntity):
""" Class for a button to trigger a global refresh of Home Connect data """
_attr_has_entity_name = True
def __init__(self, homeconnect:HomeConnect, name_suffix:str) -> None:
self._homeconnect = homeconnect
self._name_suffix = name_suffix
self.entity_id = f'home_connect.{self.unique_id}'
@property
def device_info(self):
"""Return information to link this entity with the correct device."""
return HOME_CONNECT_DEVICE
@property
def unique_id(self) -> str:
return "homeconnect_refresh" + self._name_suffix
@property
def translation_key(self) -> str:
return "homeconnect_refresh"
@property
def icon(self) -> str:
return "mdi:cloud-refresh"
@property
def available(self) -> bool:
return True
async def async_press(self) -> None:
""" Handle button press """
try:
self._homeconnect.start_load_data_task(refresh=HomeConnect.RefreshMode.ALL)
except HomeConnectError as ex:
if ex.error_description:
raise HomeAssistantError(f"Failed to refresh the Home Connect data: {ex.error_description} ({ex.code})")
raise HomeAssistantError(f"Failed to refresh the Home Connect data ({ex.code})")
class HomeConnectDebugButton(ButtonEntity):
""" Class for a button to trigger a global refresh of Home Connect data """
_attr_has_entity_name = True
def __init__(self, homeconnect:HomeConnect, name_suffix:str) -> None:
self._homeconnect = homeconnect
self._name_suffix = name_suffix
self.entity_id = f'home_connect.{self.unique_id}'
@property
def device_info(self):
"""Return information to link this entity with the correct device."""
return HOME_CONNECT_DEVICE
@property
def unique_id(self) -> str:
return "homeconnect_debug" + self._name_suffix
# @property
# def name(self) -> str:
# return "homeconnect_debug"
# return None
#return "Home Connect Debug Info"
@property
def translation_key(self) -> str:
return "homeconnect_debug"
@property
def icon(self) -> str:
return "mdi:bug-check"
@property
def available(self) -> bool:
return True
async def async_press(self) -> None:
""" Handle button press """
try:
conf = {k:v for (k,v) in self.hass.data[DOMAIN].items() if isinstance(v, (str, int, float, dict, list)) and k not in [CONF_CLIENT_ID, CONF_CLIENT_SECRET] }
js=json.dumps(conf, indent=2, default=lambda o: '<not serializable>')
#js=json.dumps(self.hass.data[DOMAIN], indent=2, default=lambda o: '<not serializable>')
_LOGGER.error(js)
js=self._homeconnect.to_json(indent=2)
_LOGGER.error(js)
except Exception as ex:
raise HomeAssistantError("Failed to serialize to JSON")
@@ -0,0 +1,262 @@
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 {}
@@ -0,0 +1,193 @@
"""Config flow for Home Connect New."""
import logging
from typing import Any, Mapping
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.data_entry_flow import FlowResult, FlowHandler
from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET
from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv
from homeassistant.helpers.selector import selector
from .common import Configuration
from .const import *
# class OAuth2FlowHandler2(config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN):
# """Config flow to handle Home Connect New OAuth2 authentication."""
# DOMAIN = DOMAIN
# VERSION = 1
# reauth_entry: ConfigEntry | None = None
# @property
# def logger(self) -> logging.Logger:
# """Return logger."""
# return logging.getLogger(__name__)
# @property
# def extra_authorize_data(self) -> dict:
# """Extra data that needs to be appended to the authorize url."""
# return {"scope": SCOPES}
class OAuth2FlowHandler(config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN):
"""Config flow to handle Home Connect New OAuth2 authentication."""
DOMAIN = DOMAIN
VERSION = 1
reauth_entry: ConfigEntry | None = None
@property
def logger(self) -> logging.Logger:
"""Return logger."""
return logging.getLogger(__name__)
@property
def extra_authorize_data(self) -> dict:
"""Extra data that needs to be appended to the authorize url."""
return {"scope": SCOPES}
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle a flow start."""
implementations = await config_entry_oauth2_flow.async_get_implementations(self.hass, self.DOMAIN)
if user_input is None and not implementations:
data_schema = vol.Schema({
vol.Required(CONF_API_HOST, default=CONF_API_HOST_OPTIONS[0]):
selector(
{
"select": {
"options": CONF_API_HOST_OPTIONS,
"mode": "dropdown",
"translation_key": CONF_API_HOST,
},
})
})
return self.async_show_form(step_id="user", data_schema=data_schema )
if user_input and CONF_API_HOST in user_input:
if DOMAIN not in self.hass.data:
self.hass.data[DOMAIN] = {"config_flow": {}}
elif "config_flow" not in self.hass.data[DOMAIN]:
self.hass.data[DOMAIN]["config_flow"] = {}
self.hass.data[DOMAIN]["config_flow"].update(user_input)
if "global" not in self.hass.data[DOMAIN]:
self.hass.data[DOMAIN]["global"] = {}
self.hass.data[DOMAIN]["global"].update({ CONF_API_HOST: user_input[CONF_API_HOST] })
user_input = None
return await self.async_step_pick_implementation(user_input)
async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult:
"""Perform reauth upon an API authentication error."""
self.reauth_entry = self.hass.config_entries.async_get_entry(
self.context["entry_id"]
)
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Confirm reauth dialog."""
if user_input is None:
return self.async_show_form(step_id="reauth_confirm")
return await self.async_step_user()
async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult:
"""Create an entry for the flow, or update existing entry."""
if self.reauth_entry:
self.hass.config_entries.async_update_entry(self.reauth_entry, data=data)
await self.hass.config_entries.async_reload(self.reauth_entry.entry_id)
return self.async_abort(reason="reauth_successful")
# if self._async_current_entries():
# # Config entry already exists, only one allowed.
# return self.async_abort(reason="single_instance_allowed")
if DOMAIN in self.hass.data and "config_flow" in self.hass.data[DOMAIN] and CONF_API_HOST in self.hass.data[DOMAIN]["config_flow"]:
data[CONF_API_HOST] = self.hass.data[DOMAIN]["config_flow"][CONF_API_HOST]
del self.hass.data[DOMAIN]["config_flow"]
return self.async_create_entry(
title=NAME,
data=data,
)
def default_language_code(self, hass: HomeAssistant):
"""Get default language code based on Home Assistant config."""
language_code = f"{hass.config.language}-{hass.config.country}"
return language_code
@staticmethod
@callback
def async_get_options_flow(
config_entry: config_entries.ConfigEntry,
) -> config_entries.OptionsFlow:
"""Create the options flow."""
return OptionsFlowHandler(config_entry)
class OptionsFlowHandler(config_entries.OptionsFlowWithConfigEntry):
""" Options flow """
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
""" Manage the options """
if user_input is not None:
# Save the config entry when the user input has been received
return self.async_create_entry(title="", data=user_input)
data_schema = {
vol.Optional(CONF_LANG, default="en-GB"): cv.string,
vol.Optional(CONF_TRANSLATION_MODE, default=CONF_TRANSLATION_MODES[0]):
selector({
"select": {
"options": CONF_TRANSLATION_MODES,
#"mode": "dropdown",
"translation_key": CONF_TRANSLATION_MODE
},
}),
# vol.Optional(CONF_ABSOLUTE_DELAYED_OPS, default=False): cv.boolean,
vol.Optional(CONF_DELAYED_OPS, default=CONF_DELAYED_OPS_DEFAULT):
selector({
"select": {
"options": [CONF_DELAYED_OPS_DEFAULT, CONF_DELAYED_OPS_ABSOLUTE_TIME],
"mode": "list",
"translation_key": CONF_DELAYED_OPS
},
}),
vol.Optional(CONF_LOG_MODE, default=0): vol.All(int, vol.Range(min=0, max=7)),
}
if self.context.get("show_advanced_options"):
data_schema.update(
{
vol.Optional(CONF_NAME_TEMPLATE, default=CONF_NAME_TEMPLATE_DEFAULT): str,
vol.Optional(CONF_SSE_TIMEOUT, default=CONF_SSE_TIMEOUT_DEFAULT): int,
vol.Optional(CONF_APPLIANCE_SETTINGS, default={}):
selector({
"object": {}
}),
vol.Optional(CONF_ENTITY_SETTINGS, default={}):
selector({
"object": {}
}),
}
)
defaults = Configuration.get_global_config()
defaults.update(self.config_entry.options)
data_schema = self.add_suggested_values_to_schema(data_schema=vol.Schema(data_schema), suggested_values=defaults)
return self.async_show_form(step_id="init", data_schema=data_schema)
@@ -0,0 +1,79 @@
"""Constants for the Home Connect Alt integration."""
DOMAIN = "home_connect_alt"
NAME = "Home Connect Alt"
DEFAULT_API_HOST = "https://api.home-connect.com"
ENDPOINT_AUTHORIZE = "/security/oauth/authorize"
ENDPOINT_TOKEN = "/security/oauth/token"
SCOPES = "IdentifyAppliance Monitor Control Settings"
CONF_API_HOST = "api_host"
CONF_API_HOST_OPTIONS = [ "https://api.home-connect.com", "https://api.home-connect.cn", "https://simulator.home-connect.com" ]
# CONF_API_HOST_OPTIONS = [
# {"label": "default", "value": "https://api.home-connect.com"},
# {"label": "china", "value": "https://api.home-connect.cn"}
# ]
CONF_LANG = "language"
CONF_LANG_DEFAULT = "en-GB"
CONF_CACHE = "cache"
CONF_TRANSLATION_MODE = "translation_mode"
CONF_TRANSLATION_MODES = ["local", "server"]
CONF_TRANSLATION_MODE_SERVER = "server"
CONF_SENSORS_TRANSLATION = "sensor_value_translation"
CONF_NAME_TEMPLATE = "name_template"
CONF_NAME_TEMPLATE_DEFAULT = "$brand $appliance - $name"
CONF_LOG_MODE = "log_mode"
CONF_SSE_TIMEOUT = "sse_timeout"
CONF_SSE_TIMEOUT_DEFAULT = 15
CONF_ENTITY_SETTINGS = "entity_settings"
CONF_APPLIANCE_SETTINGS = "appliance_settings"
CONF_DELAYED_OPS = "delayed_ops"
CONF_DELAYED_OPS_DEFAULT = "default"
CONF_DELAYED_OPS_ABSOLUTE_TIME = "absolute_time"
HOME_CONNECT_DEVICE = {
"identifiers": {(DOMAIN, "homeconnect")},
"name": "Home Connect Service",
"manufacturer": "BSH"
}
DEFAULT_SETTINGS = {
CONF_ENTITY_SETTINGS: {
"BSH.Common.Option.FinishInRelative": { "type": "DelayedOperation", "unit": None, "class": f"{DOMAIN}__timespan"},
"BSH.Common.Option.StartInRelative": { "type": "DelayedOperation", "unit": None, "class": f"{DOMAIN}__timespan"},
"BSH.Common.Option.ElapsedProgramTime": { "unit": None, "class": f"{DOMAIN}__timespan"},
"BSH.Common.Option.EstimatedTotalProgramTime": { "unit": None, "class": f"{DOMAIN}__timespan"},
"BSH.Common.Option.RemainingProgramTime": {"unit": None, "class": "timestamp" },
"BSH.Common.Status.DoorState": { "type": "Boolean", "class": "door", "icon": None, "on_state": "BSH.Common.EnumType.DoorState.Open" },
"Refrigeration.Common.Status.Door.Freezer": { "type": "Boolean", "class": "door", "icon": None, "on_state": "Refrigeration.Common.EnumType.Door.States.Open" },
"Refrigeration.Common.Status.Door.Refrigerator": { "type": "Boolean", "class": "door", "icon": None, "on_state": "Refrigeration.Common.EnumType.Door.States.Open" },
"Refrigeration.Common.Status.Door.ChillerCommon": { "type": "Boolean", "class": "door", "icon": None, "on_state": "Refrigeration.Common.EnumType.Door.States.Open" },
"Connected": { "class": "connectivity" },
}
}
DEVICE_ICON_MAP = {
"Dryer": "mdi:tumble-dryer",
"Washer": "mdi:washing-machine",
"Dishwasher": "mdi:dishwasher",
"CoffeeMaker": "mdi:coffee-maker",
"Oven": "mdi:stove",
"FridgeFreezer": "mdi:fridge",
"Fridge": "mdi:fridge",
"Refrigerator": "mdi:fridge",
"Freezer": "mdi:fridge",
"CleaningRobot": "mdi:robot-vacuum",
"Hood": "mdi:hvac"
}
PUBLISHED_EVENTS = [
"BSH.Common.Status.OperationState",
"*.event.*"
]
TRIGGERS_CONFIG = {
"program_started": { "key": "BSH.Common.Status.OperationState", "value": "BSH.Common.EnumType.OperationState.Run" },
"program_finished": { "key": "BSH.Common.Status.OperationState", "value": "BSH.Common.EnumType.OperationState.Finished" }
}
@@ -0,0 +1,69 @@
"""Provides device triggers for Home Connect New."""
from __future__ import annotations
from typing import Any
import voluptuous as vol
#from homeassistant.components.automation import (AutomationActionType, AutomationTriggerInfo)
from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA
from homeassistant.components.homeassistant.triggers import event as event_trigger
from homeassistant.components.homeassistant.triggers import state as state_trigger
from homeassistant.const import (CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, CONF_TYPE)
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers import entity_registry
from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
from homeassistant.helpers.typing import ConfigType
from . import DOMAIN, TRIGGERS_CONFIG
# TODO specify your supported trigger types.
TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend(
{
vol.Required(CONF_TYPE): vol.In(TRIGGERS_CONFIG.keys()),
}
)
async def async_get_triggers(
hass: HomeAssistant, device_id: str
) -> list[dict[str, Any]]:
"""List device triggers for Home Connect New devices."""
#registry = await entity_registry.async_get_registry(hass)
triggers = []
base_trigger = {
CONF_PLATFORM: "device",
CONF_DEVICE_ID: device_id,
CONF_DOMAIN: DOMAIN
}
for trigger_type in TRIGGERS_CONFIG.keys():
triggers.append({**base_trigger, CONF_TYPE: trigger_type})
return triggers
async def async_attach_trigger(
hass: HomeAssistant,
config: ConfigType,
action: TriggerActionType,
trigger_info: TriggerInfo,
) -> CALLBACK_TYPE:
"""Attach a trigger."""
trigger_type = config[CONF_TYPE]
event_config = event_trigger.TRIGGER_SCHEMA(
{
event_trigger.CONF_PLATFORM: "event",
event_trigger.CONF_EVENT_TYPE: f"{DOMAIN}_event",
event_trigger.CONF_EVENT_DATA: {
CONF_DEVICE_ID: config[CONF_DEVICE_ID],
"key": TRIGGERS_CONFIG[trigger_type]["key"],
"value": TRIGGERS_CONFIG[trigger_type]["value"]
},
}
)
return await event_trigger.async_attach_trigger(
hass, event_config, action, trigger_info, platform_type="device"
)
@@ -0,0 +1,21 @@
{
"domain": "home_connect_alt",
"name": "Home Connect Alt",
"codeowners": [
"@ekutner"
],
"config_flow": true,
"dependencies": [
"http",
"application_credentials"
],
"documentation": "https://github.com/ekutner/home-connect-hass",
"homekit": {},
"iot_class": "cloud_push",
"issue_tracker": "https://github.com/ekutner/home-connect-hass/issues",
"requirements": ["home-connect-async==0.8.2"],
"loggers": ["home_connect_alt", "home_connect_async"],
"ssdp": [],
"version": "1.2.1",
"zeroconf": []
}
@@ -0,0 +1,184 @@
""" Implement the Number entities of this implementation """
from __future__ import annotations
import sys
from home_connect_async import Appliance, HomeConnect, HomeConnectError, Events
from homeassistant.components.number import NumberEntity
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType
from .common import Configuration, InteractiveEntityBase, EntityManager
from .const import DOMAIN
async def async_setup_entry(hass:HomeAssistant , config_entry:ConfigType, async_add_entities:AddEntitiesCallback) -> None:
"""Add Numbers for passed config_entry in HA."""
#auth = hass.data[DOMAIN][config_entry.entry_id]
#homeconnect:HomeConnect = hass.data[DOMAIN]['homeconnect']
entry_conf:Configuration = hass.data[DOMAIN][config_entry.entry_id]
homeconnect:HomeConnect = entry_conf["homeconnect"]
entity_manager = EntityManager(async_add_entities, "Number")
number_types = ["Int", "Float", "Double"]
def add_appliance(appliance:Appliance) -> None:
conf = entry_conf.get_config()
if appliance.available_programs:
for program in appliance.available_programs.values():
if program.options:
for option in program.options.values():
if (not conf.has_entity_setting(option.key, "type") and option.type in number_types) or conf.has_entity_setting(option.key, "type") in number_types:
device = OptionNumber(appliance, option.key, conf, hc_obj=option)
entity_manager.add(device)
if appliance.settings:
for setting in appliance.settings.values():
if ((not conf.has_entity_setting(setting.key, "type") and setting.type in number_types) or conf.has_entity_setting(setting.key, "type") in number_types) \
and setting.access != "read" :
device = SettingsNumber(appliance, setting.key, conf, hc_obj=setting)
entity_manager.add(device)
entity_manager.register()
def remove_appliance(appliance:Appliance) -> None:
entity_manager.remove_appliance(appliance)
homeconnect.register_callback(add_appliance,[Events.PAIRED, Events.DATA_CHANGED, Events.PROGRAM_STARTED, Events.PROGRAM_SELECTED])
homeconnect.register_callback(remove_appliance, Events.DEPAIRED)
for appliance in homeconnect.appliances.values():
add_appliance(appliance)
class OptionNumber(InteractiveEntityBase, NumberEntity):
""" Class for numeric options """
@property
def device_class(self) -> str:
return f"{DOMAIN}__options"
@property
def name_ext(self) -> str|None:
if self._appliance.available_programs:
for program in self._appliance.available_programs.values():
if program.options and self._key in program.options and program.options[self._key].name:
return program.options[self._key].name
return None
@property
def icon(self) -> str:
return self.get_entity_setting('icon', 'mdi:office-building-cog')
@property
def available(self) -> bool:
return self.program_option_available
@property
def native_min_value(self) -> float:
"""Return the minimum value."""
if self._hc_obj.min:
return self._hc_obj.min
return 0
@property
def native_max_value(self) -> float:
"""Return the maximum value."""
if self._hc_obj.max:
return self._hc_obj.max
return sys.maxsize
@property
def native_step(self) -> float:
"""Return the increment/decrement step."""
return self._hc_obj.stepsize
@property
def native_unit_of_measurement(self) -> str:
if self.has_entity_setting("unit"):
return self.get_entity_setting("unit")
if self._hc_obj.unit:
return self._hc_obj.unit
return ""
@property
def native_value(self) -> float:
"""Return the entity value to represent the entity state."""
option = self._appliance.get_applied_program_option(self._key)
if option:
return option.value
return None
async def async_set_native_value(self, value: float) -> None:
"""Set new value."""
try:
if self._hc_obj.type == 'Int':
value = int(value)
await self._appliance.async_set_option(self._key, value)
except HomeConnectError as ex:
if ex.error_description:
raise HomeAssistantError(f"Failed to set the option value: {ex.error_description} ({ex.code} - {self._key}={value})")
raise HomeAssistantError(f"Failed to set the option value: ({ex.code} - {self._key}={value})")
async def async_on_update(self, appliance:Appliance, key:str, value) -> None:
self.async_write_ha_state()
class SettingsNumber(InteractiveEntityBase, NumberEntity):
""" Class for numeric settings """
@property
def device_class(self) -> str:
return f"{DOMAIN}__settings"
@property
def name_ext(self) -> str|None:
return self._hc_obj.name
@property
def icon(self) -> str:
return self.get_entity_setting('icon', 'mdi:tune')
@property
def native_min_value(self) -> float:
"""Return the minimum value."""
if self._hc_obj.min:
return self._hc_obj.min
return 0
@property
def native_max_value(self) -> float:
"""Return the maximum value."""
if self._hc_obj.max:
return self._hc_obj.max
return sys.maxsize
@property
def native_step(self) -> float:
"""Return the increment/decrement step."""
return self._hc_obj.stepsize
@property
def native_unit_of_measurement(self) -> str:
if self.has_entity_setting("unit"):
return self.get_entity_setting("unit")
if self._hc_obj.unit:
return self._hc_obj.unit
return ""
@property
def native_value(self) -> float:
"""Return the entity value to represent the entity state."""
return self._hc_obj.value
async def async_set_native_value(self, value: float) -> None:
try:
await self._appliance.async_apply_setting(self._key, value)
except HomeConnectError as ex:
if ex.error_description:
raise HomeAssistantError(f"Failed to apply the setting value: {ex.error_description} ({ex.code})")
raise HomeAssistantError(f"Failed to apply the setting value: ({ex.code})")
async def async_on_update(self, appliance:Appliance, key:str, value) -> None:
self.async_write_ha_state()
@@ -0,0 +1,339 @@
""" Implement the Select entities of this implementation """
from __future__ import annotations
import logging
from custom_components.home_connect_alt.time import DelayedOperationTime
from home_connect_async import Appliance, HomeConnect, HomeConnectError, Events, ConditionalLogger as CL
from homeassistant.components.select import SelectEntity
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType
from homeassistant.helpers.entity_registry import async_get
from .common import InteractiveEntityBase, EntityManager, is_boolean_enum, Configuration
from .const import CONF_DELAYED_OPS, CONF_DELAYED_OPS_ABSOLUTE_TIME, CONF_DELAYED_OPS_DEFAULT, CONF_TRANSLATION_MODE, CONF_TRANSLATION_MODE_SERVER, DEVICE_ICON_MAP, DOMAIN
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass:HomeAssistant , config_entry:ConfigType, async_add_entities:AddEntitiesCallback) -> None:
"""Add Selects for passed config_entry in HA."""
#homeconnect:HomeConnect = hass.data[DOMAIN]['homeconnect']
entry_conf:Configuration = hass.data[DOMAIN][config_entry.entry_id]
homeconnect:HomeConnect = entry_conf["homeconnect"]
entity_manager = EntityManager(async_add_entities, "Select")
def add_appliance(appliance:Appliance) -> None:
conf = entry_conf.get_config()
if appliance.available_programs:
device = ProgramSelect(appliance, None, conf)
entity_manager.add(device)
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" and (
entry_conf[CONF_DELAYED_OPS] == CONF_DELAYED_OPS_DEFAULT or not DelayedOperationTime.has_program_run_time(appliance)):
device = DelayedOperationSelect(appliance, option.key, conf, option)
# remove the TIME delayed operation entity if it exists
reg = async_get(hass)
time_entity = reg.async_get_entity_id("time", DOMAIN, device.unique_id)
if time_entity:
reg.async_remove(time_entity)
entity_manager.add(device)
elif option.allowedvalues and len(option.allowedvalues)>1:
device = OptionSelect(appliance, option.key, conf)
entity_manager.add(device)
if appliance.settings:
for setting in appliance.settings.values():
if (setting.allowedvalues and len(setting.allowedvalues)>1 and not is_boolean_enum(setting.allowedvalues)) \
and setting.access != "read" :
device = SettingsSelect(appliance, setting.key, conf)
entity_manager.add(device)
entity_manager.register()
def remove_appliance(appliance:Appliance) -> None:
entity_manager.remove_appliance(appliance)
homeconnect.register_callback(add_appliance, [Events.PAIRED, Events.DATA_CHANGED, Events.PROGRAM_STARTED, Events.PROGRAM_SELECTED])
homeconnect.register_callback(remove_appliance, Events.DEPAIRED)
for appliance in homeconnect.appliances.values():
add_appliance(appliance)
class ProgramSelect(InteractiveEntityBase, SelectEntity):
""" Selection of available programs """
@property
def unique_id(self) -> str:
return f'{self.haId}_programs'
@property
def translation_key(self) -> str:
return "programs"
@property
def name_ext(self) -> str:
return "Programs"
@property
def icon(self) -> str:
if self._appliance.type in DEVICE_ICON_MAP:
return DEVICE_ICON_MAP[self._appliance.type]
return None
@property
def device_class(self) -> str:
return f"{DOMAIN}__programs"
@property
def available(self) -> bool:
return super().available \
and self._appliance.available_programs \
and (
"BSH.Common.Status.RemoteControlActive" not in self._appliance.status or
self._appliance.status["BSH.Common.Status.RemoteControlActive"].value
)
@property
def options(self) -> list[str]:
"""Return a set of selectable options."""
if self._appliance.available_programs:
if self._conf[CONF_TRANSLATION_MODE] == CONF_TRANSLATION_MODE_SERVER:
return [program.name if program.name else program.key for program in self._appliance.available_programs.values()]
return list(self._appliance.available_programs.keys())
return []
@property
def current_option(self) -> str:
"""Return the selected entity option to represent the entity state."""
current_program = self._appliance.get_applied_program()
if current_program:
if self._appliance.available_programs and current_program.key in self._appliance.available_programs:
# The API sometimes returns programs which are not one of the available programs so we ignore it
CL.debug(_LOGGER, CL.LogMode.VERBOSE, "Current selected program is %s", current_program.key)
return current_program.name if current_program.name and self._conf[CONF_TRANSLATION_MODE] == CONF_TRANSLATION_MODE_SERVER else current_program.key
CL.debug(_LOGGER, CL.LogMode.VERBOSE, "Current program %s is not in available_programs", current_program.key)
else:
CL.debug(_LOGGER, CL.LogMode.VERBOSE, "Current program is None")
return None
async def async_select_option(self, option: str) -> None:
try:
if self._conf[CONF_TRANSLATION_MODE] == CONF_TRANSLATION_MODE_SERVER:
program = next((p for p in self._appliance.available_programs.values() if p.name == option), None)
await self._appliance.async_select_program(program_key=program.key)
else:
await self._appliance.async_select_program(program_key=option)
except HomeConnectError as ex:
if ex.error_description:
raise HomeAssistantError(f"Failed to set the selected program: {ex.error_description} ({ex.code} - {self._key}={option})")
raise HomeAssistantError(f"Failed to set the selected program ({ex.code} - {self._key}={option})")
async def async_on_update(self, appliance:Appliance, key:str, value) -> None:
self.async_write_ha_state()
class OptionSelect(InteractiveEntityBase, SelectEntity):
""" Selection of program options """
@property
def device_class(self) -> str:
return f"{DOMAIN}__options"
@property
def translation_key(self) -> str:
return "options"
@property
def name_ext(self) -> str|None:
if self._appliance.available_programs:
for program in self._appliance.available_programs.values():
if program.options and self._key in program.options and program.options[self._key].name:
return program.options[self._key].name
return None
@property
def icon(self) -> str:
return self.get_entity_setting('icon', 'mdi:office-building-cog')
@property
def available(self) -> bool:
return self.program_option_available
@property
def options(self) -> list[str]:
"""Return a set of selectable options."""
# if self.program_option_available:
# selected_program_key = self._appliance.selected_program.key
# available_program = self._appliance.available_programs.get(selected_program_key)
# if available_program:
# option = available_program.options.get(self._key)
# if option:
# #_LOGGER.info("Allowed values for %s : %s", self._key, str(option.allowedvalues))
# vals = option.allowedvalues.copy()
# vals.append('')
# return vals
# #_LOGGER.info("Allowed values for %s : %s", self._key, None)
option = self._appliance.get_applied_program_available_option(self._key)
if option:
if self._conf[CONF_TRANSLATION_MODE] == CONF_TRANSLATION_MODE_SERVER:
vals = option.allowedvaluesdisplay.copy() if option.allowedvaluesdisplay else option.allowedvalues.copy()
else:
vals = option.allowedvalues.copy()
#vals.append('')
return vals
return []
@property
def current_option(self) -> str:
"""Return the selected entity option to represent the entity state."""
# if self._appliance.selected_program.options[self._key].value not in self.options:
# _LOGGER.debug("The current option is not in the list of available options")
option = self._appliance.get_applied_program_option(self._key)
if option:
CL.debug(_LOGGER, CL.LogMode.VERBOSE, "Option %s current value: %s", self._key, option.value)
if self._conf[CONF_TRANSLATION_MODE] == CONF_TRANSLATION_MODE_SERVER:
available_option = self._appliance.get_applied_program_available_option(self._key)
if (available_option.allowedvaluesdisplay):
idx = available_option.allowedvalues.index(option.value)
return available_option.allowedvaluesdisplay[idx]
return option.value
CL.debug(_LOGGER, CL.LogMode.VERBOSE, "Option %s current value is None", self._key)
return None
async def async_select_option(self, option: str) -> None:
if option == '':
_LOGGER.debug('Tried to set an empty option')
return
try:
if self._conf[CONF_TRANSLATION_MODE] == CONF_TRANSLATION_MODE_SERVER:
available_option = self._appliance.get_applied_program_available_option(self._key)
if (available_option.allowedvaluesdisplay):
idx = available_option.allowedvaluesdisplay.index(option)
option = available_option.allowedvalues[idx]
await self._appliance.async_set_option(self._key, option)
except HomeConnectError as ex:
if ex.error_description:
raise HomeAssistantError(f"Failed to set the selected option: {ex.error_description} ({ex.code})")
raise HomeAssistantError(f"Failed to set the selected option: ({ex.code})")
async def async_on_update(self, appliance:Appliance, key:str, value) -> None:
self.async_write_ha_state()
class SettingsSelect(InteractiveEntityBase, SelectEntity):
""" Selection of settings """
@property
def device_class(self) -> str:
return f"{DOMAIN}__settings"
@property
def translation_key(self) -> str:
return "settings"
@property
def name_ext(self) -> str|None:
if self._key in self._appliance.settings and self._appliance.settings[self._key].name:
return self._appliance.settings[self._key].name
return None
@property
def icon(self) -> str:
return self.get_entity_setting('icon', 'mdi:tune')
@property
def available(self) -> bool:
return super().available \
and (
"BSH.Common.Status.RemoteControlActive" not in self._appliance.status or
self._appliance.status["BSH.Common.Status.RemoteControlActive"].value
)
@property
def options(self) -> list[str]:
"""Return a set of selectable options."""
try:
return self._appliance.settings[self._key].allowedvalues
except Exception as ex:
pass
return []
@property
def current_option(self) -> str:
"""Return the selected entity option to represent the entity state."""
return self._appliance.settings[self._key].value if self._appliance.settings and self._key in self._appliance.settings else None
async def async_select_option(self, option: str) -> None:
try:
await self._appliance.async_apply_setting(self._key, option)
except HomeConnectError as ex:
if ex.error_description:
raise HomeAssistantError(f"Failed to apply the setting: {ex.error_description} ({ex.code})")
raise HomeAssistantError(f"Failed to apply the setting: ({ex.code})")
async def async_on_update(self, appliance:Appliance, key:str, value) -> None:
self.async_write_ha_state()
class DelayedOperationSelect(InteractiveEntityBase, SelectEntity):
""" Class for delayed start select box """
def __init__(self, appliance: Appliance, key: str = None, conf: dict = None, hc_obj = None) -> None:
super().__init__(appliance, key, conf, hc_obj)
self._current = '0:00'
@property
def icon(self) -> str:
return self.get_entity_setting('icon', 'mdi:clock-outline')
@property
def name_ext(self) -> str|None:
""" Provide the suffix of the name, can be be overriden by sub-classes to provide a custom or translated display name """
return self._hc_obj.name if self._hc_obj.name else "Delayed operation"
@property
def available(self) -> bool:
available = super().program_option_available
if not available:
self._current = '0:00'
self._appliance.clear_startonly_option(self._key)
return available
@property
def options(self) -> list[str]:
options = [ "0:00" ]
if self._appliance.selected_program and self._appliance.selected_program.options and self._key in self._appliance.selected_program.options:
selected_program_time = self._appliance.selected_program.options[self._key].value
start = 1 if "StartInRelative" in self._key else selected_program_time//1800 + (selected_program_time % 1800 > 0)
#end = self._appliance.available_programs[self._appliance.selected_program.key].options[self._key].max
end = 49
for t in range(start, end):
options.append(f"{int(t/2)}:{(t%2)*30:02}")
else:
self._current = '0:00'
self._appliance.clear_startonly_option(self._key)
return options
@property
def current_option(self) -> str | None:
return self._current
async def async_select_option(self, option: str) -> None:
self._current = option
if option == '0:00':
self._appliance.clear_startonly_option(self._key)
return
parts = option.split(':')
delay = int(parts[0])*3600 + int(parts[1])*60
self._appliance.set_startonly_option(self._key, delay)
async def async_on_update(self, appliance:Appliance, key:str, value) -> None:
if key == Events.PROGRAM_FINISHED:
self._current = '0:00'
self.async_write_ha_state()
@@ -0,0 +1,371 @@
""" Implement the Sensor entities of this implementation """
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import logging
from typing import Any, Mapping
from home_connect_async import Appliance, HomeConnect, Events, HealthStatus
from homeassistant.components.sensor import SensorEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType
from .common import Configuration, EntityBase, EntityManager
from .const import (
CONF_TRANSLATION_MODE_SERVER,
DEVICE_ICON_MAP,
DOMAIN,
CONF_TRANSLATION_MODE,
HOME_CONNECT_DEVICE,
)
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigType, async_add_entities: AddEntitiesCallback,) -> None:
"""Add sensors for passed config_entry in HA"""
#homeconnect: HomeConnect = hass.data[DOMAIN]["homeconnect"]
entry_conf:Configuration = hass.data[DOMAIN][config_entry.entry_id]
homeconnect:HomeConnect = entry_conf["homeconnect"]
entity_manager = EntityManager(async_add_entities, "Sensor")
def add_appliance(appliance: Appliance) -> None:
if appliance.selected_program:
conf = entry_conf.get_config({"program_type": "selected"})
device = ProgramSensor(appliance, None, conf)
entity_manager.add(device)
if appliance.active_program:
conf = entry_conf.get_config({"program_type": "active"})
device = ProgramSensor(appliance, None, conf)
entity_manager.add(device)
conf = entry_conf.get_config()
if appliance.selected_program and appliance.selected_program.options:
for option in appliance.selected_program.options.values():
if not isinstance(option.value, bool):
device = ProgramOptionSensor(appliance, option.key, conf)
entity_manager.add(device)
if appliance.active_program and appliance.active_program.options:
for option in appliance.active_program.options.values():
if not isinstance(option.value, bool):
device = ProgramOptionSensor(appliance, option.key, conf)
entity_manager.add(device)
if appliance.status:
for (key, value) in appliance.status.items():
device = None
if not isinstance(value.value, bool) and conf.get_entity_setting(key, "type") != "Boolean": # should be a binary sensor if it has a boolean value
if "temperature" in key.lower():
conf.set_entity_setting(key,"class","temperature")
device = StatusSensor(appliance, key, conf)
entity_manager.add(device)
if appliance.settings:
for setting in appliance.settings.values():
conf = entry_conf.get_config()
if setting.type != "Boolean" and not isinstance(setting.value, bool) and conf.get_entity_setting(setting.key, "type") != "Boolean":
device = SettingsSensor(appliance, setting.key, conf)
entity_manager.add(device)
entity_manager.register()
def remove_appliance(appliance: Appliance) -> None:
entity_manager.remove_appliance(appliance)
# First add the global home connect status sensor
async_add_entities( [ HomeConnectStatusSensor(homeconnect, "" if entry_conf["primary_config_entry"] else "_"+config_entry.entry_id) ] )
# Subscribe for events and register the existing appliances
homeconnect.register_callback(add_appliance, [Events.PAIRED, Events.DATA_CHANGED, Events.PROGRAM_STARTED, Events.PROGRAM_SELECTED])
homeconnect.register_callback(remove_appliance, Events.DEPAIRED)
for appliance in homeconnect.appliances.values():
add_appliance(appliance)
class ProgramSensor(EntityBase, SensorEntity):
"""Selected program sensor"""
@property
def unique_id(self) -> str:
return f"{self.haId}_{self._conf['program_type']}_program"
@property
def name_ext(self) -> str:
return f"{self._conf['program_type'].capitalize()} Program"
@property
def translation_key(self) -> str:
return "programs"
@property
def icon(self) -> str:
if self._appliance.type in DEVICE_ICON_MAP:
return DEVICE_ICON_MAP[self._appliance.type]
return None
@property
def device_class(self) -> str:
return f"{DOMAIN}__programs"
@property
def native_value(self):
"""Return the state of the sensor."""
prog = self._appliance.selected_program if self._conf["program_type"] == "selected" else self._appliance.active_program
if prog:
if (prog.name and self._conf[CONF_TRANSLATION_MODE] == CONF_TRANSLATION_MODE_SERVER):
return prog.name
return prog.key
return None
async def async_on_update(self, appliance: Appliance, key: str, value) -> None:
_LOGGER.debug("Updating sensor %s => %s", self.unique_id, self.native_value)
self.async_write_ha_state()
class ProgramOptionSensor(EntityBase, SensorEntity):
"""Special active program sensor"""
@property
def device_class(self) -> str:
if self.has_entity_setting("class"):
return self.get_entity_setting("class")
return f"{DOMAIN}__options"
@property
def translation_key(self) -> str:
return "options"
@property
def icon(self) -> str:
return self.get_entity_setting("icon", "mdi:office-building-cog")
@property
def name_ext(self) -> str:
if self._appliance.selected_program and self._key in self._appliance.selected_program.options:
return self._appliance.selected_program.options[self._key].name
if self._appliance.active_program and self._key in self._appliance.active_program.options:
return self._appliance.active_program.options[self._key].name
return None
@property
def available(self) -> bool:
return (
(
self._appliance.selected_program
and (self._key in self._appliance.selected_program.options)
)
or (
self._appliance.active_program
and (self._key in self._appliance.active_program.options)
)
) and super().available
@property
def internal_unit(self) -> str | None:
"""Get the original unit before manipulations"""
unit = None
t = None
if self._appliance.active_program and self._key in self._appliance.active_program.options:
t = self._appliance.active_program.options[self._key].type
unit = self._appliance.active_program.options[self._key].unit
elif self._appliance.selected_program and self._key in self._appliance.selected_program.options:
t = self._appliance.selected_program.options[self._key].type
unit = self._appliance.selected_program.options[self._key].unit
if unit is None and t in ["Double", "Float", "Int"]:
return ""
return unit
@property
def native_unit_of_measurement(self) -> str | None:
if self.has_entity_setting("unit"):
return self.get_entity_setting("unit")
unit = self.internal_unit
if unit == "gram":
return "kg"
return unit
@property
def native_value(self):
"""Return the state of the sensor."""
program = (
self._appliance.active_program
if self._appliance.active_program
else self._appliance.selected_program
)
if program is None:
return None
if self._key not in program.options:
_LOGGER.debug("Option key %s is missing from program", self._key)
return None
option = program.options[self._key]
if self.device_class == "timestamp":
return datetime.now(timezone.utc).astimezone() + timedelta(
seconds=option.value
)
if self.device_class and "timespan" in self.device_class:
m, s = divmod(option.value, 60)
h, m = divmod(m, 60)
return f"{h}:{m:02d}"
if self.internal_unit == "gram":
return round(option.value / 1000, 1)
if (
option.displayvalue
and self._conf[CONF_TRANSLATION_MODE] == CONF_TRANSLATION_MODE_SERVER
):
return option.displayvalue
if (
isinstance(option.value, str)
and self._conf[CONF_TRANSLATION_MODE] == CONF_TRANSLATION_MODE_SERVER
):
if option.value.endswith(".Off"):
return "Off"
if option.value.endswith(".On"):
return "On"
return option.value
async def async_on_update(self, appliance: Appliance, key: str, value) -> None:
self.async_write_ha_state()
class StatusSensor(EntityBase, SensorEntity):
"""Status sensor"""
@property
def device_class(self) -> str:
return f"{DOMAIN}__status"
@property
def translation_key(self) -> str:
return "statuses"
@property
def name_ext(self) -> str:
if self._key in self._appliance.status:
status = self._appliance.status[self._key]
if status:
return status.name
return None
@property
def icon(self) -> str:
return self.get_entity_setting("icon", "mdi:gauge-full")
@property
def native_unit_of_measurement(self) -> str | None:
if self.has_entity_setting("unit"):
return self.get_entity_setting("unit")
status = self._appliance.status.get(self._key)
if status:
return status.unit
return None
@property
def native_value(self):
"""Return the state of the sensor."""
status = self._appliance.status.get(self._key)
if status:
if status.displayvalue and self._conf[CONF_TRANSLATION_MODE] == CONF_TRANSLATION_MODE_SERVER:
return status.displayvalue
return status.value
return None
async def async_on_update(self, appliance: Appliance, key: str, value) -> None:
self.async_write_ha_state()
class SettingsSensor(EntityBase, SensorEntity):
"""Settings sensor"""
@property
def device_class(self) -> str:
return f"{DOMAIN}__settings"
@property
def translation_key(self) -> str:
return "settings"
@property
def name_ext(self) -> str:
if self._key in self._appliance.settings:
setting = self._appliance.settings[self._key]
if setting:
return setting.name
return None
@property
def icon(self) -> str:
return self.get_entity_setting("icon", "mdi:tune")
@property
def native_unit_of_measurement(self) -> str | None:
if self.has_entity_setting("unit"):
return self.get_entity_setting("unit")
setting = self._appliance.settings.get(self._key)
if setting:
if setting.unit is None and setting.type in ["Double", "Float", "Int"]:
return ""
return setting.unit
return None
@property
def native_value(self):
"""Return the state of the sensor."""
setting = self._appliance.settings.get(self._key)
if setting:
if setting.displayvalue and self._conf[CONF_TRANSLATION_MODE] == CONF_TRANSLATION_MODE_SERVER:
return setting.displayvalue
return setting.value
return None
async def async_on_update(self, appliance: Appliance, key: str, value) -> None:
self.async_write_ha_state()
class HomeConnectStatusSensor(SensorEntity):
"""Global Home Connect status sensor"""
should_poll = True
_attr_has_entity_name = True
def __init__(self, homeconnect: HomeConnect, name_suffix:str) -> None:
self._homeconnect = homeconnect
self._name_suffix = name_suffix
self.entity_id = f"home_connect.{self.unique_id}"
@property
def device_info(self):
"""Return information to link this entity with the correct device."""
return HOME_CONNECT_DEVICE
@property
def unique_id(self) -> str:
return "homeconnect_status" + self._name_suffix
@property
def translation_key(self) -> str:
return "homeconnect_status"
@property
def available(self) -> bool:
return True
@property
def native_value(self):
return self._homeconnect.health.get_status().name
@property
def extra_state_attributes(self) -> Mapping[str, Any] | None:
return {
"blocked_until": self._homeconnect.health.get_blocked_until(),
"blocked_for": self._homeconnect.health.get_block_time_str(),
}
@@ -0,0 +1,116 @@
""" Implement the services of this implementation """
from home_connect_async import HomeConnect, HomeConnectError, Appliance
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr
class Services():
""" Collection of the Services offered by the integration """
def __init__(self, hass:HomeAssistant, homeconnect:HomeConnect) -> None:
self.homeconnect = homeconnect
self.hass = hass
self.dr = dr.async_get(hass)
async def async_select_program(self, call) -> None:
""" Service for selecting a program """
data = call.data
appliance = self.get_appliance_from_device_id(data['device_id'])
if appliance:
program_key = data['program_key']
options = data.get('options')
validate = data.get('validate')
try:
await appliance.async_select_program(program_key, options, validate)
except HomeConnectError as ex:
raise HomeAssistantError(ex.error_description if ex.error_description else ex.msg) from ex
async def async_start_program(self, call) -> None:
""" Service for starting the currently selected program """
data = call.data
appliance = self.get_appliance_from_device_id(data['device_id'])
if appliance:
program_key = data.get('program_key')
options = data.get('options')
validate = data.get('validate')
try:
await appliance.async_start_program(program_key, options, validate)
except HomeConnectError as ex:
raise HomeAssistantError(ex.error_description if ex.error_description else ex.msg) from ex
async def async_stop_program(self, call) -> None:
""" Service for stopping the currently active program """
data = call.data
appliance = self.get_appliance_from_device_id(data['device_id'])
if appliance:
try:
await appliance.async_stop_active_program()
except HomeConnectError as ex:
raise HomeAssistantError(ex.error_description if ex.error_description else ex.msg) from ex
async def async_pause_program(self, call) -> None:
""" Service for pausing the currently active program """
data = call.data
appliance = self.get_appliance_from_device_id(data['device_id'])
if appliance:
try:
await appliance.async_pause_active_program()
except HomeConnectError as ex:
raise HomeAssistantError(ex.error_description if ex.error_description else ex.msg) from ex
async def async_resume_program(self, call) -> None:
""" Service for stopping the currently active program """
data = call.data
appliance = self.get_appliance_from_device_id(data['device_id'])
if appliance:
try:
await appliance.async_resume_paused_program()
except HomeConnectError as ex:
raise HomeAssistantError(ex.error_description if ex.error_description else ex.msg) from ex
async def async_set_program_option(self, call) -> None:
""" Service for setting an option on the current program """
data = call.data
appliance = self.get_appliance_from_device_id(data['device_id'])
if appliance:
try:
await appliance.async_set_option(data['key'], data['value'])
except HomeConnectError as ex:
raise HomeAssistantError(ex.error_description if ex.error_description else ex.msg) from ex
except ValueError as ex:
raise HomeAssistantError(str(ex)) from ex
async def async_apply_setting(self, call) -> None:
""" Service for applying an appliance setting """
data = call.data
appliance = self.get_appliance_from_device_id(data['device_id'])
if appliance:
try:
await appliance.async_apply_setting(data['key'], data['value'])
except HomeConnectError as ex:
raise HomeAssistantError(ex.error_description if ex.error_description else ex.msg) from ex
except ValueError as ex:
raise HomeAssistantError(str(ex)) from ex
async def async_run_command(self, call) -> None:
""" Service for running a command on an appliance """
data = call.data
appliance = self.get_appliance_from_device_id(data['device_id'])
if appliance:
try:
await appliance.async_send_command(data['key'], data['value'])
except HomeConnectError as ex:
raise HomeAssistantError(ex.error_description if ex.error_description else ex.msg) from ex
except ValueError as ex:
raise HomeAssistantError(str(ex)) from ex
def get_appliance_from_device_id(self, device_id) -> Appliance|None:
""" Helper function to get an appliance from the Home Assistant device_id """
device = self.dr.devices[device_id]
haId = list(device.identifiers)[0][1]
for (key, appliance) in self.homeconnect.appliances.items():
if key.lower().replace('-','_') == haId:
return appliance
return None
@@ -0,0 +1,215 @@
select_program:
name: Select program
description: Select a program and optionally set the program options
fields:
device_id:
description: The ID of the appliance to start the program on
name: device_id
required: true
selector:
device:
integration: home_connect_alt
program_key:
name: Program
description: >
The full key of a valid program for the selected appliance
For example: ConsumerProducts.CoffeeMaker.Program.Beverage.Espresso
Documentation: https://api-docs.home-connect.com/programs-and-options
example: ConsumerProducts.CoffeeMaker.Program.Beverage.Espresso
required: true
selector:
text:
validate:
name: Validate
description: >
(Optional) Validate that the specified program_key is currently available to be called.
When this is set to false "startonly" programs will not be supported
required: false
default: true
advanced: true
selector:
boolean:
options:
name: Options
description: >
(Optional) A list of dictionaries with options for the program:
[
{ "key": "... option key ...", "value": "... option value ... "}
]
example: >
[
{ "key": "ConsumerProducts.CoffeeMaker.Option.BeanAmount", "value": "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.DoubleShot" },
{ "key": "ConsumerProducts.CoffeeMaker.Option.FillQuantity", "value": 50 },
]
required: false
selector:
object:
start_program:
name: Start program
description: Start the currently selected program
fields:
device_id:
description: The ID of the appliance to start the program on
name: device_id
required: true
selector:
device:
integration: home_connect_alt
program_key:
name: Program
description: >
The full key of a valid program for the selected appliance, if not specified
will use the currently selected program
For example: ConsumerProducts.CoffeeMaker.Program.Beverage.Espresso
Documentation: https://api-docs.home-connect.com/programs-and-options
example: ConsumerProducts.CoffeeMaker.Program.Beverage.Espresso
required: true
selector:
text:
validate:
name: Validate
description: >
(Optional) Validate that the specified program_key is currently available to be called.
required: false
default: true
advanced: true
selector:
boolean:
options:
name: Options
description: >
(Optional) A list of dictionaries with options for the program:
[
{ "key": "... option key ...", "value": "... option value ... "}
]
example: >
[
{ "key": "ConsumerProducts.CoffeeMaker.Option.BeanAmount", "value": "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.DoubleShot" },
{ "key": "ConsumerProducts.CoffeeMaker.Option.FillQuantity", "value": 50 },
]
required: false
selector:
object:
stop_program:
name: Stop program
description: Stop the currently active program
fields:
device_id:
description: The ID of the appliance to stop the program on
name: device_id
required: true
selector:
device:
integration: home_connect_alt
pause_program:
name: Pause program
description: Pause the currently active program (if and when supported by the appliance)
fields:
device_id:
description: The ID of the appliance to pause the program on
name: device_id
required: true
selector:
device:
integration: home_connect_alt
resume_program:
name: Resume program
description: Resumes a paused program (if and when supported by the appliance)
fields:
device_id:
description: The ID of the appliance to resume the program on
name: device_id
required: true
selector:
device:
integration: home_connect_alt
set_program_option:
name: Set program option
description: Sets an option for the currently selected or active program
fields:
device_id:
description: The ID of the appliance to start the program on
name: device_id
required: true
selector:
device:
integration: home_connect_alt
key:
name: Option key
description: >
The ENUM key of an option which is available for the current program
example: ConsumerProducts.CoffeeMaker.Program.Beverage.Espresso
required: true
selector:
text:
value:
name: Option value
description: >
An allowed value for the specified option
example: ConsumerProducts.CoffeeMaker.Program.Beverage.Espresso
required: true
selector:
text:
apply_setting:
name: Apply setting
description: Applies a Home Connect setting
fields:
device_id:
description: The ID of the appliance to apply the settings on
name: device_id
required: true
selector:
device:
integration: home_connect_alt
key:
name: Setting key
description: >
The ENUM key of a setting which is available for the specified appliance
example: ConsumerProducts.CoffeeMaker.Setting.CupWarmer
required: true
selector:
text:
value:
name: Setting value
description: >
An allowed value for the specified setting.
Note that if the setting type is not a string or ENUM (eg. Boolean), it must be specified in YAML in the correct format for its data type
example: true
required: true
selector:
text:
run_command:
name: Run command
description: Runs a command on the appliance (must be available to run on the appliance)
fields:
device_id:
description: The ID of the appliance to run the command on
name: device_id
required: true
selector:
device:
integration: home_connect_alt
key:
name: Command key
description: >
The key of the command to run
example: BSH.Common.Command.OpenDoor
required: true
selector:
text:
value:
name: The command value
description: >
An allowed value for the specified command.
Note that if the setting type is not a string or ENUM (eg. Boolean), it must be specified in YAML in the correct format for its data type
example: true
required: true
selector:
text:
@@ -0,0 +1,184 @@
""" Implement the Switch entities of this implementation """
from __future__ import annotations
import logging
from typing import Any
from home_connect_async import Appliance, HomeConnect, HomeConnectError, Events
from homeassistant.components.switch import SwitchEntity
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType
from .common import InteractiveEntityBase, EntityManager, is_boolean_enum, Configuration
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass:HomeAssistant , config_entry:ConfigType, async_add_entities:AddEntitiesCallback) -> None:
"""Add sensors for passed config_entry in HA."""
#homeconnect:HomeConnect = hass.data[DOMAIN]['homeconnect']
entry_conf:Configuration = hass.data[DOMAIN][config_entry.entry_id]
homeconnect:HomeConnect = entry_conf["homeconnect"]
entity_manager = EntityManager(async_add_entities, "Switch")
def add_appliance(appliance:Appliance) -> None:
conf = entry_conf.get_config()
if appliance.available_programs:
for program in appliance.available_programs.values():
if program.options:
for option in program.options.values():
if ( not conf.has_entity_setting(option.key, "type") and (option.type == "Boolean" or isinstance(option.value, bool))) \
or conf.get_entity_setting(option.key, "type") == "Boolean" :
device = OptionSwitch(appliance, option.key, conf)
entity_manager.add(device)
if appliance.settings:
for setting in appliance.settings.values():
if ( (not conf.has_entity_setting(setting.key, "type")
and ( setting.type == "Boolean" or isinstance(setting.value, bool) or is_boolean_enum(setting.allowedvalues))) \
or conf.get_entity_setting(setting.key, "type") == "Boolean") \
and setting.access != "read" :
device = SettingsSwitch(appliance, setting.key, conf)
entity_manager.add(device)
entity_manager.register()
def remove_appliance(appliance:Appliance) -> None:
entity_manager.remove_appliance(appliance)
homeconnect.register_callback(add_appliance, [Events.PAIRED, Events.DATA_CHANGED, Events.PROGRAM_STARTED, Events.PROGRAM_SELECTED])
homeconnect.register_callback(remove_appliance, Events.DEPAIRED)
for appliance in homeconnect.appliances.values():
add_appliance(appliance)
class OptionSwitch(InteractiveEntityBase, SwitchEntity):
""" Switch for binary options """
@property
def device_class(self) -> str:
return f"{DOMAIN}__options"
@property
def name_ext(self) -> str|None:
if self._appliance.available_programs:
for program in self._appliance.available_programs.values():
if program.options and self._key in program.options and program.options[self._key].name:
return program.options[self._key].name
return None
@property
def icon(self) -> str:
return self.get_entity_setting('icon', 'mdi:office-building-cog')
@property
def available(self) -> bool:
return self.program_option_available
@property
def is_on(self) -> bool:
"""Return True if entity is on."""
option = self._appliance.get_applied_program_option(self._key)
if option:
return option.value
return None
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on."""
try:
await self._appliance.async_set_option(self._key, True)
except HomeConnectError as ex:
if ex.error_description:
raise HomeAssistantError(f"Failed to set the option: {ex.error_description} ({ex.code})")
raise HomeAssistantError(f"Failed to set the option: ({ex.code})")
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the entity off."""
try:
await self._appliance.async_set_option(self._key, False)
except HomeConnectError as ex:
if ex.error_description:
raise HomeAssistantError(f"Failed to set the option: {ex.error_description} ({ex.code})")
raise HomeAssistantError(f"Failed to set the option: ({ex.code})")
async def async_on_update(self, appliance:Appliance, key:str, value) -> None:
self.async_write_ha_state()
class SettingsSwitch(InteractiveEntityBase, SwitchEntity):
""" Switch for binary settings """
@property
def device_class(self) -> str:
return f"{DOMAIN}__settings"
@property
def name_ext(self) -> str|None:
if self._key in self._appliance.settings and self._appliance.settings[self._key].name:
return self._appliance.settings[self._key].name
return None
@property
def icon(self) -> str:
return self.get_entity_setting('icon', 'mdi:tune')
@property
def available(self) -> bool:
return self._key in self._appliance.settings \
and super().available \
and (
"BSH.Common.Status.RemoteControlActive" not in self._appliance.status or
self._appliance.status["BSH.Common.Status.RemoteControlActive"].value
)
@property
def is_on(self) -> bool:
"""Return True if entity is on."""
if self._key in self._appliance.settings:
setting = self._appliance.settings[self._key]
if setting.allowedvalues and setting.value.lower().endswith(".off"):
return False
if setting.allowedvalues and setting.value.lower().endswith(".on"):
return True
return setting.value
return None
def bool_to_enum(self, allowedvalues, val:bool) -> str:
""" Get the matching enum value for the provided boolean value """
for av in allowedvalues:
if (val and av.lower().endswith('.on')) or (not val and av.lower().endswith('.off')) :
return av
_LOGGER.error("Unexpected Error: couldn't find a boolean enum value in allowedvalues: %s", allowedvalues)
return None
async def async_turn_on(self, **kwargs: Any) -> None:
try:
setting = self._appliance.settings[self._key]
if setting.allowedvalues:
await self._appliance.async_apply_setting(self._key, self.bool_to_enum(setting.allowedvalues, True))
else:
await self._appliance.async_apply_setting(self._key, True)
except HomeConnectError as ex:
if ex.error_description:
raise HomeAssistantError(f"Failed to apply the setting: {ex.error_description} ({ex.code})")
raise HomeAssistantError(f"Failed to apply the setting: ({ex.code})")
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the entity off."""
try:
setting = self._appliance.settings[self._key]
if setting.allowedvalues:
await self._appliance.async_apply_setting(self._key, self.bool_to_enum(setting.allowedvalues, False))
else:
await self._appliance.async_apply_setting(self._key, False)
except HomeConnectError as ex:
if ex.error_description:
raise HomeAssistantError(f"Failed to apply the setting: {ex.error_description} ({ex.code})")
raise HomeAssistantError(f"Failed to apply the setting: ({ex.code})")
async def async_on_update(self, appliance:Appliance, key:str, value) -> None:
self.async_write_ha_state()
+168
View File
@@ -0,0 +1,168 @@
from __future__ import annotations
import logging
import datetime
from homeassistant.components.time import TimeEntity, time, timedelta
from home_connect_async import Appliance, HomeConnect, HomeConnectError, Events, ConditionalLogger as CL
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType
from homeassistant.helpers.entity_registry import async_get
from .common import InteractiveEntityBase, EntityManager, is_boolean_enum, Configuration
from .const import CONF_DELAYED_OPS, CONF_DELAYED_OPS_ABSOLUTE_TIME, DOMAIN
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass:HomeAssistant , config_entry:ConfigType, async_add_entities:AddEntitiesCallback) -> None:
"""Add Selects for passed config_entry in HA."""
entry_conf:Configuration = hass.data[DOMAIN][config_entry.entry_id]
homeconnect:HomeConnect = entry_conf["homeconnect"]
entity_manager = EntityManager(async_add_entities, "Time")
def add_appliance(appliance:Appliance) -> None:
conf = entry_conf.get_config()
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" \
and entry_conf[CONF_DELAYED_OPS]==CONF_DELAYED_OPS_ABSOLUTE_TIME \
and DelayedOperationTime.has_program_run_time(appliance):
device = DelayedOperationTime(appliance, option.key, conf, option)
# remove the SELECT delayed operation entity if it exists
reg = async_get(hass)
select_entity = reg.async_get_entity_id("select", DOMAIN, device.unique_id)
if select_entity:
reg.async_remove(select_entity)
entity_manager.add(device)
entity_manager.register()
def remove_appliance(appliance:Appliance) -> None:
entity_manager.remove_appliance(appliance)
homeconnect.register_callback(add_appliance, [Events.PAIRED, Events.DATA_CHANGED, Events.PROGRAM_STARTED, Events.PROGRAM_SELECTED])
homeconnect.register_callback(remove_appliance, Events.DEPAIRED)
for appliance in homeconnect.appliances.values():
add_appliance(appliance)
class DelayedOperationTime(InteractiveEntityBase, TimeEntity):
""" Class for setting delayed start by the program end time """
should_poll = True
def __init__(self, appliance: Appliance, key: str = None, conf: dict = None, hc_obj = None) -> None:
super().__init__(appliance, key, conf, hc_obj)
self._current:time = None
@property
def name_ext(self) -> str|None:
return self._hc_obj.name if self._hc_obj.name else "Delayed operation"
@property
def icon(self) -> str:
return self.get_entity_setting('icon', 'mdi:clock-outline')
@property
def available(self) -> bool:
# We must have the program run time for this entity to work
available = super().program_option_available and self.get_program_run_time(self._appliance) is not None
if not available:
self._appliance.clear_startonly_option(self._key)
return available
async def async_set_value(self, value: time) -> None:
"""Update the current value."""
self._current = self.adjust_time(value, True)
#self.async_write_ha_state()
@property
def native_value(self) -> time:
"""Return the entity value to represent the entity state."""
if self._current is None:
self._current = self.init_time()
if self._appliance.startonly_options and self._key in self._appliance.startonly_options:
self._current = self.adjust_time(self._current, True)
else:
self._current = self.adjust_time(self._current, False)
return self._current
def adjust_time(self, t:time, set_option:bool) -> time|None:
""" Adjust the time state when required """
now = datetime.datetime.now()
endtime = datetime.datetime(year=now.year, month=now.month, day=now.day, hour=t.hour, minute=t.minute)
if (now.hour > endtime.hour) or (now.hour == endtime.hour and now.minute > endtime.minute):
# if the specified time is smaller than now then it means tomorrow
endtime += datetime.timedelta(days=1)
program_run_time = self.get_program_run_time(self._appliance)
if not program_run_time:
return None
if endtime < now + timedelta(seconds=program_run_time):
# the set end time is closer then the program run time so change it to the expected end of the program
# and cancel the set delay option
endtime = now + timedelta(seconds=program_run_time)
#self._current = time(hour=endtime.hour, minute=endtime.minute)
if self._appliance.startonly_options and self._key in self._appliance.startonly_options:
_LOGGER.debug("Clearing startonly option %s", self._key)
self._appliance.clear_startonly_option(self._key)
elif set_option:
delay = (endtime-now).total_seconds()
if "StartInRelative" in self._key:
delay -= program_run_time
# round the delay to the stepsize
stepsize_option = self._appliance.get_applied_program_available_option(self._key)
stepsize = stepsize_option.stepsize if stepsize_option and stepsize_option.stepsize and stepsize_option.stepsize != 0 else 60
delay = int(delay/stepsize)*stepsize
_LOGGER.debug("Setting startonly option %s to: %i", self._key, delay)
self._appliance.set_startonly_option(self._key, delay)
return time(hour=endtime.hour, minute=endtime.minute)
def init_time(self) -> time:
""" Initialize the time state """
inittime = datetime.datetime.now() + timedelta(minutes=1)
t = time(hour=inittime.hour, minute=inittime.minute)
return self.adjust_time(t, False)
@classmethod
def get_program_run_time(cls, appliance:Appliance) -> int|None:
""" Try to get the expected run time of the selected program or the remaining time of the running program """
time_option_keys = [
"BSH.Common.Option.RemainingProgramTime",
"BSH.Common.Option.FinishInRelative",
"BSH.Common.Option.EstimatedTotalProgramTime",
]
for key in time_option_keys:
o = appliance.get_applied_program_option(key)
if o:
return o.value
return None
@classmethod
def has_program_run_time(cls, appliance:Appliance) ->bool:
""" Check if it's possible to get a program run time estimate """
return cls.get_program_run_time(appliance) is not None
async def async_on_update(self, appliance:Appliance, key:str, value) -> None:
# reset the end time clock when a different program is selected
if key == Events.PROGRAM_SELECTED or "RemoteControlStartAllowed" in key:
self._current = self.init_time()
self.async_write_ha_state()
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff