From e1dcbbbb40659d588af5f1a147a73aa4234539c9 Mon Sep 17 00:00:00 2001 From: Alan Tse Date: Thu, 16 Apr 2020 19:13:04 -0700 Subject: [PATCH 01/14] ci: add recommended lint for HA Signed-off-by: Alan Tse --- .github/workflows/hassfest.yaml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .github/workflows/hassfest.yaml diff --git a/.github/workflows/hassfest.yaml b/.github/workflows/hassfest.yaml new file mode 100644 index 00000000..440e4543 --- /dev/null +++ b/.github/workflows/hassfest.yaml @@ -0,0 +1,14 @@ +name: Validate with hassfest + +on: + push: + pull_request: + schedule: + - cron: '0 0 * * *' + +jobs: + validate: + runs-on: "ubuntu-latest" + steps: + - uses: "actions/checkout@v2" + - uses: home-assistant/actions/hassfest@master From 511a1a2087618add1d7ddc8f8d7eb97fe90375f1 Mon Sep 17 00:00:00 2001 From: Alan Tse Date: Thu, 16 Apr 2020 19:32:16 -0700 Subject: [PATCH 02/14] fix: fix hassfest lint issues --- custom_components/alexa_media/manifest.json | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/custom_components/alexa_media/manifest.json b/custom_components/alexa_media/manifest.json index 18ffea26..fd4804cd 100644 --- a/custom_components/alexa_media/manifest.json +++ b/custom_components/alexa_media/manifest.json @@ -3,13 +3,12 @@ "name": "Alexa Media Player", "config_flow": true, "documentation": "https://github.com/custom-components/alexa_media_player/wiki", - "dependencies": [], + "dependencies": ["configurator"], "codeowners": [ - "keatontaylor", - "alandtse" + "@keatontaylor", + "@alandtse" ], "requirements": [ "alexapy==1.5.2" ], - "homeassistant": "0.96.0" -} \ No newline at end of file +} From edb9fff67c06d4c24d02b34194788801ac4a1772 Mon Sep 17 00:00:00 2001 From: Alan Tse Date: Thu, 16 Apr 2020 19:37:26 -0700 Subject: [PATCH 03/14] fix: fix json syntax error --- custom_components/alexa_media/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/alexa_media/manifest.json b/custom_components/alexa_media/manifest.json index fd4804cd..7c089e9e 100644 --- a/custom_components/alexa_media/manifest.json +++ b/custom_components/alexa_media/manifest.json @@ -10,5 +10,5 @@ ], "requirements": [ "alexapy==1.5.2" - ], + ] } From 687dc5442510e530f01e6bab2f817cb607b1f1dc Mon Sep 17 00:00:00 2001 From: Alan Tse Date: Thu, 16 Apr 2020 21:02:39 -0700 Subject: [PATCH 04/14] docs: update readme with badges --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 464bb94f..5a4fbb97 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,6 @@ +![Validate with hassfest](https://github.com/custom-components/alexa_media_player/workflows/Validate%20with%20hassfest/badge.svg) +![semantic_release](https://github.com/custom-components/alexa_media_player/workflows/semantic_release/badge.svg) + [Alexa Media Player Custom Component](https://github.com/custom-components/alexa_media_player) for homeassistant # What This Is: From fd5ec392a77f45c9dcdf62abaf64a33ab4d1dca9 Mon Sep 17 00:00:00 2001 From: semantic-release Date: Sat, 11 Apr 2020 16:10:19 +0000 Subject: [PATCH 05/14] 2.5.13 Automatically generated by python-semantic-release --- custom_components/alexa_media/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/alexa_media/const.py b/custom_components/alexa_media/const.py index 0ee92619..e23ca5cf 100644 --- a/custom_components/alexa_media/const.py +++ b/custom_components/alexa_media/const.py @@ -9,7 +9,7 @@ """ from datetime import timedelta -__version__ = "2.5.12" +__version__ = "2.5.13" PROJECT_URL = "https://github.com/custom-components/alexa_media_player/" ISSUE_URL = "{}issues".format(PROJECT_URL) From 1873a1974452bcb9178f7ab707f2b2c9c088b64b Mon Sep 17 00:00:00 2001 From: "Alan D. Tse" Date: Sun, 12 Apr 2020 11:19:00 -0700 Subject: [PATCH 06/14] refactor: migrate to DataUpdateCoordinator --- custom_components/alexa_media/__init__.py | 415 ++++++------------ custom_components/alexa_media/configurator.py | 214 +++++++++ custom_components/alexa_media/helpers.py | 40 +- hacs.json | 11 +- 4 files changed, 394 insertions(+), 286 deletions(-) create mode 100644 custom_components/alexa_media/configurator.py diff --git a/custom_components/alexa_media/__init__.py b/custom_components/alexa_media/__init__.py index 10ac8b27..1a8eefec 100644 --- a/custom_components/alexa_media/__init__.py +++ b/custom_components/alexa_media/__init__.py @@ -7,12 +7,15 @@ 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 from datetime import datetime, timedelta +from json import JSONDecodeError import logging import time -from typing import List, Optional, Text +from typing import Optional, Text from alexapy import AlexapyLoginError, WebsocketEchoClient, hide_email, hide_serial +import async_timeout from homeassistant import util from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( @@ -23,13 +26,16 @@ CONF_URL, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.helpers import config_validation as cv +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv, entity from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.event import async_call_later +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt import voluptuous as vol from .config_flow import configured_instances +from .configurator import clear_configurator, test_login_status from .const import ( ALEXA_COMPONENTS, ATTR_EMAIL, @@ -48,7 +54,7 @@ SERVICE_UPDATE_LAST_CALLED, STARTUP, ) -from .helpers import retry_async +from .helpers import _existing_serials _LOGGER = logging.getLogger(__name__) @@ -142,7 +148,7 @@ async def async_setup(hass, config, discovery_info=None): return True -@retry_async(limit=5, delay=5, catch_exceptions=True) +# @retry_async(limit=5, delay=5, catch_exceptions=True) async def async_setup_entry(hass, config_entry): """Set up Alexa Media Player as config entry.""" @@ -165,8 +171,9 @@ async def close_alexa_media(event=None) -> None: hass.data[DATA_ALEXAMEDIA]["accounts"].setdefault( email, { + "coordinator": None, "config_entry": config_entry, - "setup_platform_callback": setup_platform_callback, + "setup_alexa": setup_alexa, "test_login_status": test_login_status, "devices": {"media_player": {}, "switch": {}}, "entities": {"media_player": {}, "switch": {}}, @@ -186,228 +193,23 @@ async def close_alexa_media(event=None) -> None: AlexaLogin(url, email, password, hass.config.path, account.get(CONF_DEBUG)), ) await login.login_with_cookie() - await test_login_status(hass, config_entry, login, setup_platform_callback) - return True - - -async def setup_platform_callback(hass, config_entry, login, callback_data): - """Handle response from configurator. - - Args: - callback_data (json): Returned data from configurator passed through - request_configuration and configuration_callback - - """ - _LOGGER.debug( - ( - "Configurator closed for Status: %s\n" - " got captcha: %s securitycode: %s" - " Claimsoption: %s AuthSelectOption: %s " - " VerificationCode: %s" - ), - login.status, - callback_data.get("captcha"), - callback_data.get("securitycode"), - callback_data.get("claimsoption"), - callback_data.get("authselectoption"), - callback_data.get("verificationcode"), - ) - await login.login(data=callback_data) - await test_login_status(hass, config_entry, login, setup_platform_callback) - - -async def request_configuration(hass, config_entry, login, setup_platform_callback): - """Request configuration steps from the user using the configurator.""" - - async def configuration_callback(callback_data): - """Handle the submitted configuration.""" - await hass.async_add_job( - setup_platform_callback, hass, config_entry, login, callback_data - ) - - configurator = hass.components.configurator - status = login.status - email = login.email - # links = "" - footer = "" - if "error_message" in status and status["error_message"]: - footer = ( - "\nNOTE: Actual Amazon error message in red below. " - "Remember password will be provided automatically" - " and Amazon error message normally appears first!" - ) - # if login.links: - # links = '\n\nGo to link with link# (e.g. link0)\n' + login.links - # Get Captcha - if ( - status - and "captcha_image_url" in status - and status["captcha_image_url"] is not None - ): - config_id = configurator.async_request_config( - "Alexa Media Player - Captcha - {}".format(email), - configuration_callback, - description=( - "Please enter the text for the captcha." - " Please hit confirm to reload image." - # + links - + footer - ), - description_image=status["captcha_image_url"], - submit_caption="Confirm", - fields=[{"id": "captcha", "name": "Captcha"}], - ) - elif ( - status and "securitycode_required" in status and status["securitycode_required"] - ): # Get 2FA code - config_id = configurator.async_request_config( - "Alexa Media Player - 2FA - {}".format(email), - configuration_callback, - description=( - "Please enter your Two-Factor Security code." - # + links - + footer - ), - submit_caption="Confirm", - fields=[{"id": "securitycode", "name": "Security Code"}], - ) - elif ( - status and "claimspicker_required" in status and status["claimspicker_required"] - ): # Get picker method - options = status["claimspicker_message"] - if options: - config_id = configurator.async_request_config( - "Alexa Media Player - Verification Method - {}".format(email), - configuration_callback, - description=( - "Please select the verification method. " - "(e.g., `sms` or `email`).\n{}".format(options) - # + links - + footer - ), - submit_caption="Confirm", - fields=[{"id": "claimsoption", "name": "Option"}], - ) - else: - await configuration_callback({}) - elif ( - status and "authselect_required" in status and status["authselect_required"] - ): # Get picker method - options = status["authselect_message"] - if options: - config_id = configurator.async_request_config( - "Alexa Media Player - OTP Method - {}".format(email), - configuration_callback, - description=( - "Please select the OTP method. " - "(e.g., `0`, `1`).
{}".format(options) - # + links - + footer - ), - submit_caption="Confirm", - fields=[{"id": "authselectoption", "name": "Option"}], - ) - else: - await configuration_callback({}) - elif ( - status - and "verificationcode_required" in status - and status["verificationcode_required"] - ): # Get picker method - config_id = configurator.async_request_config( - "Alexa Media Player - Verification Code - {}".format(email), - configuration_callback, - description=( - "Please enter received verification code." - # + links - + footer - ), - submit_caption="Confirm", - fields=[{"id": "verificationcode", "name": "Verification Code"}], - ) - else: # Check login - config_id = configurator.async_request_config( - "Alexa Media Player - Begin - {}".format(email), - configuration_callback, - description=("Please hit confirm to begin login attempt."), - submit_caption="Confirm", - fields=[], - ) - hass.data[DATA_ALEXAMEDIA]["accounts"][email]["configurator"].append(config_id) - if "error_message" in status and status["error_message"]: - configurator.async_notify_errors(config_id, status["error_message"]) - if len(hass.data[DATA_ALEXAMEDIA]["accounts"][email]["configurator"]) > 1: - configurator.async_request_done( - (hass.data[DATA_ALEXAMEDIA]["accounts"][email]["configurator"]).pop(0) - ) - - -async def test_login_status(hass, config_entry, login, setup_platform_callback) -> None: - """Test the login status and spawn requests for info.""" - _LOGGER.debug("Testing login status: %s", login.status) - if "login_successful" in login.status and login.status["login_successful"]: - _LOGGER.debug("Setting up Alexa devices for %s", hide_email(login.email)) + if await test_login_status(hass, config_entry, login, setup_alexa): await hass.async_add_job(setup_alexa, hass, config_entry, login) - return - if "captcha_required" in login.status and login.status["captcha_required"]: - _LOGGER.debug("Creating configurator to request captcha") - elif ( - "securitycode_required" in login.status - and login.status["securitycode_required"] - ): - _LOGGER.debug("Creating configurator to request 2FA") - elif ( - "claimspicker_required" in login.status - and login.status["claimspicker_required"] - ): - _LOGGER.debug("Creating configurator to select verification option") - elif "authselect_required" in login.status and login.status["authselect_required"]: - _LOGGER.debug("Creating configurator to select OTA option") - elif ( - "verificationcode_required" in login.status - and login.status["verificationcode_required"] - ): - _LOGGER.debug("Creating configurator to enter verification code") - elif "login_failed" in login.status and login.status["login_failed"]: - _LOGGER.debug("Creating configurator to start new login attempt") - await hass.async_add_job( - request_configuration, hass, config_entry, login, setup_platform_callback - ) + return True + return False async def setup_alexa(hass, config_entry, login_obj): """Set up a alexa api based on host parameter.""" - def _existing_serials() -> List: - email: Text = login_obj.email - existing_serials = ( - list( - hass.data[DATA_ALEXAMEDIA]["accounts"][email]["entities"][ - "media_player" - ].keys() - ) - if "entities" in (hass.data[DATA_ALEXAMEDIA]["accounts"][email]) - else [] - ) - for serial in existing_serials: - device = hass.data[DATA_ALEXAMEDIA]["accounts"][email]["devices"][ - "media_player" - ][serial] - if "appDeviceList" in device and device["appDeviceList"]: - apps = list( - map( - lambda x: x["serialNumber"] if "serialNumber" in x else None, - device["appDeviceList"], - ) - ) - # _LOGGER.debug("Combining %s with %s", - # existing_serials, apps) - existing_serials = existing_serials + apps - return existing_serials + async def async_update_data(): + """Fetch data from API endpoint. - @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS) - async def update_devices(login_obj): - """Ping Alexa API to identify all devices, bluetooth, and last called device. + This is the place to pre-process the data to lookup tables + so entities can quickly look up their data. + + This will ping Alexa API to identify all devices, bluetooth, and the last + called device. This will add new devices and services when discovered. By default this runs every SCAN_INTERVAL seconds unless another method calls it. if @@ -423,13 +225,10 @@ async def update_devices(login_obj): email: Text = login_obj.email if email not in hass.data[DATA_ALEXAMEDIA]["accounts"]: return - existing_serials = _existing_serials() + existing_serials = _existing_serials(hass, login_obj) existing_entities = hass.data[DATA_ALEXAMEDIA]["accounts"][email]["entities"][ "media_player" ].values() - websocket_enabled = hass.data[DATA_ALEXAMEDIA]["accounts"][email].get( - "websocket" - ) auth_info = hass.data[DATA_ALEXAMEDIA]["accounts"][email].get("auth_info") new_devices = hass.data[DATA_ALEXAMEDIA]["accounts"][email]["new_devices"] devices = {} @@ -437,35 +236,60 @@ async def update_devices(login_obj): preferences = {} dnd = {} raw_notifications = {} + tasks = [ + AlexaAPI.get_devices(login_obj), + AlexaAPI.get_bluetooth(login_obj), + AlexaAPI.get_device_preferences(login_obj), + AlexaAPI.get_dnd_state(login_obj), + AlexaAPI.get_notifications(login_obj), + ] + if new_devices: + tasks.append(AlexaAPI.get_authentication(login_obj)) + try: - if new_devices: - auth_info = await AlexaAPI.get_authentication(login_obj) - devices = await AlexaAPI.get_devices(login_obj) - bluetooth = await AlexaAPI.get_bluetooth(login_obj) - preferences = await AlexaAPI.get_device_preferences(login_obj) - dnd = await AlexaAPI.get_dnd_state(login_obj) - raw_notifications = await AlexaAPI.get_notifications(login_obj) - _LOGGER.debug( - "%s: Found %s devices, %s bluetooth", - hide_email(email), - len(devices) if devices is not None else "", - len(bluetooth.get("bluetoothStates", [])) - if bluetooth is not None - else "", - ) - if (devices is None or bluetooth is None) and not ( - hass.data[DATA_ALEXAMEDIA]["accounts"][email]["configurator"] - ): - raise AlexapyLoginError() - except (AlexapyLoginError, RuntimeError): + # Note: asyncio.TimeoutError and aiohttp.ClientError are already + # handled by the data update coordinator. + async with async_timeout.timeout(10): + if new_devices: + ( + devices, + bluetooth, + preferences, + dnd, + raw_notifications, + auth_info, + ) = await asyncio.gather(*tasks) + else: + ( + devices, + bluetooth, + preferences, + dnd, + raw_notifications, + ) = await asyncio.gather(*tasks) + _LOGGER.debug( + "%s: Found %s devices, %s bluetooth", + hide_email(email), + len(devices) if devices is not None else "", + len(bluetooth.get("bluetoothStates", [])) + if bluetooth is not None + else "", + ) + if (devices is None or bluetooth is None) and not ( + hass.data[DATA_ALEXAMEDIA]["accounts"][email]["configurator"] + ): + raise AlexapyLoginError() + except (AlexapyLoginError, RuntimeError, JSONDecodeError): _LOGGER.debug( "%s: Alexa API disconnected; attempting to relogin", hide_email(email) ) await login_obj.login_with_cookie() - await test_login_status( - hass, config_entry, login_obj, setup_platform_callback - ) + if await test_login_status(hass, config_entry, login_obj, setup_alexa): + await hass.async_add_job(setup_alexa, hass, config_entry, login_obj) return + except BaseException as err: + raise UpdateFailed(f"Error communicating with API: {err}") + await process_notifications(login_obj, raw_notifications) # Process last_called data to fire events await update_last_called(login_obj) @@ -580,15 +404,6 @@ async def update_devices(login_obj): ) hass.data[DATA_ALEXAMEDIA]["accounts"][email]["new_devices"] = False - async_call_later( - hass, - scan_interval if not websocket_enabled else scan_interval * 10, - lambda _: hass.async_create_task( - update_devices( # pylint: disable=unexpected-keyword-arg - login_obj, no_throttle=True - ) - ), - ) async def process_notifications(login_obj, raw_notifications=None): """Process raw notifications json.""" @@ -775,7 +590,7 @@ async def ws_handler(message_obj): and "payload" in message_obj.json_payload else None ) - existing_serials = _existing_serials() + existing_serials = _existing_serials(hass, login_obj) seen_commands = hass.data[DATA_ALEXAMEDIA]["accounts"][email][ "websocket_commands" ] @@ -926,9 +741,9 @@ async def ws_handler(message_obj): ): _LOGGER.debug("Discovered new media_player %s", serial) (hass.data[DATA_ALEXAMEDIA]["accounts"][email]["new_devices"]) = True - await update_devices( # pylint: disable=unexpected-keyword-arg - login_obj, no_throttle=True - ) + await hass.data[DATA_ALEXAMEDIA]["accounts"][email][ + "coordinator" + ].async_request_refresh() async def ws_open_handler(): """Handle websocket open.""" @@ -985,9 +800,12 @@ async def ws_close_handler(): "%s: Websocket closed; retries exceeded; polling", hide_email(email) ) hass.data[DATA_ALEXAMEDIA]["accounts"][email]["websocket"] = None - await update_devices( # pylint: disable=unexpected-keyword-arg - login_obj, no_throttle=True - ) + hass.data[DATA_ALEXAMEDIA]["accounts"][email][ + "coordinator" + ].update_interval = scan_interval + await hass.data[DATA_ALEXAMEDIA]["accounts"][email][ + "coordinator" + ].async_request_refresh() async def ws_error_handler(message): """Handle websocket error. @@ -996,7 +814,7 @@ async def ws_error_handler(message): the websocket and determine if a reconnect should be done. By specification, websockets will issue a close after every error. """ - email = login_obj.email + email: Text = login_obj.email errors = hass.data[DATA_ALEXAMEDIA]["accounts"][email]["websocketerror"] _LOGGER.debug( "%s: Received websocket error #%i %s: type %s", @@ -1022,10 +840,25 @@ async def ws_error_handler(message): else config.get(CONF_SCAN_INTERVAL) ) hass.data[DATA_ALEXAMEDIA]["accounts"][email]["login_obj"] = login_obj - hass.data[DATA_ALEXAMEDIA]["accounts"][email]["websocket"] = await ws_connect() - await update_devices( # pylint: disable=unexpected-keyword-arg - login_obj, no_throttle=True + websocket_enabled = hass.data[DATA_ALEXAMEDIA]["accounts"][email][ + "websocket" + ] = await ws_connect() + hass.data[DATA_ALEXAMEDIA]["accounts"][email][ + "coordinator" + ] = coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + # Name of the data. For logging purposes. + name="alexa_media", + update_method=async_update_data, + # Polling interval. Will only be polled if there are subscribers. + update_interval=timedelta( + seconds=scan_interval * 10 if websocket_enabled else scan_interval + ), ) + # Fetch initial data so we have data when entities subscribe + await coordinator.async_refresh() + hass.services.async_register( DOMAIN, SERVICE_UPDATE_LAST_CALLED, @@ -1058,17 +891,6 @@ async def async_unload_entry(hass, entry) -> bool: return True -async def clear_configurator(hass, email: Text) -> None: - """Clear open configurators for email.""" - if email not in hass.data[DATA_ALEXAMEDIA]["accounts"]: - return - if "configurator" in hass.data[DATA_ALEXAMEDIA]["accounts"][email]: - for config_id in hass.data[DATA_ALEXAMEDIA]["accounts"][email]["configurator"]: - configurator = hass.components.configurator - configurator.async_request_done(config_id) - hass.data[DATA_ALEXAMEDIA]["accounts"][email]["configurator"] = [] - - async def close_connections(hass, email: Text) -> None: """Clear open aiohttp connections for email.""" if ( @@ -1083,3 +905,40 @@ async def close_connections(hass, email: Text) -> None: "%s: Connection closed: %s", hide_email(email), login_obj._session.closed ) await clear_configurator(hass, email) + + +class AlexaHub(entity.Entity): + def __init__(self, coordinator, idx): + self.coordinator = coordinator + self.idx = idx + + @property + def is_on(self): + """Return entity state. + + Example to show how we fetch data from coordinator. + """ + self.coordinator.data[self.idx]["state"] + + @property + def should_poll(self): + """No need to poll. Coordinator notifies entity of updates.""" + return False + + @property + def available(self): + """Return if entity is available.""" + return self.coordinator.last_update_success + + async def async_added_to_hass(self): + """When entity is added to hass.""" + self.async_on_remove( + self.coordinator.async_add_listener(self.async_write_ha_state) + ) + + async def async_update(self): + """Update the entity. + + Only used by the generic entity update service. + """ + await self.coordinator.async_request_refresh() diff --git a/custom_components/alexa_media/configurator.py b/custom_components/alexa_media/configurator.py new file mode 100644 index 00000000..ee5ad08b --- /dev/null +++ b/custom_components/alexa_media/configurator.py @@ -0,0 +1,214 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# SPDX-License-Identifier: Apache-2.0 +""" +Support to interface with Alexa Devices. + +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 logging +from typing import Text + +from . import hide_email +from .const import DATA_ALEXAMEDIA + +_LOGGER = logging.getLogger(__name__) + + +async def test_login_status(hass, config_entry, login, alexa_setup_callback) -> bool: + """Test the login status and spawn requests for info.""" + + async def request_configuration(hass, config_entry, login): + """Request configuration steps from the user using the configurator.""" + + async def configuration_callback(callback_data): + """Handle the submitted configuration.""" + await hass.async_add_job( + setup_platform_callback, hass, config_entry, login, callback_data + ) + + configurator = hass.components.configurator + status = login.status + email = login.email + # links = "" + footer = "" + if "error_message" in status and status["error_message"]: + footer = ( + "\nNOTE: Actual Amazon error message in red below. " + "Remember password will be provided automatically" + " and Amazon error message normally appears first!" + ) + # if login.links: + # links = '\n\nGo to link with link# (e.g. link0)\n' + login.links + # Get Captcha + if ( + status + and "captcha_image_url" in status + and status["captcha_image_url"] is not None + ): + config_id = configurator.async_request_config( + "Alexa Media Player - Captcha - {}".format(email), + configuration_callback, + description=( + "Please enter the text for the captcha." + " Please hit confirm to reload image." + # + links + + footer + ), + description_image=status["captcha_image_url"], + submit_caption="Confirm", + fields=[{"id": "captcha", "name": "Captcha"}], + ) + elif ( + status + and "securitycode_required" in status + and status["securitycode_required"] + ): # Get 2FA code + config_id = configurator.async_request_config( + "Alexa Media Player - 2FA - {}".format(email), + configuration_callback, + description=( + "Please enter your Two-Factor Security code." + # + links + + footer + ), + submit_caption="Confirm", + fields=[{"id": "securitycode", "name": "Security Code"}], + ) + elif ( + status + and "claimspicker_required" in status + and status["claimspicker_required"] + ): # Get picker method + options = status["claimspicker_message"] + if options: + config_id = configurator.async_request_config( + "Alexa Media Player - Verification Method - {}".format(email), + configuration_callback, + description=( + "Please select the verification method. " + "(e.g., `sms` or `email`).\n{}".format(options) + # + links + + footer + ), + submit_caption="Confirm", + fields=[{"id": "claimsoption", "name": "Option"}], + ) + else: + await configuration_callback({}) + elif ( + status and "authselect_required" in status and status["authselect_required"] + ): # Get picker method + options = status["authselect_message"] + if options: + config_id = configurator.async_request_config( + "Alexa Media Player - OTP Method - {}".format(email), + configuration_callback, + description=( + "Please select the OTP method. " + "(e.g., `0`, `1`).
{}".format(options) + # + links + + footer + ), + submit_caption="Confirm", + fields=[{"id": "authselectoption", "name": "Option"}], + ) + else: + await configuration_callback({}) + elif ( + status + and "verificationcode_required" in status + and status["verificationcode_required"] + ): # Get picker method + config_id = configurator.async_request_config( + "Alexa Media Player - Verification Code - {}".format(email), + configuration_callback, + description=( + "Please enter received verification code." + # + links + + footer + ), + submit_caption="Confirm", + fields=[{"id": "verificationcode", "name": "Verification Code"}], + ) + else: # Check login + config_id = configurator.async_request_config( + "Alexa Media Player - Begin - {}".format(email), + configuration_callback, + description=("Please hit confirm to begin login attempt."), + submit_caption="Confirm", + fields=[], + ) + hass.data[DATA_ALEXAMEDIA]["accounts"][email]["configurator"].append(config_id) + if "error_message" in status and status["error_message"]: + configurator.async_notify_errors(config_id, status["error_message"]) + if len(hass.data[DATA_ALEXAMEDIA]["accounts"][email]["configurator"]) > 1: + configurator.async_request_done( + (hass.data[DATA_ALEXAMEDIA]["accounts"][email]["configurator"]).pop(0) + ) + + async def setup_platform_callback(hass, config_entry, login, callback_data): + """Handle response from configurator. + + Args: + callback_data (json): Returned data from configurator passed through + request_configuration and configuration_callback + + """ + _LOGGER.debug( + ( + "Configurator closed for Status: %s\n" + " got captcha: %s securitycode: %s" + " Claimsoption: %s AuthSelectOption: %s " + " VerificationCode: %s" + ), + login.status, + callback_data.get("captcha"), + callback_data.get("securitycode"), + callback_data.get("claimsoption"), + callback_data.get("authselectoption"), + callback_data.get("verificationcode"), + ) + await login.login(data=callback_data) + await test_login_status(hass, config_entry, login, alexa_setup_callback) + + _LOGGER.debug("Testing login status: %s", login.status) + if "login_successful" in login.status and login.status["login_successful"]: + _LOGGER.debug("Setting up Alexa devices for %s", hide_email(login.email)) + await clear_configurator(hass, login.email) + await hass.async_add_job(alexa_setup_callback, hass, config_entry, login) + return True + if "captcha_required" in login.status and login.status["captcha_required"]: + _LOGGER.debug("Creating configurator to request captcha") + elif ( + "securitycode_required" in login.status + and login.status["securitycode_required"] + ): + _LOGGER.debug("Creating configurator to request 2FA") + elif ( + "claimspicker_required" in login.status + and login.status["claimspicker_required"] + ): + _LOGGER.debug("Creating configurator to select verification option") + elif "authselect_required" in login.status and login.status["authselect_required"]: + _LOGGER.debug("Creating configurator to select OTA option") + elif ( + "verificationcode_required" in login.status + and login.status["verificationcode_required"] + ): + _LOGGER.debug("Creating configurator to enter verification code") + elif "login_failed" in login.status and login.status["login_failed"]: + _LOGGER.debug("Creating configurator to start new login attempt") + await hass.async_add_job(request_configuration, hass, config_entry, login) + + +async def clear_configurator(hass, email: Text) -> None: + """Clear open configurators for email.""" + if email not in hass.data[DATA_ALEXAMEDIA]["accounts"]: + return + if "configurator" in hass.data[DATA_ALEXAMEDIA]["accounts"][email]: + for config_id in hass.data[DATA_ALEXAMEDIA]["accounts"][email]["configurator"]: + configurator = hass.components.configurator + configurator.async_request_done(config_id) + hass.data[DATA_ALEXAMEDIA]["accounts"][email]["configurator"] = [] diff --git a/custom_components/alexa_media/helpers.py b/custom_components/alexa_media/helpers.py index dd016131..f26096e2 100644 --- a/custom_components/alexa_media/helpers.py +++ b/custom_components/alexa_media/helpers.py @@ -24,10 +24,12 @@ async def add_devices( account: Text, devices: List[EntityComponent], add_devices_callback: Callable, - include_filter: List[Text] = [], - exclude_filter: List[Text] = [], + include_filter: List[Text] = None, + exclude_filter: List[Text] = None, ) -> bool: """Add devices using add_devices_callback.""" + include_filter = [] or include_filter + exclude_filter = [] or exclude_filter new_devices = [] for device in devices: if ( @@ -168,8 +170,8 @@ async def wrapper(*args, **kwargs) -> Any: config_entry = hass.data[DATA_ALEXAMEDIA]["accounts"][email][ "config_entry" ] - callback = hass.data[DATA_ALEXAMEDIA]["accounts"][email][ - "setup_platform_callback" + setup_alexa = hass.data[DATA_ALEXAMEDIA]["accounts"][email][ + "setup_alexa" ] test_login_status = hass.data[DATA_ALEXAMEDIA]["accounts"][email][ "test_login_status" @@ -179,8 +181,36 @@ async def wrapper(*args, **kwargs) -> Any: hide_email(email), ) await login.login_with_cookie() - await test_login_status(hass, config_entry, login, callback) + await test_login_status(hass, config_entry, login, setup_alexa) return None return result return wrapper + + +def _existing_serials(hass, login_obj) -> List: + email: Text = login_obj.email + existing_serials = ( + list( + hass.data[DATA_ALEXAMEDIA]["accounts"][email]["entities"][ + "media_player" + ].keys() + ) + if "entities" in (hass.data[DATA_ALEXAMEDIA]["accounts"][email]) + else [] + ) + for serial in existing_serials: + device = hass.data[DATA_ALEXAMEDIA]["accounts"][email]["devices"][ + "media_player" + ][serial] + if "appDeviceList" in device and device["appDeviceList"]: + apps = list( + map( + lambda x: x["serialNumber"] if "serialNumber" in x else None, + device["appDeviceList"], + ) + ) + # _LOGGER.debug("Combining %s with %s", + # existing_serials, apps) + existing_serials = existing_serials + apps + return existing_serials diff --git a/hacs.json b/hacs.json index 4bb3d639..387cecaf 100644 --- a/hacs.json +++ b/hacs.json @@ -1,9 +1,14 @@ { "name": "Alexa Media Player", "content_in_root": false, - "domains": ["media_player", "switch", "alarm_control_panel", "sensor"], + "domains": [ + "media_player", + "switch", + "alarm_control_panel", + "sensor" + ], "iot_class": "cloud_poll", "zip_release": true, "filename": "alexa_media.zip", - "homeassistant": "0.96.0" -} + "homeassistant": "0.106.0" +} \ No newline at end of file From 497b13256831d3b9318c0b0fa51ddeb0fca9d85c Mon Sep 17 00:00:00 2001 From: "Alan D. Tse" Date: Sat, 18 Apr 2020 19:32:30 -0700 Subject: [PATCH 07/14] docs: Add debugging to show config import --- custom_components/alexa_media/__init__.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/custom_components/alexa_media/__init__.py b/custom_components/alexa_media/__init__.py index 1a8eefec..f1e0abcd 100644 --- a/custom_components/alexa_media/__init__.py +++ b/custom_components/alexa_media/__init__.py @@ -106,11 +106,19 @@ async def async_setup(hass, config, discovery_info=None): # pylint: disable=unused-argument """Set up the Alexa domain.""" if DOMAIN not in config: + _LOGGER.debug( + "Nothing to import from configuration.yaml, loading from Integrations", + ) return True domainconfig = config.get(DOMAIN) for account in domainconfig[CONF_ACCOUNTS]: entry_title = "{} - {}".format(account[CONF_EMAIL], account[CONF_URL]) + _LOGGER.debug( + "Importing config information for %s - %s from configuration.yaml", + hide_email(account[CONF_EMAIL]), + account[CONF_URL], + ) if entry_title in configured_instances(hass): for entry in hass.config_entries.async_entries(DOMAIN): if entry_title == entry.title: From e0a29ab0af0d68a9fd98b47b793c382f2e146fd9 Mon Sep 17 00:00:00 2001 From: "Alan D. Tse" Date: Sun, 19 Apr 2020 12:16:40 -0700 Subject: [PATCH 08/14] fix: remove redudant alexa_setup --- custom_components/alexa_media/__init__.py | 24 ++++++----------------- 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/custom_components/alexa_media/__init__.py b/custom_components/alexa_media/__init__.py index f1e0abcd..0e539761 100644 --- a/custom_components/alexa_media/__init__.py +++ b/custom_components/alexa_media/__init__.py @@ -202,7 +202,6 @@ async def close_alexa_media(event=None) -> None: ) await login.login_with_cookie() if await test_login_status(hass, config_entry, login, setup_alexa): - await hass.async_add_job(setup_alexa, hass, config_entry, login) return True return False @@ -292,8 +291,7 @@ async def async_update_data(): "%s: Alexa API disconnected; attempting to relogin", hide_email(email) ) await login_obj.login_with_cookie() - if await test_login_status(hass, config_entry, login_obj, setup_alexa): - await hass.async_add_job(setup_alexa, hass, config_entry, login_obj) + await test_login_status(hass, config_entry, login_obj, setup_alexa) return except BaseException as err: raise UpdateFailed(f"Error communicating with API: {err}") @@ -394,22 +392,12 @@ async def async_update_data(): cleaned_config.pop(CONF_PASSWORD, None) # CONF_PASSWORD contains sensitive info which is no longer needed for component in ALEXA_COMPONENTS: - if component == "notify": - hass.async_create_task( - async_load_platform( - hass, - component, - DOMAIN, - {CONF_NAME: DOMAIN, "config": cleaned_config}, - config, - ) - ) - else: - hass.async_add_job( - hass.config_entries.async_forward_entry_setup( - config_entry, component - ) + _LOGGER.debug("Loading %s", component) + hass.async_add_job( + hass.config_entries.async_forward_entry_setup( + config_entry, component ) + ) hass.data[DATA_ALEXAMEDIA]["accounts"][email]["new_devices"] = False From 0b077ec77182cbab313cdd06ee933f0ee015ac29 Mon Sep 17 00:00:00 2001 From: "Alan D. Tse" Date: Sun, 19 Apr 2020 12:17:05 -0700 Subject: [PATCH 09/14] style: remove unused code --- custom_components/alexa_media/__init__.py | 37 ----------------------- 1 file changed, 37 deletions(-) diff --git a/custom_components/alexa_media/__init__.py b/custom_components/alexa_media/__init__.py index 0e539761..c8dc86cf 100644 --- a/custom_components/alexa_media/__init__.py +++ b/custom_components/alexa_media/__init__.py @@ -901,40 +901,3 @@ async def close_connections(hass, email: Text) -> None: "%s: Connection closed: %s", hide_email(email), login_obj._session.closed ) await clear_configurator(hass, email) - - -class AlexaHub(entity.Entity): - def __init__(self, coordinator, idx): - self.coordinator = coordinator - self.idx = idx - - @property - def is_on(self): - """Return entity state. - - Example to show how we fetch data from coordinator. - """ - self.coordinator.data[self.idx]["state"] - - @property - def should_poll(self): - """No need to poll. Coordinator notifies entity of updates.""" - return False - - @property - def available(self): - """Return if entity is available.""" - return self.coordinator.last_update_success - - async def async_added_to_hass(self): - """When entity is added to hass.""" - self.async_on_remove( - self.coordinator.async_add_listener(self.async_write_ha_state) - ) - - async def async_update(self): - """Update the entity. - - Only used by the generic entity update service. - """ - await self.coordinator.async_request_refresh() From 75faa7f2f71fe045ede1c50295df0a50179e12c0 Mon Sep 17 00:00:00 2001 From: "Alan D. Tse" Date: Sun, 19 Apr 2020 12:17:42 -0700 Subject: [PATCH 10/14] fix: add checks for configurator use --- custom_components/alexa_media/__init__.py | 46 ++++++++++++----------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/custom_components/alexa_media/__init__.py b/custom_components/alexa_media/__init__.py index c8dc86cf..6f2dae92 100644 --- a/custom_components/alexa_media/__init__.py +++ b/custom_components/alexa_media/__init__.py @@ -737,9 +737,11 @@ async def ws_handler(message_obj): ): _LOGGER.debug("Discovered new media_player %s", serial) (hass.data[DATA_ALEXAMEDIA]["accounts"][email]["new_devices"]) = True - await hass.data[DATA_ALEXAMEDIA]["accounts"][email][ + coordinator = hass.data[DATA_ALEXAMEDIA]["accounts"][email].get( "coordinator" - ].async_request_refresh() + ) + if coordinator: + await coordinator.async_request_refresh() async def ws_open_handler(): """Handle websocket open.""" @@ -796,12 +798,10 @@ async def ws_close_handler(): "%s: Websocket closed; retries exceeded; polling", hide_email(email) ) hass.data[DATA_ALEXAMEDIA]["accounts"][email]["websocket"] = None - hass.data[DATA_ALEXAMEDIA]["accounts"][email][ - "coordinator" - ].update_interval = scan_interval - await hass.data[DATA_ALEXAMEDIA]["accounts"][email][ - "coordinator" - ].async_request_refresh() + coordinator = hass.data[DATA_ALEXAMEDIA]["accounts"][email].get("coordinator") + if coordinator: + coordinator.update_interval = scan_interval + await coordinator.async_request_refresh() async def ws_error_handler(message): """Handle websocket error. @@ -839,20 +839,24 @@ async def ws_error_handler(message): websocket_enabled = hass.data[DATA_ALEXAMEDIA]["accounts"][email][ "websocket" ] = await ws_connect() - hass.data[DATA_ALEXAMEDIA]["accounts"][email][ - "coordinator" - ] = coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - # Name of the data. For logging purposes. - name="alexa_media", - update_method=async_update_data, - # Polling interval. Will only be polled if there are subscribers. - update_interval=timedelta( - seconds=scan_interval * 10 if websocket_enabled else scan_interval - ), - ) + coordinator = hass.data[DATA_ALEXAMEDIA]["accounts"][email].get("coordinator") + if coordinator is None: + _LOGGER.debug("Creating coordinator") + hass.data[DATA_ALEXAMEDIA]["accounts"][email][ + "coordinator" + ] = coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + # Name of the data. For logging purposes. + name="alexa_media", + update_method=async_update_data, + # Polling interval. Will only be polled if there are subscribers. + update_interval=timedelta( + seconds=scan_interval * 10 if websocket_enabled else scan_interval + ), + ) # Fetch initial data so we have data when entities subscribe + _LOGGER.debug("Refreshing coordinator") await coordinator.async_refresh() hass.services.async_register( From 0ddda71c3cac266750cbe05f2f65dab15240f99b Mon Sep 17 00:00:00 2001 From: "Alan D. Tse" Date: Sun, 19 Apr 2020 12:18:53 -0700 Subject: [PATCH 11/14] fix: stagger loading of components --- custom_components/alexa_media/const.py | 3 +- custom_components/alexa_media/media_player.py | 34 ++++++++++++++++--- custom_components/alexa_media/sensor.py | 15 ++------ custom_components/alexa_media/switch.py | 13 ++----- 4 files changed, 37 insertions(+), 28 deletions(-) diff --git a/custom_components/alexa_media/const.py b/custom_components/alexa_media/const.py index e23ca5cf..29f6ea5f 100644 --- a/custom_components/alexa_media/const.py +++ b/custom_components/alexa_media/const.py @@ -21,7 +21,8 @@ MIN_TIME_BETWEEN_SCANS = SCAN_INTERVAL MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(seconds=1) -ALEXA_COMPONENTS = ["media_player", "notify", "alarm_control_panel", "switch", "sensor"] +ALEXA_COMPONENTS = ["media_player", "alarm_control_panel"] +DEPENDENT_ALEXA_COMPONENTS = ["notify", "switch", "sensor"] CONF_ACCOUNTS = "accounts" CONF_DEBUG = "debug" diff --git a/custom_components/alexa_media/media_player.py b/custom_components/alexa_media/media_player.py index 06ab2662..4ac331b6 100644 --- a/custom_components/alexa_media/media_player.py +++ b/custom_components/alexa_media/media_player.py @@ -36,18 +36,22 @@ STATE_STANDBY, STATE_UNAVAILABLE, ) +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.event import async_call_later from . import ( CONF_EMAIL, + CONF_NAME, + CONF_PASSWORD, DATA_ALEXAMEDIA, DOMAIN as ALEXA_DOMAIN, MIN_TIME_BETWEEN_FORCED_SCANS, MIN_TIME_BETWEEN_SCANS, + async_load_platform, hide_email, hide_serial, ) -from .const import PLAY_SCAN_INTERVAL +from .const import DEPENDENT_ALEXA_COMPONENTS, PLAY_SCAN_INTERVAL from .helpers import _catch_login_errors, add_devices, retry_async SUPPORT_ALEXA = ( @@ -70,7 +74,7 @@ DEPENDENCIES = [ALEXA_DOMAIN] -@retry_async(limit=5, delay=2, catch_exceptions=True) +# @retry_async(limit=5, delay=2, catch_exceptions=True) async def async_setup_platform(hass, config, add_devices_callback, discovery_info=None): # pylint: disable=unused-argument """Set up the Alexa media player platform.""" @@ -99,9 +103,31 @@ async def async_setup_platform(hass, config, add_devices_callback, discovery_inf async def async_setup_entry(hass, config_entry, async_add_devices): """Set up the Alexa media player platform by config_entry.""" - return await async_setup_platform( + if await async_setup_platform( hass, config_entry.data, async_add_devices, discovery_info=None - ) + ): + for component in DEPENDENT_ALEXA_COMPONENTS: + if component == "notify": + cleaned_config = config_entry.data.copy() + cleaned_config.pop(CONF_PASSWORD, None) + # CONF_PASSWORD contains sensitive info which is no longer needed + hass.async_create_task( + async_load_platform( + hass, + component, + ALEXA_DOMAIN, + {CONF_NAME: ALEXA_DOMAIN, "config": cleaned_config}, + cleaned_config, + ) + ) + else: + hass.async_add_job( + hass.config_entries.async_forward_entry_setup( + config_entry, component + ) + ) + return True + raise ConfigEntryNotReady async def async_unload_entry(hass, entry) -> bool: diff --git a/custom_components/alexa_media/sensor.py b/custom_components/alexa_media/sensor.py index 21d95eb6..b71f446c 100644 --- a/custom_components/alexa_media/sensor.py +++ b/custom_components/alexa_media/sensor.py @@ -12,7 +12,7 @@ from typing import List, Text # noqa pylint: disable=unused-import from homeassistant.const import DEVICE_CLASS_TIMESTAMP, STATE_UNAVAILABLE -from homeassistant.exceptions import NoEntitySpecifiedError +from homeassistant.exceptions import ConfigEntryNotReady, NoEntitySpecifiedError from homeassistant.helpers.entity import Entity from homeassistant.util import dt import pytz @@ -61,7 +61,6 @@ } -@retry_async(limit=5, delay=5, catch_exceptions=False) async def async_setup_platform(hass, config, add_devices_callback, discovery_info=None): """Set up the Alexa sensor platform.""" devices: List[AlexaMediaNotificationSensor] = [] @@ -84,15 +83,7 @@ async def async_setup_platform(hass, config, add_devices_callback, discovery_inf hide_email(account), hide_serial(key), ) - if devices: - await add_devices( - hide_email(account), - devices, - add_devices_callback, - include_filter, - exclude_filter, - ) - return False + raise ConfigEntryNotReady if key not in (account_dict["entities"]["sensor"]): (account_dict["entities"]["sensor"][key]) = {} for (n_type, class_) in SENSOR_TYPES.items(): @@ -243,7 +234,7 @@ def _fix_alarm_date_time(self, value): return value def _update_recurring_alarm(self, value): - _LOGGER.debug("value %s", value) + _LOGGER.debug("Sensor value %s", value) alarm = value[1][self._sensor_property] reminder = None if isinstance(value[1][self._sensor_property], int): diff --git a/custom_components/alexa_media/switch.py b/custom_components/alexa_media/switch.py index 37b3023a..96d44e2d 100644 --- a/custom_components/alexa_media/switch.py +++ b/custom_components/alexa_media/switch.py @@ -11,7 +11,7 @@ from typing import List # noqa pylint: disable=unused-import from homeassistant.components.switch import SwitchDevice -from homeassistant.exceptions import NoEntitySpecifiedError +from homeassistant.exceptions import ConfigEntryNotReady, NoEntitySpecifiedError from . import ( CONF_EMAIL, @@ -27,7 +27,6 @@ _LOGGER = logging.getLogger(__name__) -@retry_async(limit=5, delay=5, catch_exceptions=True) async def async_setup_platform(hass, config, add_devices_callback, discovery_info=None): """Set up the Alexa switch platform.""" devices = [] # type: List[DNDSwitch] @@ -50,15 +49,7 @@ async def async_setup_platform(hass, config, add_devices_callback, discovery_inf hide_email(account), hide_serial(key), ) - if devices: - await add_devices( - hide_email(account), - devices, - add_devices_callback, - include_filter, - exclude_filter, - ) - return False + raise ConfigEntryNotReady if key not in ( hass.data[DATA_ALEXAMEDIA]["accounts"][account]["entities"]["switch"] ): From 88ee4ad01775ee96b1efb131e3a7a036adfa5e43 Mon Sep 17 00:00:00 2001 From: "Alan D. Tse" Date: Sun, 19 Apr 2020 12:21:41 -0700 Subject: [PATCH 12/14] docs: add hacs badge --- README.md | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 5a4fbb97..5c650530 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,38 @@ ![Validate with hassfest](https://github.com/custom-components/alexa_media_player/workflows/Validate%20with%20hassfest/badge.svg) ![semantic_release](https://github.com/custom-components/alexa_media_player/workflows/semantic_release/badge.svg) +[![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg?style=for-the-badge)](https://github.com/custom-components/hacs) [Alexa Media Player Custom Component](https://github.com/custom-components/alexa_media_player) for homeassistant # What This Is: + This is a custom component to allow control of Amazon Alexa devices in [Homeassistant](https://home-assistant.io) using the unofficial Alexa API. Please note this mimics the Alexa app but Amazon may cut off access at anytime. # What It Does: + Allows for control of Amazon Echo products as home assistant media devices with the following features: -* Play/Pause/Stop -* Next/Previous (Track) -* Volume -* Retrieval for displaying in home assistant of: - * Song Title - * Artists Name - * Album Name - * Album Image +- Play/Pause/Stop +- Next/Previous (Track) +- Volume +- Retrieval for displaying in home assistant of: + - Song Title + - Artists Name + - Album Name + - Album Image # Installation and Configuration -Please see the [wiki.](https://github.com/custom-components/alexa_media_player/wiki/Configuration) +Please see the [wiki.](https://github.com/custom-components/alexa_media_player/wiki/Configuration) # Further Documentation + Please see the [wiki](https://github.com/custom-components/alexa_media_player/wiki) # Changelog + Use the commit history but we try to maintain this [wiki](https://github.com/custom-components/alexa_media_player/wiki/Changelog). # License + [Apache-2.0](LICENSE). By providing a contribution, you agree the contribution is licensed under Apache-2.0. This is required for Home Assistant contributions. From 94cd97ba4f87cb3fcbd4a6ea4579ead149c02b9a Mon Sep 17 00:00:00 2001 From: "Alan D. Tse" Date: Sun, 19 Apr 2020 12:56:05 -0700 Subject: [PATCH 13/14] refactor: change to dispatcher instead of bus --- custom_components/alexa_media/__init__.py | 25 +++++--- .../alexa_media/alarm_control_panel.py | 9 ++- custom_components/alexa_media/media_player.py | 57 ++++++++++--------- custom_components/alexa_media/sensor.py | 11 ++-- custom_components/alexa_media/switch.py | 15 +++-- 5 files changed, 68 insertions(+), 49 deletions(-) diff --git a/custom_components/alexa_media/__init__.py b/custom_components/alexa_media/__init__.py index 6f2dae92..712c2184 100644 --- a/custom_components/alexa_media/__init__.py +++ b/custom_components/alexa_media/__init__.py @@ -29,6 +29,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv, entity from homeassistant.helpers.discovery import async_load_platform +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt @@ -470,7 +471,8 @@ async def update_last_called(login_obj, last_called=None): ), hide_serial(last_called), ) - hass.bus.async_fire( + async_dispatcher_send( + hass, f"{DOMAIN}_{hide_email(email)}"[0:32], {"last_called_change": last_called}, ) @@ -624,7 +626,8 @@ async def ws_handler(message_obj): } if serial and serial in existing_serials: await update_last_called(login_obj, last_called) - hass.bus.async_fire( + async_dispatcher_send( + hass, f"{DOMAIN}_{hide_email(email)}"[0:32], {"push_activity": json_payload}, ) @@ -638,7 +641,8 @@ async def ws_handler(message_obj): _LOGGER.debug( "Updating media_player: %s", hide_serial(json_payload) ) - hass.bus.async_fire( + async_dispatcher_send( + hass, f"{DOMAIN}_{hide_email(email)}"[0:32], {"player_state": json_payload}, ) @@ -648,7 +652,8 @@ async def ws_handler(message_obj): _LOGGER.debug( "Updating media_player volume: %s", hide_serial(json_payload) ) - hass.bus.async_fire( + async_dispatcher_send( + hass, f"{DOMAIN}_{hide_email(email)}"[0:32], {"player_state": json_payload}, ) @@ -662,7 +667,8 @@ async def ws_handler(message_obj): "Updating media_player availability %s", hide_serial(json_payload), ) - hass.bus.async_fire( + async_dispatcher_send( + hass, f"{DOMAIN}_{hide_email(email)}"[0:32], {"player_state": json_payload}, ) @@ -684,7 +690,8 @@ async def ws_handler(message_obj): # _LOGGER.debug("bluetooth_state %s", # hide_serial(bluetooth_state)) if bluetooth_state: - hass.bus.async_fire( + async_dispatcher_send( + hass, f"{DOMAIN}_{hide_email(email)}"[0:32], {"bluetooth_change": bluetooth_state}, ) @@ -694,7 +701,8 @@ async def ws_handler(message_obj): _LOGGER.debug( "Updating media_player queue %s", hide_serial(json_payload) ) - hass.bus.async_fire( + async_dispatcher_send( + hass, f"{DOMAIN}_{hide_email(email)}"[0:32], {"queue_state": json_payload}, ) @@ -706,7 +714,8 @@ async def ws_handler(message_obj): "Updating mediaplayer notifications: %s", hide_serial(json_payload), ) - hass.bus.async_fire( + async_dispatcher_send( + hass, f"{DOMAIN}_{hide_email(email)}"[0:32], {"notification_update": json_payload}, ) diff --git a/custom_components/alexa_media/alarm_control_panel.py b/custom_components/alexa_media/alarm_control_panel.py index 4f4ddd41..123015a2 100644 --- a/custom_components/alexa_media/alarm_control_panel.py +++ b/custom_components/alexa_media/alarm_control_panel.py @@ -13,6 +13,7 @@ from homeassistant import util from homeassistant.components.alarm_control_panel import AlarmControlPanel from homeassistant.const import STATE_ALARM_ARMED_AWAY, STATE_ALARM_DISARMED +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.event import async_call_later from . import ( @@ -156,8 +157,10 @@ async def async_added_to_hass(self): except AttributeError: pass # Register event handler on bus - self._listener = self.hass.bus.async_listen( - f"{ALEXA_DOMAIN}_{hide_email(self._login.email)}"[0:32], self._handle_event + self._listener = async_dispatcher_connect( + self.hass, + f"{ALEXA_DOMAIN}_{hide_email(self._login.email)}"[0:32], + self._handle_event, ) await self.async_update() @@ -176,7 +179,7 @@ def _handle_event(self, event): return except AttributeError: pass - if "push_activity" in event.data: + if "push_activity" in event: async_call_later( self.hass, 2, diff --git a/custom_components/alexa_media/media_player.py b/custom_components/alexa_media/media_player.py index 4ac331b6..009dfb26 100644 --- a/custom_components/alexa_media/media_player.py +++ b/custom_components/alexa_media/media_player.py @@ -37,6 +37,7 @@ STATE_UNAVAILABLE, ) from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.event import async_call_later from . import ( @@ -210,8 +211,10 @@ async def init(self, device): async def async_added_to_hass(self): """Perform tasks after loading.""" # Register event handler on bus - self._listener = self.hass.bus.async_listen( - f"{ALEXA_DOMAIN}_{hide_email(self._login.email)}"[0:32], self._handle_event + self._listener = async_dispatcher_connect( + self.hass, + f"{ALEXA_DOMAIN}_{hide_email(self._login.email)}"[0:32], + self._handle_event, ) async def async_will_remove_from_hass(self): @@ -272,39 +275,39 @@ async def _refresh_if_no_audiopush(already_refreshed=False): pass already_refreshed = False event_serial = None - if "last_called_change" in event.data: + if "last_called_change" in event: event_serial = ( - event.data["last_called_change"]["serialNumber"] - if event.data["last_called_change"] + event["last_called_change"]["serialNumber"] + if event["last_called_change"] else None ) - elif "bluetooth_change" in event.data: + elif "bluetooth_change" in event: event_serial = ( - event.data["bluetooth_change"]["deviceSerialNumber"] - if event.data["bluetooth_change"] + event["bluetooth_change"]["deviceSerialNumber"] + if event["bluetooth_change"] else None ) - elif "player_state" in event.data: + elif "player_state" in event: event_serial = ( - event.data["player_state"]["dopplerId"]["deviceSerialNumber"] - if event.data["player_state"] + event["player_state"]["dopplerId"]["deviceSerialNumber"] + if event["player_state"] else None ) - elif "queue_state" in event.data: + elif "queue_state" in event: event_serial = ( - event.data["queue_state"]["dopplerId"]["deviceSerialNumber"] - if event.data["queue_state"] + event["queue_state"]["dopplerId"]["deviceSerialNumber"] + if event["queue_state"] else None ) - elif "push_activity" in event.data: + elif "push_activity" in event: event_serial = ( - event.data.get("push_activity", {}).get("key", {}).get("serialNumber") + event.get("push_activity", {}).get("key", {}).get("serialNumber") ) if not event_serial: return self.available = True self.async_schedule_update_ha_state() - if "last_called_change" in event.data: + if "last_called_change" in event: if event_serial == self.device_serial_number or any( item["serialNumber"] == event_serial for item in self._app_device_list ): @@ -314,9 +317,7 @@ async def _refresh_if_no_audiopush(already_refreshed=False): hide_serial(self.device_serial_number), ) self._last_called = True - self._last_called_timestamp = event.data["last_called_change"][ - "timestamp" - ] + self._last_called_timestamp = event["last_called_change"]["timestamp"] else: self._last_called = False if self.hass and self.async_schedule_update_ha_state: @@ -325,14 +326,14 @@ async def _refresh_if_no_audiopush(already_refreshed=False): self.hass.data[DATA_ALEXAMEDIA]["accounts"][email]["websocket"] ) self.async_schedule_update_ha_state(force_refresh=force_refresh) - elif "bluetooth_change" in event.data: + elif "bluetooth_change" in event: if event_serial == self.device_serial_number: _LOGGER.debug( "%s bluetooth_state update: %s", self.name, - hide_serial(event.data["bluetooth_change"]), + hide_serial(event["bluetooth_change"]), ) - self._bluetooth_state = event.data["bluetooth_change"] + self._bluetooth_state = event["bluetooth_change"] # the setting of bluetooth_state is not consistent as this # takes from the event instead of the hass storage. We're # setting the value twice. Architectually we should have a @@ -341,8 +342,8 @@ async def _refresh_if_no_audiopush(already_refreshed=False): self._source_list = await self._get_source_list() if self.hass and self.async_schedule_update_ha_state: self.async_schedule_update_ha_state() - elif "player_state" in event.data: - player_state = event.data["player_state"] + elif "player_state" in event: + player_state = event["player_state"] if event_serial == self.device_serial_number: if "audioPlayerState" in player_state: _LOGGER.debug( @@ -376,7 +377,7 @@ async def _refresh_if_no_audiopush(already_refreshed=False): if self.hass and self.async_schedule_update_ha_state: self.async_schedule_update_ha_state() await _refresh_if_no_audiopush(already_refreshed) - elif "push_activity" in event.data: + elif "push_activity" in event: if self.state in {STATE_IDLE, STATE_PAUSED, STATE_PLAYING}: _LOGGER.debug( "%s checking for potential state update due to push activity on %s", @@ -387,8 +388,8 @@ async def _refresh_if_no_audiopush(already_refreshed=False): await asyncio.sleep(2) await self.async_update() already_refreshed = True - if "queue_state" in event.data: - queue_state = event.data["queue_state"] + if "queue_state" in event: + queue_state = event["queue_state"] if event_serial == self.device_serial_number: if ( "trackOrderChanged" in queue_state diff --git a/custom_components/alexa_media/sensor.py b/custom_components/alexa_media/sensor.py index b71f446c..745befa8 100644 --- a/custom_components/alexa_media/sensor.py +++ b/custom_components/alexa_media/sensor.py @@ -13,6 +13,7 @@ from homeassistant.const import DEVICE_CLASS_TIMESTAMP, STATE_UNAVAILABLE from homeassistant.exceptions import ConfigEntryNotReady, NoEntitySpecifiedError +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity from homeassistant.util import dt import pytz @@ -281,8 +282,10 @@ async def async_added_to_hass(self): except AttributeError: pass # Register event handler on bus - self._listener = self.hass.bus.async_listen( - f"{ALEXA_DOMAIN}_{hide_email(self._account)}"[0:32], self._handle_event + self._listener = async_dispatcher_connect( + self.hass, + f"{ALEXA_DOMAIN}_{hide_email(self._account)}"[0:32], + self._handle_event, ) await self.async_update() @@ -302,9 +305,9 @@ def _handle_event(self, event): return except AttributeError: pass - if "notification_update" in event.data: + if "notification_update" in event: if ( - event.data["notification_update"]["dopplerId"]["deviceSerialNumber"] + event["notification_update"]["dopplerId"]["deviceSerialNumber"] == self._client.unique_id ): _LOGGER.debug("Updating sensor %s", self.name) diff --git a/custom_components/alexa_media/switch.py b/custom_components/alexa_media/switch.py index 96d44e2d..9e51684c 100644 --- a/custom_components/alexa_media/switch.py +++ b/custom_components/alexa_media/switch.py @@ -12,6 +12,7 @@ from homeassistant.components.switch import SwitchDevice from homeassistant.exceptions import ConfigEntryNotReady, NoEntitySpecifiedError +from homeassistant.helpers.dispatcher import async_dispatcher_connect from . import ( CONF_EMAIL, @@ -149,8 +150,10 @@ async def async_added_to_hass(self): except AttributeError: pass # Register event handler on bus - self._listener = self.hass.bus.async_listen( - f"{ALEXA_DOMAIN}_{hide_email(self._login.email)}"[0:32], self._handle_event + self._listener = async_dispatcher_connect( + self.hass, + f"{ALEXA_DOMAIN}_{hide_email(self._login.email)}"[0:32], + self._handle_event, ) async def async_will_remove_from_hass(self): @@ -169,8 +172,8 @@ def _handle_event(self, event): return except AttributeError: pass - if "queue_state" in event.data: - queue_state = event.data["queue_state"] + if "queue_state" in event: + queue_state = event["queue_state"] if queue_state["dopplerId"]["deviceSerialNumber"] == self._client.unique_id: self._state = getattr(self._client, self._switch_property) self.async_schedule_update_ha_state() @@ -307,8 +310,8 @@ def _handle_event(self, event): return except AttributeError: pass - if "player_state" in event.data: - queue_state = event.data["player_state"] + if "player_state" in event: + queue_state = event["player_state"] if queue_state["dopplerId"]["deviceSerialNumber"] == self._client.unique_id: self._state = getattr(self._client, self._switch_property) self.async_schedule_update_ha_state() From e7d5f0458113f5b167e35b5d227f585da9c57a9f Mon Sep 17 00:00:00 2001 From: "Alan D. Tse" Date: Sun, 19 Apr 2020 20:46:30 -0700 Subject: [PATCH 14/14] fix: switch to queueable set_guard_state This will use the same command as the Alexa app's routine. --- .../alexa_media/alarm_control_panel.py | 66 ++++++++++++++----- custom_components/alexa_media/const.py | 6 +- custom_components/alexa_media/manifest.json | 9 +-- 3 files changed, 54 insertions(+), 27 deletions(-) diff --git a/custom_components/alexa_media/alarm_control_panel.py b/custom_components/alexa_media/alarm_control_panel.py index 123015a2..fd212778 100644 --- a/custom_components/alexa_media/alarm_control_panel.py +++ b/custom_components/alexa_media/alarm_control_panel.py @@ -8,11 +8,17 @@ https://community.home-assistant.io/t/echo-devices-alexa-as-media-player-testers-needed/58639 """ import logging +from asyncio import sleep from typing import Dict, List, Text # noqa pylint: disable=unused-import from homeassistant import util from homeassistant.components.alarm_control_panel import AlarmControlPanel -from homeassistant.const import STATE_ALARM_ARMED_AWAY, STATE_ALARM_DISARMED +from homeassistant.const import ( + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_DISARMED, + STATE_UNAVAILABLE, +) +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.event import async_call_later @@ -25,6 +31,7 @@ MIN_TIME_BETWEEN_FORCED_SCANS, MIN_TIME_BETWEEN_SCANS, hide_email, + hide_serial, ) from .helpers import _catch_login_errors, add_devices, retry_async @@ -33,7 +40,6 @@ DEPENDENCIES = [ALEXA_DOMAIN] -@retry_async(limit=5, delay=2, catch_exceptions=True) async def async_setup_platform( hass, config, add_devices_callback, discovery_info=None ) -> bool: @@ -43,6 +49,17 @@ async def async_setup_platform( include_filter = config.get(CONF_INCLUDE_DEVICES, []) exclude_filter = config.get(CONF_EXCLUDE_DEVICES, []) account_dict = hass.data[DATA_ALEXAMEDIA]["accounts"][account] + guard_media_players = {} + for key, device 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 "GUARD_EARCON" in device["capabilities"]: + guard_media_players[key] = account_dict["entities"]["media_player"][key] if "alarm_control_panel" not in (account_dict["entities"]): ( hass.data[DATA_ALEXAMEDIA]["accounts"][account]["entities"][ @@ -50,7 +67,7 @@ async def async_setup_platform( ] ) = {} alexa_client: AlexaAlarmControlPanel = AlexaAlarmControlPanel( - account_dict["login_obj"] + account_dict["login_obj"], guard_media_players ) await alexa_client.init() if not (alexa_client and alexa_client.unique_id): @@ -100,7 +117,7 @@ async def async_unload_entry(hass, entry) -> bool: class AlexaAlarmControlPanel(AlarmControlPanel): """Implementation of Alexa Media Player alarm control panel.""" - def __init__(self, login) -> None: + def __init__(self, login, media_players=None) -> None: # pylint: disable=unexpected-keyword-arg """Initialize the Alexa device.""" from alexapy import AlexaAPI @@ -118,6 +135,7 @@ def __init__(self, login) -> None: self._state = None self._should_poll = False self._attrs: Dict[Text, Text] = {} + self._media_players = {} or media_players async def init(self): """Initialize.""" @@ -233,34 +251,46 @@ async def async_update(self): self.async_schedule_update_ha_state() @_catch_login_errors - async def async_alarm_disarm(self, code=None) -> None: + async def _async_alarm_set(self, command: Text = "", code=None) -> None: # pylint: disable=unexpected-keyword-arg - """Send disarm command.""" + """Send command.""" try: if not self.enabled: return except AttributeError: pass - await self.alexa_api.set_guard_state( - self._login, self._guard_entity_id, "ARMED_STAY" + if command not in (STATE_ALARM_ARMED_AWAY, STATE_ALARM_DISARMED): + _LOGGER.error("Invalid command: %s", command) + return + command_map = {STATE_ALARM_ARMED_AWAY: "AWAY", STATE_ALARM_DISARMED: "HOME"} + available_media_players = list( + filter(lambda x: x.state != STATE_UNAVAILABLE, self._media_players.values()) ) + if available_media_players: + _LOGGER.debug("Sending guard command to: %s", available_media_players[0]) + await available_media_players[0].alexa_api.set_guard_state( + self._appliance_id.split("_")[2], command_map[command] + ) + await sleep(2) # delay + else: + _LOGGER.debug("Performing static guard command") + await self.alexa_api.static_set_guard_state( + self._login, self._guard_entity_id, command + ) await self.async_update(no_throttle=True) self.async_schedule_update_ha_state() + @_catch_login_errors + async def async_alarm_disarm(self, code=None) -> None: + # pylint: disable=unexpected-keyword-arg + """Send disarm command.""" + await self._async_alarm_set(STATE_ALARM_DISARMED) + @_catch_login_errors async def async_alarm_arm_away(self, code=None) -> None: """Send arm away command.""" # pylint: disable=unexpected-keyword-arg - try: - if not self.enabled: - return - except AttributeError: - pass - await self.alexa_api.set_guard_state( - self._login, self._guard_entity_id, "ARMED_AWAY" - ) - await self.async_update(no_throttle=True) - self.async_schedule_update_ha_state() + await self._async_alarm_set(STATE_ALARM_ARMED_AWAY) @property def unique_id(self): diff --git a/custom_components/alexa_media/const.py b/custom_components/alexa_media/const.py index 29f6ea5f..e5a530db 100644 --- a/custom_components/alexa_media/const.py +++ b/custom_components/alexa_media/const.py @@ -21,8 +21,10 @@ MIN_TIME_BETWEEN_SCANS = SCAN_INTERVAL MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(seconds=1) -ALEXA_COMPONENTS = ["media_player", "alarm_control_panel"] -DEPENDENT_ALEXA_COMPONENTS = ["notify", "switch", "sensor"] +ALEXA_COMPONENTS = [ + "media_player", +] +DEPENDENT_ALEXA_COMPONENTS = ["notify", "switch", "sensor", "alarm_control_panel"] CONF_ACCOUNTS = "accounts" CONF_DEBUG = "debug" diff --git a/custom_components/alexa_media/manifest.json b/custom_components/alexa_media/manifest.json index 7c089e9e..c131465f 100644 --- a/custom_components/alexa_media/manifest.json +++ b/custom_components/alexa_media/manifest.json @@ -4,11 +4,6 @@ "config_flow": true, "documentation": "https://github.com/custom-components/alexa_media_player/wiki", "dependencies": ["configurator"], - "codeowners": [ - "@keatontaylor", - "@alandtse" - ], - "requirements": [ - "alexapy==1.5.2" - ] + "codeowners": ["@keatontaylor", "@alandtse"], + "requirements": ["alexapy==1.6.0"] }