503 lines
17 KiB
Python
503 lines
17 KiB
Python
"""
|
|
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)
|