""" Alexa Devices notification service. 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 asyncio import json import logging from alexapy.helpers import hide_email, hide_serial from homeassistant.components.notify import ( ATTR_DATA, ATTR_TARGET, ATTR_TITLE, ATTR_TITLE_DEFAULT, SERVICE_NOTIFY, BaseNotificationService, ) from homeassistant.const import CONF_EMAIL from homeassistant.helpers.group import expand_entity_ids import voluptuous as vol from .const import ( CONF_QUEUE_DELAY, DATA_ALEXAMEDIA, DEFAULT_QUEUE_DELAY, DOMAIN, NOTIFY_URL, ) from .helpers import retry_async _LOGGER = logging.getLogger(__name__) @retry_async(limit=5, delay=2, catch_exceptions=True) async def async_get_service(hass, config, discovery_info=None): # pylint: disable=unused-argument """Get the demo notification service.""" result = False for account, account_dict in hass.data[DATA_ALEXAMEDIA]["accounts"].items(): 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), ) return False result = hass.data[DATA_ALEXAMEDIA]["notify_service"] = AlexaNotificationService( hass ) return result async def async_unload_entry(hass, entry) -> bool: """Unload a config entry.""" _LOGGER.debug("Attempting to unload notify") target_account = entry.data[CONF_EMAIL] other_accounts = False for account, account_dict in hass.data[DATA_ALEXAMEDIA]["accounts"].items(): if account == target_account: if "entities" not in account_dict: continue for device in account_dict["entities"]["media_player"].values(): if device.entity_id: entity_id = device.entity_id.split(".") hass.services.async_remove( SERVICE_NOTIFY, f"{DOMAIN}_{entity_id[1]}" ) else: other_accounts = True if not other_accounts: hass.services.async_remove(SERVICE_NOTIFY, f"{DOMAIN}") if hass.data[DATA_ALEXAMEDIA].get("notify_service"): hass.data[DATA_ALEXAMEDIA].pop("notify_service") return True class AlexaNotificationService(BaseNotificationService): """Implement Alexa Media Player notification service.""" def __init__(self, hass): """Initialize the service.""" self.hass = hass self.last_called = True def convert(self, names, type_="entities", filter_matches=False): """Return a list of converted Alexa devices based on names. Names may be matched either by serialNumber, accountName, or Homeassistant entity_id and can return any of the above plus entities Parameters ---------- names : list(string) A list of names to convert type_ : string The type to return entities, entity_ids, serialnumbers, names filter_matches : bool Whether non-matching items are removed from the returned list. Returns ------- list(string) List of home assistant entity_ids """ devices = [] if isinstance(names, str): names = [names] for item in names: matched = False for alexa in self.devices: # _LOGGER.debug( # "Testing item: %s against (%s, %s, %s, %s)", # item, # alexa, # alexa.name, # hide_serial(alexa.unique_id), # alexa.entity_id, # ) if item in ( alexa, alexa.name, alexa.unique_id, alexa.entity_id, alexa.device_serial_number, ): if type_ == "entities": converted = alexa elif type_ == "serialnumbers": converted = alexa.device_serial_number elif type_ == "names": converted = alexa.name elif type_ == "entity_ids": converted = alexa.entity_id devices.append(converted) matched = True # _LOGGER.debug("Converting: %s to (%s): %s", item, type_, converted) if not filter_matches and not matched: devices.append(item) return devices @property def targets(self): """Return a dictionary of Alexa devices.""" devices = {} for email, account_dict in self.hass.data[DATA_ALEXAMEDIA]["accounts"].items(): if "entities" not in account_dict: continue last_called_entity = None for _, entity in account_dict["entities"]["media_player"].items(): if entity is None or entity.entity_id is None: continue entity_name = (entity.entity_id).split(".")[1] devices[entity_name] = entity.unique_id if self.last_called and entity.extra_state_attributes.get( "last_called" ): attrs = entity.extra_state_attributes try: ts = int(attrs.get("last_called_timestamp") or 0) except (TypeError, ValueError): ts = 0 if last_called_entity is None: last_called_entity = entity else: best_attrs = last_called_entity.extra_state_attributes try: best_ts = int(best_attrs.get("last_called_timestamp") or 0) except (TypeError, ValueError): best_ts = 0 if ts > best_ts: last_called_entity = entity if last_called_entity is not None: entity_name = (last_called_entity.entity_id).split(".")[1] entity_name_last_called = ( f"last_called{'_'+ email if entity_name[-1:].isdigit() else ''}" ) devices[entity_name_last_called] = last_called_entity.unique_id return devices @property def devices(self): """Return a list of Alexa devices.""" devices = [] if ( "accounts" not in self.hass.data[DATA_ALEXAMEDIA] or not self.hass.data[DATA_ALEXAMEDIA]["accounts"].items() ): return devices for _, account_dict in self.hass.data[DATA_ALEXAMEDIA]["accounts"].items(): devices = devices + list(account_dict["entities"]["media_player"].values()) return devices async def async_send_message(self, message="", **kwargs): # pylint: disable=too-many-branches """Send a message to an Alexa device.""" _LOGGER.debug("Message: %s, kwargs: %s", message, kwargs) _LOGGER.debug("Target type: %s", type(kwargs.get(ATTR_TARGET))) kwargs["message"] = message targets = kwargs.get(ATTR_TARGET) title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) data = kwargs.get(ATTR_DATA, {}) data = data if data is not None else {} if isinstance(targets, str): try: targets = json.loads(targets) except json.JSONDecodeError: _LOGGER.error("Target must be a valid json") return processed_targets = [] for target in targets: _LOGGER.debug("Processing: %s", target) if not isinstance(target, str): processed_targets.append(target) _LOGGER.debug("Processed non-string target: %s", processed_targets) continue try: parsed = json.loads(target) if isinstance(parsed, list): processed_targets.extend(parsed) else: processed_targets.append(parsed) _LOGGER.debug("Processed Target by json: %s", processed_targets) except json.JSONDecodeError: if "," in target: processed_targets += [ item.strip() for item in target.split(",") if item.strip() ] else: processed_targets.append(target.strip()) _LOGGER.debug("Processed Target by string: %s", processed_targets) # Expand Home Assistant group targets into member entity IDs before # passing to convert(). The convert() method resolves Alexa-specific # identifiers (entity_id, name, serial), but it does not expand HA groups. # # Supported group forms: # - media_player.* helper groups with an entity_id attribute # - old-style YAML group.* entities, via expand_entity_ids() # # Expansion happens here, while targets are still plain strings. Do not run # expand_entity_ids() after convert(), because convert() returns Alexa # objects, not entity ID strings. expanded_targets = [] for target in processed_targets: if not isinstance(target, str): expanded_targets.append(target) continue # UI media_player group helper if ( target.startswith("media_player.") and (state := self.hass.states.get(target)) is not None and "entity_id" in state.attributes ): members = state.attributes["entity_id"] if isinstance(members, (list, tuple)): expanded_targets.extend(members) else: expanded_targets.append(target) continue # Old-style YAML group.*, expand before convert() if target.startswith("group."): try: expanded_targets.extend(expand_entity_ids(self.hass, [target])) except ValueError: _LOGGER.debug("Invalid Home Assistant group target: %s", target) expanded_targets.append(target) continue expanded_targets.append(target) entities = self.convert(expanded_targets, type_="entities") tasks = [] for account, account_dict in self.hass.data[DATA_ALEXAMEDIA][ "accounts" ].items(): data_type = data.get("type", "tts") for alexa in account_dict["entities"]["media_player"].values(): if data_type == "tts": targets = self.convert( entities, type_="entities", filter_matches=True ) # _LOGGER.debug("TTS entities: %s", targets) if alexa in targets and alexa.available: _LOGGER.debug("TTS by %s : %s", alexa, message) tasks.append( alexa.async_send_tts( message, queue_delay=self.hass.data[DATA_ALEXAMEDIA]["accounts"][ account ]["options"].get(CONF_QUEUE_DELAY, DEFAULT_QUEUE_DELAY), ) ) elif data_type == "announce": targets = self.convert( entities, type_="serialnumbers", filter_matches=True ) # _LOGGER.debug( # "Announce targets: %s entities: %s", # list(map(hide_serial, targets)), # entities, # ) if alexa.device_serial_number in targets and alexa.available: _LOGGER.debug( ("%s: Announce by %s to targets: %s: %s"), hide_email(account), alexa, list(map(hide_serial, targets)), message, ) tasks.append( alexa.async_send_announcement( message, targets=targets, title=title, method=(data["method"] if "method" in data else "all"), queue_delay=self.hass.data[DATA_ALEXAMEDIA]["accounts"][ account ]["options"].get(CONF_QUEUE_DELAY, DEFAULT_QUEUE_DELAY), ) ) break elif data_type == "push": targets = self.convert( entities, type_="entities", filter_matches=True ) if alexa in targets and alexa.available: _LOGGER.debug("Push by %s: %s %s", alexa, title, message) tasks.append( alexa.async_send_mobilepush( message, title=title, queue_delay=self.hass.data[DATA_ALEXAMEDIA]["accounts"][ account ]["options"].get(CONF_QUEUE_DELAY, DEFAULT_QUEUE_DELAY), ) ) elif data_type == "dropin_notification": targets = self.convert( entities, type_="entities", filter_matches=True ) if alexa in targets and alexa.available: _LOGGER.debug( "Notification dropin by %s: %s %s", alexa, title, message ) tasks.append( alexa.async_send_dropin_notification( message, title=title, queue_delay=self.hass.data[DATA_ALEXAMEDIA]["accounts"][ account ]["options"].get(CONF_QUEUE_DELAY, DEFAULT_QUEUE_DELAY), ) ) else: errormessage = ( f"{account}: Data value `type={data_type}` is not implemented. " f"See {NOTIFY_URL}" ) _LOGGER.debug(errormessage) raise vol.Invalid(errormessage) await asyncio.gather(*tasks)