Added Alexa Music
This commit is contained in:
@@ -0,0 +1,370 @@
|
||||
"""
|
||||
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)
|
||||
Reference in New Issue
Block a user