""" Alexa Devices Switches. SPDX-License-Identifier: Apache-2.0 For more details about this platform, please refer to the documentation at https://community.home-assistant.io/t/echo-devices-alexa-as-media-player-testers-needed/58639 """ import datetime import logging from alexapy import AlexaAPI from homeassistant.exceptions import ConfigEntryNotReady, NoEntitySpecifiedError from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import ( CONF_EMAIL, CONF_EXCLUDE_DEVICES, CONF_INCLUDE_DEVICES, DATA_ALEXAMEDIA, DOMAIN as ALEXA_DOMAIN, hide_email, hide_serial, ) from .alexa_entity import parse_power_from_coordinator from .alexa_media import AlexaMedia from .const import CONF_EXTENDED_ENTITY_DISCOVERY from .helpers import _catch_login_errors, add_devices, safe_get try: from homeassistant.components.switch import SwitchEntity as SwitchDevice except ImportError: from homeassistant.components.switch import SwitchDevice _LOGGER = logging.getLogger(__name__) async def async_setup_platform(hass, config, add_devices_callback, discovery_info=None): """Set up the Alexa switch platform.""" devices: list[DNDSwitch] = [] SWITCH_TYPES = [ # pylint: disable=invalid-name ("dnd", DNDSwitch), ("shuffle", ShuffleSwitch), ("repeat", RepeatSwitch), ] account = None if config: account = config.get(CONF_EMAIL) if account is None and discovery_info: account = safe_get(discovery_info, ["config", CONF_EMAIL]) if account is None: raise ConfigEntryNotReady include_filter = config.get(CONF_INCLUDE_DEVICES, []) exclude_filter = config.get(CONF_EXCLUDE_DEVICES, []) account_dict = hass.data[DATA_ALEXAMEDIA]["accounts"][account] _LOGGER.debug("%s: Loading switches", hide_email(account)) if "switch" not in account_dict["entities"]: hass.data[DATA_ALEXAMEDIA]["accounts"][account]["entities"]["switch"] = {} for key, _ in account_dict["devices"]["media_player"].items(): if key not in account_dict["entities"]["media_player"]: _LOGGER.debug( "%s: Media player %s not loaded yet; delaying load", hide_email(account), hide_serial(key), ) raise ConfigEntryNotReady if key not in ( hass.data[DATA_ALEXAMEDIA]["accounts"][account]["entities"]["switch"] ): hass.data[DATA_ALEXAMEDIA]["accounts"][account]["entities"]["switch"][ key ] = {} for switch_key, class_ in SWITCH_TYPES: if ( switch_key == "dnd" and not safe_get(account_dict, ["devices", "switch", key, "dnd"]) ) or ( switch_key in ["shuffle", "repeat"] and "MUSIC_SKILL" not in account_dict["devices"]["media_player"] .get(key, {}) .get("capabilities", {}) ): _LOGGER.debug( "%s: Skipping %s for %s", hide_email(account), switch_key, hide_serial(key), ) continue alexa_client = class_( account_dict["entities"]["media_player"][key] ) # type: AlexaMediaSwitch _LOGGER.debug( "%s: Found %s %s switch with status: %s", hide_email(account), hide_serial(key), switch_key, alexa_client.is_on, ) devices.append(alexa_client) ( hass.data[DATA_ALEXAMEDIA]["accounts"][account]["entities"][ "switch" ][key][switch_key] ) = alexa_client else: for alexa_client in hass.data[DATA_ALEXAMEDIA]["accounts"][account][ "entities" ]["switch"][key].values(): _LOGGER.debug( "%s: Skipping already added device: %s", hide_email(account), alexa_client, ) # Add Amazon Smart Plug devices switch_entities = safe_get(account_dict, ["devices", "smart_switch"], []) hue_emulated_enabled = "emulated_hue" in hass.config.as_dict().get( "components", set() ) if switch_entities and account_dict["options"].get(CONF_EXTENDED_ENTITY_DISCOVERY): for switch_entity in switch_entities: if not (switch_entity["is_hue_v1"] and hue_emulated_enabled): _LOGGER.debug( "Creating entity %s for a switch with name %s", hide_serial(switch_entity["id"]), switch_entity["name"], ) coordinator = account_dict["coordinator"] switch = SmartSwitch( coordinator, account_dict["login_obj"], switch_entity ) account_dict["entities"]["smart_switch"].append(switch) devices.append(switch) else: _LOGGER.debug( "Switch '%s' has not been added because it may originate from emulated_hue", switch_entity["name"], ) return await add_devices( hide_email(account), devices, add_devices_callback, include_filter, exclude_filter, ) async def async_setup_entry(hass, config_entry, async_add_devices): """Set up the Alexa switch platform by config_entry.""" return await async_setup_platform( hass, config_entry.data, async_add_devices, discovery_info=None ) async def async_unload_entry(hass, entry) -> bool: """Unload a config entry.""" account = entry.data[CONF_EMAIL] _LOGGER.debug("Attempting to unload switch") account_dict = hass.data[DATA_ALEXAMEDIA]["accounts"][account] for key, switches in account_dict["entities"]["switch"].items(): for device in switches[key].values(): _LOGGER.debug("Removing %s", device) await device.async_remove() return True class AlexaMediaSwitch(SwitchDevice, AlexaMedia): """Representation of a Alexa Media switch.""" _attr_has_entity_name = True def __init__( self, client, switch_property: str, switch_function: str, unique_id_suffix: str = "switch", ): """Initialize the Alexa Switch device.""" # Class info self._client = client self._unique_id_suffix = unique_id_suffix self._switch_property = switch_property self._switch_function = switch_function super().__init__(client, client._login) async def async_added_to_hass(self): """Store register state change callback.""" try: if not self.enabled: return except AttributeError: pass # Register event handler on bus self._listener = async_dispatcher_connect( self.hass, f"{ALEXA_DOMAIN}_{hide_email(self.email)}"[0:32], self._handle_event, ) async def async_will_remove_from_hass(self): """Prepare to remove entity.""" # Register event handler on bus self._listener() def _handle_event(self, event): """Handle events. This will update PUSH_MEDIA_QUEUE_CHANGE events to see if the switch should be updated. """ try: if not self.enabled: return except AttributeError: pass if "queue_state" in event: queue_state = event["queue_state"] if queue_state["dopplerId"]["deviceSerialNumber"] == self._client.unique_id: self.schedule_update_ha_state() @_catch_login_errors async def _set_switch(self, state, **kwargs): # pylint: disable=unused-argument try: if not self.enabled: return except AttributeError: pass success = await getattr(self.alexa_api, self._switch_function)(state) # if function returns success, make immediate state change if success: setattr(self._client, self._switch_property, state) _LOGGER.debug( "Setting %s to %s", self.name, getattr(self._client, self._switch_property), ) self.schedule_update_ha_state() elif self.should_poll: # if we need to poll, refresh media_client _LOGGER.debug( "Requesting update of %s due to %s switch to %s", self._client, self._unique_id_suffix, state, ) await self._client.async_update() @property def is_on(self): """Return true if on.""" return self.available and getattr(self._client, self._switch_property) async def async_turn_on(self, **kwargs): """Turn on switch.""" await self._set_switch(True, **kwargs) async def async_turn_off(self, **kwargs): """Turn off switch.""" await self._set_switch(False, **kwargs) @property def available(self): """Return the availability of the switch.""" return ( self._client.available and getattr(self._client, self._switch_property) is not None ) @property def assumed_state(self): """Return whether the state is an assumed_state.""" return self._client.assumed_state @property def unique_id(self): """Return the unique ID.""" return self._client.unique_id + "_" + self._unique_id_suffix @property def device_class(self): """Return the device_class of the switch.""" return "switch" @property def hidden(self): """Return whether the switch should be hidden from the UI.""" return not self.available @property def should_poll(self): """Return the polling state.""" return True @_catch_login_errors async def async_update(self): """Update state.""" try: if not self.enabled: return except AttributeError: pass try: self.schedule_update_ha_state() except NoEntitySpecifiedError: pass # we ignore this due to a harmless startup race condition @property def device_info(self): """Return device_info for device registry.""" return { "identifiers": {(ALEXA_DOMAIN, self._client.unique_id)}, "via_device": (ALEXA_DOMAIN, self._client.unique_id), } @property def icon(self): """Return the icon of the switch.""" return self._icon() def _icon(self, on=None, off=None): # pylint: disable=invalid-name return on if self.is_on else off class DNDSwitch(AlexaMediaSwitch): """Representation of a Alexa Media Do Not Disturb switch.""" _attr_translation_key = "do_not_disturb" def __init__(self, client): """Initialize the Alexa Switch.""" # Class info super().__init__( client, "dnd_state", "set_dnd_state", "do not disturb", # Keep original suffix for backward compatibility ) @property def icon(self): """Return the icon of the switch.""" return super()._icon("mdi:minus-circle", "mdi:minus-circle-off") @property def entity_category(self): """Return the entity category of the switch.""" return EntityCategory.CONFIG def _handle_event(self, event): """Handle events.""" try: if not self.enabled: return except AttributeError: pass if "dnd_update" in event: result = list( filter( lambda x: x["deviceSerialNumber"] == self._client.device_serial_number, event["dnd_update"], ) ) if result: state = result[0]["enabled"] is True if state != self.is_on: _LOGGER.debug("Detected %s changed to %s", self, state) setattr(self._client, self._switch_property, state) self.schedule_update_ha_state() class ShuffleSwitch(AlexaMediaSwitch): """Representation of a Alexa Media Shuffle switch.""" _attr_translation_key = "shuffle" def __init__(self, client): """Initialize the Alexa Switch.""" # Class info super().__init__(client, "shuffle", "shuffle", "shuffle") @property def icon(self): """Return the icon of the switch.""" return super()._icon("mdi:shuffle", "mdi:shuffle-disabled") @property def entity_category(self): """Return the entity category of the switch.""" return EntityCategory.CONFIG class RepeatSwitch(AlexaMediaSwitch): """Representation of a Alexa Media Repeat switch.""" _attr_translation_key = "repeat" def __init__(self, client): """Initialize the Alexa Switch.""" # Class info super().__init__(client, "repeat_state", "repeat", "repeat") @property def icon(self): """Return the icon of the switch.""" return super()._icon("mdi:repeat", "mdi:repeat-off") @property def entity_category(self): """Return the entity category of the switch.""" return EntityCategory.CONFIG class SmartSwitch(CoordinatorEntity, SwitchDevice): def __init__(self, coordinator, login, details): """Initialize alexa light entity.""" super().__init__(coordinator) self.alexa_entity_id = details["id"] self._name = details["name"] self._login = login # Store the requested state from the last call to _set_state # This is so that no new network call is needed just to get values that are already known # This is useful because refreshing the full state can take a bit when many switches are in play. # Especially since Alexa actually polls the switches and that appears to be error-prone with some Zigbee lights. # That delay(1-5s in practice) causes the UI controls to jump all over the place after _set_state self._requested_state_at = None # When was state last set in UTC self._requested_power = None @property def name(self): """Return name.""" return self._name @property def unique_id(self): """Return unique id.""" return self.alexa_entity_id @property def is_on(self): """Return whether on.""" power = parse_power_from_coordinator( self.coordinator, self.alexa_entity_id, self._requested_state_at ) if power is None: return self._requested_power if self._requested_power is not None else False return power == "ON" @property def assumed_state(self) -> bool: """Return whether state is assumed.""" last_refresh_success = ( self.coordinator.data and self.alexa_entity_id in self.coordinator.data ) return not last_refresh_success async def _set_state(self, power_on: bool) -> None: response = await AlexaAPI.set_light_state( self._login, self.alexa_entity_id, power_on, ) if not isinstance(response, dict): # If something failed any state is possible, fallback to a full refresh await self.coordinator.async_request_refresh() return control_responses = response.get("controlResponses", []) for ctrl_resp in control_responses: if ctrl_resp.get("code") != "SUCCESS": # If something failed any state is possible, fallback to a full refresh await self.coordinator.async_request_refresh() return self._requested_power = power_on self._requested_state_at = datetime.datetime.now( datetime.timezone.utc ) # must be set last so that previous getters work properly self.schedule_update_ha_state() # Confirm quickly, but debounce to avoid spamming across multiple entities. account = self.hass.data[DATA_ALEXAMEDIA]["accounts"].get(self._login.email) if account: debouncer = account.get("confirm_refresh_debouncer") if debouncer: await debouncer.async_call() async def async_turn_on(self, **kwargs): """Turn on.""" await self._set_state(True) async def async_turn_off(self, **kwargs): # pylint:disable=unused-argument """Turn off.""" await self._set_state(False)