Files
Home-Assistant/custom_components/alexa_media/notify.py
T
2026-07-17 10:12:15 -04:00

371 lines
15 KiB
Python

"""
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)