Initial Home Assistant Configuration
This commit is contained in:
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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
Reference in New Issue
Block a user