371 lines
15 KiB
Python
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)
|