From 2ddd0c2944c35c4684cb0fb5905518652fc45ea6 Mon Sep 17 00:00:00 2001 From: Alan Tse Date: Sat, 10 Aug 2019 01:16:21 -0700 Subject: [PATCH 01/11] chore(custom_components): remove deprecated file Signed-off-by: Alan Tse --- custom_components.json | 17 ----------------- 1 file changed, 17 deletions(-) delete mode 100644 custom_components.json diff --git a/custom_components.json b/custom_components.json deleted file mode 100644 index 3fc45496..00000000 --- a/custom_components.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "alexa_media": { - "version": "1.3.1", - "local_location": "/custom_components/alexa_media/__init__.py", - "remote_location": "https://raw.githubusercontent.com/keatontaylor/alexa_media_player/master/custom_components/alexa_media/__init__.py", - "visit_repo": "https://github.com/keatontaylor/alexa_media_player", - "changelog": "https://github.com/keatontaylor/alexa_media_player/wiki/Changelog", - "resources": [ - "https://raw.githubusercontent.com/keatontaylor/alexa_media_player/master/custom_components/alexa_media/const.py", - "https://raw.githubusercontent.com/keatontaylor/alexa_media_player/master/custom_components/alexa_media/media_player.py", - "https://raw.githubusercontent.com/keatontaylor/alexa_media_player/master/custom_components/alexa_media/notify.py", - "https://raw.githubusercontent.com/keatontaylor/alexa_media_player/master/custom_components/alexa_media/alarm_control_panel.py", - "https://raw.githubusercontent.com/keatontaylor/alexa_media_player/master/custom_components/alexa_media/manifest.json", - "https://raw.githubusercontent.com/keatontaylor/alexa_media_player/master/custom_components/alexa_media/services.yaml" - ] - } -} From 54f04f8775ff35f7104703f8c9eea0217229203d Mon Sep 17 00:00:00 2001 From: "Alan D. Tse" Date: Sat, 17 Aug 2019 16:23:53 -0700 Subject: [PATCH 02/11] feat: migrate to async --- custom_components/alexa_media/__init__.py | 80 ++++---- .../alexa_media/alarm_control_panel.py | 75 +++---- custom_components/alexa_media/media_player.py | 192 ++++++++++-------- custom_components/alexa_media/notify.py | 37 ++-- custom_components/alexa_media/switch.py | 99 +++++---- 5 files changed, 262 insertions(+), 221 deletions(-) diff --git a/custom_components/alexa_media/__init__.py b/custom_components/alexa_media/__init__.py index 605b7036..97a88161 100644 --- a/custom_components/alexa_media/__init__.py +++ b/custom_components/alexa_media/__init__.py @@ -15,8 +15,8 @@ from homeassistant.const import ( CONF_EMAIL, CONF_NAME, CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_URL) from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.event import track_time_interval -from homeassistant.helpers.discovery import load_platform +from homeassistant.helpers.event import async_call_later +from homeassistant.helpers.discovery import async_load_platform from .const import ( ALEXA_COMPONENTS, CONF_DEBUG, CONF_ACCOUNTS, CONF_INCLUDE_DEVICES, CONF_EXCLUDE_DEVICES, DATA_ALEXAMEDIA, DOMAIN, MIN_TIME_BETWEEN_SCANS, @@ -79,7 +79,7 @@ def hide_serial(item): return response -def setup(hass, config, discovery_info=None): +async def async_setup(hass, config, discovery_info=None): """Set up the Alexa domain.""" if DATA_ALEXAMEDIA not in hass.data: hass.data[DATA_ALEXAMEDIA] = {} @@ -87,8 +87,8 @@ def setup(hass, config, discovery_info=None): from alexapy import AlexaLogin, __version__ as alexapy_version _LOGGER.info(STARTUP) _LOGGER.info("Loaded alexapy==%s", alexapy_version) - config = config.get(DOMAIN) - for account in config[CONF_ACCOUNTS]: + domainconfig = config.get(DOMAIN) + for account in domainconfig[CONF_ACCOUNTS]: # if account[CONF_EMAIL] in configured_instances(hass): # continue @@ -98,13 +98,13 @@ def setup(hass, config, discovery_info=None): hass.data[DATA_ALEXAMEDIA]['accounts'][email] = {"config": []} login = AlexaLogin(url, email, password, hass.config.path, account.get(CONF_DEBUG)) - + await login.login_with_cookie() test_login_status(hass, account, login, setup_platform_callback) return True -def setup_platform_callback(hass, config, login, callback_data): +async def setup_platform_callback(hass, config, login, callback_data): """Handle response from configurator. Args: @@ -120,7 +120,7 @@ def setup_platform_callback(hass, config, login, callback_data): callback_data.get('claimsoption'), callback_data.get('authselectoption'), callback_data.get('verificationcode')) - login.login(data=callback_data) + await login.login(data=callback_data) test_login_status(hass, config, login, setup_platform_callback) @@ -235,8 +235,8 @@ def test_login_status(hass, config, login, """Test the login status and spawn requests for info.""" if 'login_successful' in login.status and login.status['login_successful']: _LOGGER.debug("Setting up Alexa devices") - hass.add_job(setup_alexa, hass, config, - login) + hass.async_add_job(setup_alexa, hass, config, + login) return if ('captcha_required' in login.status and login.status['captcha_required']): @@ -256,14 +256,14 @@ def test_login_status(hass, config, login, elif ('login_failed' in login.status and login.status['login_failed']): _LOGGER.debug("Creating configurator to start new login attempt") - hass.add_job(request_configuration, hass, config, login, - setup_platform_callback) + hass.async_add_job(request_configuration, hass, config, login, + setup_platform_callback) -def setup_alexa(hass, config, login_obj): +async def setup_alexa(hass, config, login_obj): """Set up a alexa api based on host parameter.""" @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS) - def update_devices(): + async def update_devices(): """Ping Alexa API to identify all devices, bluetooth, and last called device. This will add new devices and services when discovered. By default this @@ -292,10 +292,10 @@ def update_devices(): ['accounts'][email]['new_devices'])): return hass.data[DATA_ALEXAMEDIA]['accounts'][email]['new_devices'] = False - devices = AlexaAPI.get_devices(login_obj) - bluetooth = AlexaAPI.get_bluetooth(login_obj) - preferences = AlexaAPI.get_device_preferences(login_obj) - dnd = AlexaAPI.get_dnd_state(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) _LOGGER.debug("%s: Found %s devices, %s bluetooth", hide_email(email), len(devices) if devices is not None else '', @@ -304,7 +304,7 @@ def update_devices(): and not (hass.data[DATA_ALEXAMEDIA] ['accounts'][email]['config'])): _LOGGER.debug("Alexa API disconnected; attempting to relogin") - login_obj.login_with_cookie() + await login_obj.login() test_login_status(hass, config, login_obj, setup_platform_callback) return @@ -380,13 +380,19 @@ def update_devices(): if new_alexa_clients: for component in ALEXA_COMPONENTS: - load_platform(hass, component, DOMAIN, {CONF_NAME: DOMAIN}, - config) + hass.async_create_task(async_load_platform(hass, + component, + DOMAIN, + {CONF_NAME: DOMAIN}, + config)) # Process last_called data to fire events - update_last_called(login_obj) + await update_last_called(login_obj) + scan_interval = config.get(CONF_SCAN_INTERVAL) + async_call_later(hass, scan_interval.total_seconds(), lambda _: + hass.async_create_task(update_devices())) - def update_last_called(login_obj, last_called=None): + async def update_last_called(login_obj, last_called=None): """Update the last called device for the login_obj. This will store the last_called in hass.data and also fire an event @@ -396,7 +402,7 @@ def update_last_called(login_obj, last_called=None): if last_called: last_called = last_called else: - last_called = AlexaAPI.get_last_device_serial(login_obj) + last_called = await AlexaAPI.get_last_device_serial(login_obj) _LOGGER.debug("%s: Updated last_called: %s", hide_email(email), hide_serial(last_called)) @@ -417,10 +423,10 @@ def update_last_called(login_obj, last_called=None): [email] ['last_called']) = last_called - def update_bluetooth_state(login_obj, device_serial): + async def update_bluetooth_state(login_obj, device_serial): """Update the bluetooth state on ws bluetooth event.""" from alexapy import AlexaAPI - bluetooth = AlexaAPI.get_bluetooth(login_obj) + bluetooth = await AlexaAPI.get_bluetooth(login_obj) device = (hass.data[DATA_ALEXAMEDIA] ['accounts'] [email] @@ -433,7 +439,7 @@ def update_bluetooth_state(login_obj, device_serial): device['bluetooth_state'] = b_state return device['bluetooth_state'] - def last_call_handler(call): + async def last_call_handler(call): """Handle last call service request. Args: @@ -447,7 +453,7 @@ def last_call_handler(call): if requested_emails and email not in requested_emails: continue login_obj = account_dict['login_obj'] - update_last_called(login_obj) + await update_last_called(login_obj) def ws_connect(): """Open WebSocket connection. @@ -464,6 +470,7 @@ def ws_connect(): ws_error_handler) _LOGGER.debug("%s: Websocket created: %s", hide_email(email), websocket) + hass.async_create_task(websocket.async_run()) except BaseException as exception_: _LOGGER.debug("%s: Websocket creation failed: %s", hide_email(email), @@ -507,7 +514,8 @@ def ws_handler(message_obj): 'timestamp': json_payload['timestamp'] } if (serial and serial in existing_serials): - update_last_called(login_obj, last_called) + hass.async_create_task(update_last_called( + login_obj, last_called)) hass.bus.fire(('{}_{}'.format(DOMAIN, hide_email(email)))[0:32], {'push_activity': json_payload}) @@ -564,7 +572,7 @@ def ws_handler(message_obj): _LOGGER.debug("Discovered new media_player %s", serial) (hass.data[DATA_ALEXAMEDIA] ['accounts'][email]['new_devices']) = True - update_devices(no_throttle=True) + hass.async_create_task(update_devices(no_throttle=True)) def ws_open_handler(): """Handle websocket open.""" @@ -599,7 +607,7 @@ def ws_close_handler(): hide_email(email)) (hass.data[DATA_ALEXAMEDIA]['accounts'] [email]['websocket']) = None - update_devices() + hass.async_create_task(update_devices()) def ws_error_handler(message): """Handle websocket error. @@ -635,14 +643,14 @@ def ws_error_handler(message): ['entities']) = {'media_player': {}} (hass.data[DATA_ALEXAMEDIA] ['accounts'][email]['new_devices']) = True # force initial update - track_time_interval(hass, lambda now: update_devices(), scan_interval) - update_devices() - hass.services.register(DOMAIN, SERVICE_UPDATE_LAST_CALLED, - last_call_handler, schema=LAST_CALL_UPDATE_SCHEMA) + hass.async_create_task(update_devices()) + hass.services.async_register(DOMAIN, SERVICE_UPDATE_LAST_CALLED, + last_call_handler, + schema=LAST_CALL_UPDATE_SCHEMA) # Clear configurator. We delay till here to avoid leaving a modal orphan for config_id in hass.data[DATA_ALEXAMEDIA]['accounts'][email]['config']: configurator = hass.components.configurator - configurator.request_done(config_id) + configurator.async_request_done(config_id) hass.data[DATA_ALEXAMEDIA]['accounts'][email]['config'] = [] return True diff --git a/custom_components/alexa_media/alarm_control_panel.py b/custom_components/alexa_media/alarm_control_panel.py index 686072f9..fe853eca 100644 --- a/custom_components/alexa_media/alarm_control_panel.py +++ b/custom_components/alexa_media/alarm_control_panel.py @@ -13,9 +13,9 @@ from homeassistant import util from homeassistant.components.alarm_control_panel import AlarmControlPanel from homeassistant.const import (STATE_ALARM_ARMED_AWAY, - STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED) + STATE_ALARM_DISARMED) from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.event import call_later +from homeassistant.helpers.event import async_call_later from . import DATA_ALEXAMEDIA from . import DOMAIN as ALEXA_DOMAIN @@ -26,15 +26,17 @@ DEPENDENCIES = [ALEXA_DOMAIN] -def setup_platform(hass, config, add_devices_callback, - discovery_info=None): +async def async_setup_platform(hass, + config, + add_devices_callback, + discovery_info=None) -> bool: """Set up the Alexa alarm control panel platform.""" devices = [] # type: List[AlexaAlarmControlPanel] for account, account_dict in (hass.data[DATA_ALEXAMEDIA] ['accounts'].items()): - alexa_client = AlexaAlarmControlPanel(account_dict['login_obj'], - hass) \ - # type: AlexaAlarmControlPanel + alexa_client: AlexaAlarmControlPanel = AlexaAlarmControlPanel( + account_dict['login_obj']) + await alexa_client.init() if not (alexa_client and alexa_client.unique_id): _LOGGER.debug("%s: Skipping creation of uninitialized device: %s", hide_email(account), @@ -65,7 +67,7 @@ def setup_platform(hass, config, add_devices_callback, class AlexaAlarmControlPanel(AlarmControlPanel): """Implementation of Alexa Media Player alarm control panel.""" - def __init__(self, login, hass): + def __init__(self, login) -> None: # pylint: disable=unexpected-keyword-arg """Initialize the Alexa device.""" from alexapy import AlexaAPI @@ -74,7 +76,6 @@ def __init__(self, login, hass): self.alexa_api = AlexaAPI(self, login) self.alexa_api_session = login.session self.account = hide_email(login.email) - self.hass = hass # Guard info self._appliance_id = None @@ -84,9 +85,10 @@ def __init__(self, login, hass): self._should_poll = False self._attrs = {} + async def init(self): try: from simplejson import JSONDecodeError - data = self.alexa_api.get_guard_details(self._login) + data = await self.alexa_api.get_guard_details(self._login) guard_dict = (data['locationDetails'] ['locationDetails']['Default_Location'] ['amazonBridgeDetails']['amazonBridgeDetails'] @@ -107,11 +109,14 @@ def __init__(self, login, hass): if not self._appliance_id: _LOGGER.debug("%s: No Alexa Guard entity found", self.account) return None + + async def async_added_to_hass(self): + """Store register state change callback.""" # Register event handler on bus - hass.bus.listen(('{}_{}'.format(ALEXA_DOMAIN, - hide_email(login.email)))[0:32], - self._handle_event) - self.refresh(no_throttle=True) + self.hass.bus.async_listen(('{}_{}'.format( + ALEXA_DOMAIN, + hide_email(self._login.email)))[0:32], + self._handle_event) def _handle_event(self, event): """Handle websocket events. @@ -119,17 +124,18 @@ def _handle_event(self, event): Used instead of polling. """ if 'push_activity' in event.data: - call_later(self.hass, 2, lambda _: - self.refresh(no_throttle=True)) + async_call_later(self.hass, 2, lambda _: + self.hass.async_create_task( + self.async_update(no_throttle=True))) @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS) - def refresh(self): + async def async_update(self): """Update Guard state.""" import json _LOGGER.debug("%s: Refreshing %s", self.account, self.name) state = None - state_json = self.alexa_api.get_guard_state(self._login, - self._appliance_id) + state_json = await self.alexa_api.get_guard_state(self._login, + self._appliance_id) # _LOGGER.debug("%s: state_json %s", self.account, state_json) if (state_json and 'deviceStates' in state_json and state_json['deviceStates']): @@ -156,33 +162,32 @@ def refresh(self): else: self._state = STATE_ALARM_DISARMED _LOGGER.debug("%s: Alarm State: %s", self.account, self.state) - self.schedule_update_ha_state() + self.async_schedule_update_ha_state() - def alarm_disarm(self, code=None): + async def async_alarm_disarm(self, code=None) -> None: # pylint: disable=unexpected-keyword-arg """Send disarm command. We use the arm_home state as Alexa does not have disarm state. """ - self.alarm_arm_home() - self.schedule_update_ha_state() + await self.async_alarm_arm_home() - def alarm_arm_home(self, code=None): + async def async_alarm_arm_home(self, code=None) -> None: """Send arm home command.""" - self.alexa_api.set_guard_state(self._login, - self._guard_entity_id, - "ARMED_STAY") - self.refresh(no_throttle=True) - self.schedule_update_ha_state() + await self.alexa_api.set_guard_state(self._login, + self._guard_entity_id, + "ARMED_STAY") + await self.async_update(no_throttle=True) + self.async_schedule_update_ha_state() - def alarm_arm_away(self, code=None): + async def async_alarm_arm_away(self, code=None) -> None: """Send arm away command.""" # pylint: disable=unexpected-keyword-arg - self.alexa_api.set_guard_state(self._login, - self._guard_entity_id, - "ARMED_AWAY") - self.refresh(no_throttle=True) - self.schedule_update_ha_state() + 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() @property def unique_id(self): diff --git a/custom_components/alexa_media/media_player.py b/custom_components/alexa_media/media_player.py index 4e3c5ad2..081749f0 100644 --- a/custom_components/alexa_media/media_player.py +++ b/custom_components/alexa_media/media_player.py @@ -32,13 +32,15 @@ STATE_STANDBY) from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.event import call_later +from homeassistant.helpers.event import async_call_later from homeassistant.helpers.service import extract_entity_ids +from homeassistant.helpers.discovery import async_load_platform from .const import ATTR_MESSAGE, PLAY_SCAN_INTERVAL from . import ( DOMAIN as ALEXA_DOMAIN, + CONF_NAME, DATA_ALEXAMEDIA, MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS, hide_email, hide_serial) @@ -53,8 +55,8 @@ DEPENDENCIES = [ALEXA_DOMAIN] -def setup_platform(hass, config, add_devices_callback, - discovery_info=None): +async def async_setup_platform(hass, config, add_devices_callback, + discovery_info=None): """Set up the Alexa media player platform.""" devices = [] # type: List[AlexaClient] for account, account_dict in (hass.data[DATA_ALEXAMEDIA] @@ -62,8 +64,9 @@ def setup_platform(hass, config, add_devices_callback, for key, device in account_dict['devices']['media_player'].items(): if key not in account_dict['entities']['media_player']: alexa_client = AlexaClient(device, - account_dict['login_obj'], - hass) + account_dict['login_obj'] + ) + await alexa_client.init(device) devices.append(alexa_client) (hass.data[DATA_ALEXAMEDIA] ['accounts'] @@ -87,14 +90,14 @@ def setup_platform(hass, config, add_devices_callback, class AlexaClient(MediaPlayerDevice): """Representation of a Alexa device.""" - def __init__(self, device, login, hass): + def __init__(self, device, login): """Initialize the Alexa device.""" from alexapy import AlexaAPI # Class info self._login = login self.alexa_api = AlexaAPI(self, login) - self.auth = AlexaAPI.get_authentication(login) + self.auth = None self.alexa_api_session = login.session self.account = hide_email(login.email) @@ -104,7 +107,6 @@ def __init__(self, device, login, hass): self._customer_email = None self._customer_id = None self._customer_name = None - self._set_authentication_details(self.auth) # Device info self._device = None @@ -141,13 +143,21 @@ def __init__(self, device, login, hass): # Polling state self._should_poll = True self._last_update = 0 - self.refresh(device) + + async def init(self, device): + from alexapy import AlexaAPI + self.auth = await AlexaAPI.get_authentication(self._login) + await self._set_authentication_details(self.auth) + await self.refresh(device) + + async def async_added_to_hass(self): + """Store register state change callback.""" # Register event handler on bus - hass.bus.listen(('{}_{}'.format(ALEXA_DOMAIN, - hide_email(login.email)))[0:32], - self._handle_event) + self.hass.bus.async_listen(('{}_{}'.format(ALEXA_DOMAIN, + hide_email(self._login.email)))[0:32], + self._handle_event) - def _handle_event(self, event): + async def _handle_event(self, event): """Handle events. This will update last_called and player_state events. @@ -173,19 +183,20 @@ def _handle_event(self, event): self._last_called = True else: self._last_called = False - if (self.hass and self.schedule_update_ha_state): + if (self.hass and self.async_schedule_update_ha_state): email = self._login.email force_refresh = not (self.hass.data[DATA_ALEXAMEDIA] ['accounts'][email]['websocket']) - self.schedule_update_ha_state(force_refresh=force_refresh) + self.async_schedule_update_ha_state( + force_refresh=force_refresh) elif 'bluetooth_change' in event.data: if (event.data['bluetooth_change']['deviceSerialNumber'] == self.device_serial_number): self._bluetooth_state = event.data['bluetooth_change'] self._source = self._get_source() self._source_list = self._get_source_list() - if (self.hass and self.schedule_update_ha_state): - self.schedule_update_ha_state() + if (self.hass and self.async_schedule_update_ha_state): + await self.async_schedule_update_ha_state() elif 'player_state' in event.data: player_state = event.data['player_state'] if (player_state['dopplerId'] @@ -194,19 +205,19 @@ def _handle_event(self, event): _LOGGER.debug("%s state update: %s", self.name, player_state['audioPlayerState']) - self.update() # refresh is necessary to pull all data + await self.async_update() # refresh is necessary to pull all data elif 'volumeSetting' in player_state: _LOGGER.debug("%s volume updated: %s", self.name, player_state['volumeSetting']) self._media_vol_level = player_state['volumeSetting']/100 if (self.hass and self.schedule_update_ha_state): - self.schedule_update_ha_state() + self.async_schedule_update_ha_state() elif 'dopplerConnectionState' in player_state: self._available = (player_state['dopplerConnectionState'] == "ONLINE") if (self.hass and self.schedule_update_ha_state): - self.schedule_update_ha_state() + self.async_schedule_update_ha_state() if 'queue_state' in event.data: queue_state = event.data['queue_state'] if (queue_state['dopplerId'] @@ -228,7 +239,7 @@ def _handle_event(self, event): self._shuffle, queue_state['playBackOrder']) - def _clear_media_details(self): + async def _clear_media_details(self): """Set all Media Items to None.""" # General self._media_duration = None @@ -241,7 +252,7 @@ def _clear_media_details(self): self._media_is_muted = None self._media_vol_level = None - def _set_authentication_details(self, auth): + async def _set_authentication_details(self, auth): """Set Authentication based off auth.""" self._authenticated = auth['authenticated'] self._can_access_prime_music = auth['canAccessPrimeMusicContent'] @@ -250,7 +261,7 @@ def _set_authentication_details(self, auth): self._customer_name = auth['customerName'] @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS) - def refresh(self, device=None): + async def refresh(self, device=None): """Refresh device data. This is a per device refresh and for many Alexa devices can result in @@ -279,13 +290,13 @@ def refresh(self, device=None): self._dnd = device['dnd'] if 'dnd' in device else None if self._available is True: _LOGGER.debug("%s: Refreshing %s", self.account, self.name) - self._source = self._get_source() - self._source_list = self._get_source_list() - self._last_called = self._get_last_called() - session = self.alexa_api.get_state() + self._source = await self._get_source() + self._source_list = await self._get_source_list() + self._last_called = await self._get_last_called() + session = await self.alexa_api.get_state() else: session = None - self._clear_media_details() + await self._clear_media_details() # update the session if it exists; not doing relogin here if session is not None: self._session = session @@ -357,10 +368,10 @@ def source_list(self): """List of available input sources.""" return self._source_list - def select_source(self, source): + async def select_source(self, source): """Select input source.""" if source == 'Local Speaker': - self.alexa_api.disconnect_bluetooth() + await self.alexa_api.disconnect_bluetooth() self._source = 'Local Speaker' elif self._bluetooth_state['pairedDeviceList'] is not None: for devices in self._bluetooth_state['pairedDeviceList']: @@ -368,7 +379,7 @@ def select_source(self, source): self.alexa_api.set_bluetooth(devices['address']) self._source = source - def _get_source(self): + async def _get_source(self): source = 'Local Speaker' if self._bluetooth_state['pairedDeviceList'] is not None: for device in self._bluetooth_state['pairedDeviceList']: @@ -376,7 +387,7 @@ def _get_source(self): return device['friendlyName'] return source - def _get_source_list(self): + async def _get_source_list(self): sources = [] if self._bluetooth_state['pairedDeviceList'] is not None: for devices in self._bluetooth_state['pairedDeviceList']: @@ -385,7 +396,7 @@ def _get_source_list(self): sources.append(devices['friendlyName']) return ['Local Speaker'] + sources - def _get_last_called(self): + async def _get_last_called(self): try: last_called_serial = (None if self.hass is None else (self.hass.data[DATA_ALEXAMEDIA] @@ -445,7 +456,7 @@ def state(self): return STATE_IDLE return STATE_STANDBY - def update(self): + async def async_update(self): """Get the latest details on a media player. Because media players spend the majority of time idle, an adaptive @@ -464,8 +475,8 @@ def update(self): ['devices'] ['media_player'] [self.unique_id]) - self.refresh(device, # pylint: disable=unexpected-keyword-arg - no_throttle=True) + await self.refresh(device, # pylint: disable=unexpected-keyword-arg + no_throttle=True) if (self.state in [STATE_PLAYING] and # only enable polling if websocket not connected (not self.hass.data[DATA_ALEXAMEDIA] @@ -476,8 +487,9 @@ def update(self): > PLAY_SCAN_INTERVAL): _LOGGER.debug("%s playing; scheduling update in %s seconds", self.name, PLAY_SCAN_INTERVAL) - call_later(self.hass, PLAY_SCAN_INTERVAL, lambda _: - self.schedule_update_ha_state(force_refresh=True)) + async_call_later(self.hass, PLAY_SCAN_INTERVAL, lambda _: + self.async_schedule_update_ha_state( + force_refresh=True)) elif self._should_poll: # Not playing, one last poll self._should_poll = False if not (self.hass.data[DATA_ALEXAMEDIA] @@ -485,13 +497,14 @@ def update(self): _LOGGER.debug("Disabling polling and scheduling last update in" " 300 seconds for %s", self.name) - call_later(self.hass, 300, lambda _: - self.schedule_update_ha_state(force_refresh=True)) + async_call_later(self.hass, 300, lambda _: + self.async_schedule_update_ha_state( + force_refresh=True)) else: _LOGGER.debug("Disabling polling for %s", self.name) self._last_update = util.utcnow() - self.schedule_update_ha_state() + self.async_schedule_update_ha_state() @property def media_content_type(self): @@ -550,9 +563,9 @@ def dnd_state(self, state): """Set the Do Not Disturb state.""" self._dnd = state - def set_shuffle(self, shuffle): + async def set_shuffle(self, shuffle): """Enable/disable shuffle mode.""" - self.alexa_api.shuffle(shuffle) + await self.alexa_api.shuffle(shuffle) self.shuffle_state = shuffle @property @@ -580,15 +593,15 @@ def supported_features(self): """Flag media player features that are supported.""" return SUPPORT_ALEXA - def set_volume_level(self, volume): + async def set_volume_level(self, volume): """Set volume level, range 0..1.""" if not self.available: return - self.alexa_api.set_volume(volume) + await self.alexa_api.set_volume(volume) self._media_vol_level = volume if not (self.hass.data[DATA_ALEXAMEDIA] ['accounts'][self._login.email]['websocket']): - self.update() + await self.async_update() @property def volume_level(self): @@ -602,7 +615,7 @@ def is_volume_muted(self): return True return False - def mute_volume(self, mute): + async def mute_volume(self, mute): """Mute the volume. Since we can't actually mute, we'll: @@ -615,111 +628,114 @@ def mute_volume(self, mute): self._media_is_muted = mute if mute: self._previous_volume = self.volume_level - self.alexa_api.set_volume(0) + await self.alexa_api.set_volume(0) else: if self._previous_volume is not None: - self.alexa_api.set_volume(self._previous_volume) + await self.alexa_api.set_volume(self._previous_volume) else: - self.alexa_api.set_volume(50) + await self.alexa_api.set_volume(50) if not (self.hass.data[DATA_ALEXAMEDIA] ['accounts'][self._login.email]['websocket']): - self.update() + await self.async_update() - def media_play(self): + async def async_media_play(self): """Send play command.""" if not (self.state in [STATE_PLAYING, STATE_PAUSED] and self.available): return - self.alexa_api.play() + await self.alexa_api.play() if not (self.hass.data[DATA_ALEXAMEDIA] ['accounts'][self._login.email]['websocket']): - self.update() + await self.async_update() - def media_pause(self): + async def async_media_pause(self): """Send pause command.""" if not (self.state in [STATE_PLAYING, STATE_PAUSED] and self.available): return - self.alexa_api.pause() + await self.alexa_api.pause() if not (self.hass.data[DATA_ALEXAMEDIA] ['accounts'][self._login.email]['websocket']): - self.update() + await self.async_update() - def turn_off(self): + async def async_turn_off(self): """Turn the client off. While Alexa's do not have on/off capability, we can use this as another trigger to do updates. For turning off, we can clear media_details. """ self._should_poll = False - self.media_pause() - self._clear_media_details() + await self.async_media_pause() + await self._clear_media_details() - def turn_on(self): + async def async_turn_on(self): """Turn the client on. While Alexa's do not have on/off capability, we can use this as another trigger to do updates. """ self._should_poll = True - self.media_pause() + await self.async_media_pause() - def media_next_track(self): + async def async_media_next_track(self): """Send next track command.""" if not (self.state in [STATE_PLAYING, STATE_PAUSED] and self.available): return - self.alexa_api.next() + await self.alexa_api.next() if not (self.hass.data[DATA_ALEXAMEDIA] ['accounts'][self._login.email]['websocket']): - self.update() + await self.async_update() - def media_previous_track(self): + async def async_media_previous_track(self): """Send previous track command.""" if not (self.state in [STATE_PLAYING, STATE_PAUSED] and self.available): return - self.alexa_api.previous() + await self.alexa_api.previous() if not (self.hass.data[DATA_ALEXAMEDIA] ['accounts'][self._login.email]['websocket']): - self.update() + await self.async_update() - def send_tts(self, message): + async def async_send_tts(self, message): """Send TTS to Device. NOTE: Does not work on WHA Groups. """ - self.alexa_api.send_tts(message, customer_id=self._customer_id) + await self.alexa_api.send_tts(message, customer_id=self._customer_id) - def send_announcement(self, message, **kwargs): + async def async_send_announcement(self, message, **kwargs): """Send announcement to the media player.""" - self.alexa_api.send_announcement(message, - customer_id=self._customer_id, - **kwargs) + await self.alexa_api.send_announcement(message, + customer_id=self._customer_id, + **kwargs) - def send_mobilepush(self, message, **kwargs): + async def async_send_mobilepush(self, message, **kwargs): """Send push to the media player's associated mobile devices.""" - self.alexa_api.send_mobilepush(message, - customer_id=self._customer_id, - **kwargs) + await self.alexa_api.send_mobilepush(message, + customer_id=self._customer_id, + **kwargs) - def play_media(self, media_type, media_id, enqueue=None, **kwargs): + async def async_play_media(self, + media_type, media_id, enqueue=None, **kwargs): """Send the play_media command to the media player.""" if media_type == "music": - self.alexa_api.send_tts("Sorry, text to speech can only be called " - " with the media player alexa tts service") + await self.alexa_api.send_tts( + "Sorry, text to speech can only be called " + " with the media player alexa tts service") elif media_type == "sequence": - self.alexa_api.send_sequence(media_id, - customer_id=self._customer_id, - **kwargs) + await self.alexa_api.send_sequence(media_id, + customer_id=self._customer_id, + **kwargs) elif media_type == "routine": - self.alexa_api.run_routine(media_id) + await self.alexa_api.run_routine(media_id) else: - self.alexa_api.play_music(media_type, media_id, - customer_id=self._customer_id, **kwargs) + await self.alexa_api.play_music( + media_type, media_id, + customer_id=self._customer_id, **kwargs) if not (self.hass.data[DATA_ALEXAMEDIA] ['accounts'][self._login.email]['websocket']): - self.update() + await self.async_update() @property def device_state_attributes(self): diff --git a/custom_components/alexa_media/notify.py b/custom_components/alexa_media/notify.py index aa0217df..ac87a5e4 100644 --- a/custom_components/alexa_media/notify.py +++ b/custom_components/alexa_media/notify.py @@ -26,7 +26,7 @@ EVENT_NOTIFY = "notify" -def get_service(hass, config, discovery_info=None): +async def async_get_service(hass, config, discovery_info=None): # pylint: disable=unused-argument """Get the demo notification service.""" return AlexaNotificationService(hass) @@ -39,7 +39,7 @@ def __init__(self, hass): """Initialize the service.""" self.hass = hass - def convert(self, names, type_="entities", filter_matches=False): + async 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 @@ -116,7 +116,7 @@ def devices(self): ['entities']['media_player'].values()) return devices - def send_message(self, message="", **kwargs): + async def send_message(self, message="", **kwargs): """Send a message to a Alexa device.""" _LOGGER.debug("Message: %s, kwargs: %s", message, @@ -128,22 +128,22 @@ def send_message(self, message="", **kwargs): data = kwargs.get(ATTR_DATA) if isinstance(targets, str): targets = [targets] - entities = self.convert(targets, type_="entities") + entities = await self.convert(targets, type_="entities") try: entities.extend(self.hass.components.group.expand_entity_ids( entities)) except ValueError: _LOGGER.debug("Invalid Home Assistant entity in %s", entities) if data['type'] == "tts": - targets = self.convert(entities, type_="entities", - filter_matches=True) + targets = await self.convert(entities, type_="entities", + filter_matches=True) _LOGGER.debug("TTS entities: %s", targets) for alexa in targets: _LOGGER.debug("TTS by %s : %s", alexa, message) - alexa.send_tts(message) + await alexa.async_send_tts(message) elif data['type'] == "announce": - targets = self.convert(entities, type_="serialnumbers", - filter_matches=True) + targets = await self.convert(entities, type_="serialnumbers", + filter_matches=True) _LOGGER.debug("Announce targets: %s entities: %s", list(map(hide_serial, targets)), entities) @@ -158,16 +158,17 @@ def send_message(self, message="", **kwargs): alexa, list(map(hide_serial, targets)), message) - alexa.send_announcement(message, - targets=targets, - title=title, - method=(data['method'] if - 'method' in data - else 'all')) + await alexa.async_send_announcement( + message, + targets=targets, + title=title, + method=(data['method'] if + 'method' in data + else 'all')) break elif data['type'] == "push": - targets = self.convert(entities, type_="entities", - filter_matches=True) + targets = await self.convert(entities, type_="entities", + filter_matches=True) for alexa in targets: _LOGGER.debug("Push by %s : %s %s", alexa, title, message) - alexa.send_mobilepush(message, title=title) + await alexa.async_send_mobilepush(message, title=title) diff --git a/custom_components/alexa_media/switch.py b/custom_components/alexa_media/switch.py index d25656cd..4f531531 100644 --- a/custom_components/alexa_media/switch.py +++ b/custom_components/alexa_media/switch.py @@ -13,7 +13,7 @@ from homeassistant import util from homeassistant.components.switch import SwitchDevice from homeassistant.exceptions import NoEntitySpecifiedError -from homeassistant.helpers.event import call_later +from homeassistant.helpers.event import async_call_later from . import DATA_ALEXAMEDIA from . import DOMAIN as ALEXA_DOMAIN @@ -25,8 +25,8 @@ _LOGGER = logging.getLogger(__name__) -def setup_platform(hass, config, add_devices_callback, - discovery_info=None): +async def async_setup_platform(hass, config, add_devices_callback, + discovery_info=None): """Set up the Alexa switch platform.""" _LOGGER.debug("Loading switches") devices = [] # type: List[DNDSwitch] @@ -46,11 +46,12 @@ def setup_platform(hass, config, add_devices_callback, ['switch']) = {} if key not in account_dict['entities']['media_player']: _LOGGER.debug("Media Players not loaded yet; delaying load") - call_later(hass, 5, lambda _: - setup_platform(hass, - config, - add_devices_callback, - discovery_info)) + async_call_later(hass, 5, lambda _: + hass.async_create_task( + async_setup_platform(hass, + config, + add_devices_callback, + discovery_info))) return True elif key not in account_dict['entities']['switch']: (hass.data[DATA_ALEXAMEDIA] @@ -62,7 +63,6 @@ def setup_platform(hass, config, add_devices_callback, alexa_client = class_(account_dict['entities'] ['media_player'] [key], - hass, account) # type: AlexaMediaSwitch (hass.data[DATA_ALEXAMEDIA] ['accounts'] @@ -84,7 +84,6 @@ class AlexaMediaSwitch(SwitchDevice): """Representation of a Alexa Media switch.""" def __init__(self, - hass, client, switch_property, switch_function, @@ -99,10 +98,15 @@ def __init__(self, self._state = False self._switch_function = switch_function _LOGGER.debug("Creating %s switch for %s", name, client) + + async def async_added_to_hass(self): + """Store register state change callback.""" # Register event handler on bus - hass.bus.listen(('{}_{}'.format(ALEXA_DOMAIN, - client.account))[0:32], - self._handle_event) + self.hass.bus.async_listen( + ('{}_{}'.format( + ALEXA_DOMAIN, + hide_email(self._account)))[0:32], + self._handle_event) def _handle_event(self, event): """Handle events. @@ -115,10 +119,10 @@ def _handle_event(self, event): if (queue_state['dopplerId'] ['deviceSerialNumber'] == self._client.unique_id): self._state = getattr(self._client, self._switch_property) - self.schedule_update_ha_state() + self.async_schedule_update_ha_state() - def _set_switch(self, state, **kwargs): - success = self._switch_function(state) + async def _set_switch(self, state, **kwargs): + success = await self._switch_function(state) # if function returns success, make immediate state change if success: setattr(self._client, self._switch_property, state) @@ -126,20 +130,27 @@ def _set_switch(self, state, **kwargs): getattr(self._client, self._switch_property), state) - self.schedule_update_ha_state() + self.async_schedule_update_ha_state() + elif self.should_poll: + # if we need to poll, refresh media_client + _LOGGER.debug("Requesting update of %s due to %s switch to %s", + self._client, + self._name, + state) + await self._client.async_update() @property def is_on(self): """Return true if on.""" return getattr(self._client, self._switch_property) - def turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Turn on switch.""" - self._set_switch(True, **kwargs) + await self._set_switch(True, **kwargs) - def turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs): """Turn off switch.""" - self._set_switch(False, **kwargs) + await self._set_switch(False, **kwargs) @property def unique_id(self): @@ -157,10 +168,10 @@ def should_poll(self): return not (self.hass.data[DATA_ALEXAMEDIA] ['accounts'][self._account]['websocket']) - def update(self): + async def async_update(self): """Update state.""" try: - self.schedule_update_ha_state() + self.async_schedule_update_ha_state() except NoEntitySpecifiedError: pass # we ignore this due to a harmless startup race condition @@ -168,40 +179,40 @@ def update(self): class DNDSwitch(AlexaMediaSwitch): """Representation of a Alexa Media Do Not Disturb switch.""" - def __init__(self, client, hass, account): + def __init__(self, client, account): """Initialize the Alexa Switch.""" # Class info - super().__init__(hass, - client, - 'dnd_state', - client.alexa_api.set_dnd_state, - account, - "do not disturb") + super().__init__( + client, + 'dnd_state', + client.alexa_api.set_dnd_state, + account, + "do not disturb") class ShuffleSwitch(AlexaMediaSwitch): """Representation of a Alexa Media Shuffle switch.""" - def __init__(self, client, hass, account): + def __init__(self, client, account): """Initialize the Alexa Switch.""" # Class info - super().__init__(hass, - client, - 'shuffle_state', - client.alexa_api.shuffle, - account, - "shuffle") + super().__init__( + client, + 'shuffle_state', + client.alexa_api.shuffle, + account, + "shuffle") class RepeatSwitch(AlexaMediaSwitch): """Representation of a Alexa Media Repeat switch.""" - def __init__(self, client, hass, account): + def __init__(self, client, account): """Initialize the Alexa Switch.""" # Class info - super().__init__(hass, - client, - 'repeat_state', - client.alexa_api.repeat, - account, - "repeat") + super().__init__( + client, + 'repeat_state', + client.alexa_api.repeat, + account, + "repeat") From 3edbea2c8bfa0b5161ab20ba004b15ea1ddc3feb Mon Sep 17 00:00:00 2001 From: "Alan D. Tse" Date: Sun, 18 Aug 2019 22:55:43 -0700 Subject: [PATCH 03/11] fix: complete migration to async --- custom_components/alexa_media/__init__.py | 191 ++++++++++++---------- 1 file changed, 106 insertions(+), 85 deletions(-) diff --git a/custom_components/alexa_media/__init__.py b/custom_components/alexa_media/__init__.py index 97a88161..519a395f 100644 --- a/custom_components/alexa_media/__init__.py +++ b/custom_components/alexa_media/__init__.py @@ -11,12 +11,16 @@ import voluptuous as vol +from typing import Optional, Text from homeassistant import util from homeassistant.const import ( - CONF_EMAIL, CONF_NAME, CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_URL) + CONF_EMAIL, CONF_NAME, CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_URL, + EVENT_HOMEASSISTANT_STOP) from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import async_call_later from homeassistant.helpers.discovery import async_load_platform +from alexapy import WebsocketEchoClient + from .const import ( ALEXA_COMPONENTS, CONF_DEBUG, CONF_ACCOUNTS, CONF_INCLUDE_DEVICES, CONF_EXCLUDE_DEVICES, DATA_ALEXAMEDIA, DOMAIN, MIN_TIME_BETWEEN_SCANS, @@ -59,7 +63,7 @@ def hide_email(email): """Obfuscate email.""" part = email.split('@') return "{}{}{}@{}".format(part[0][0], - "*"*(len(part[0])-2), + "*" * (len(part[0]) - 2), part[0][-1], part[1]) @@ -74,7 +78,7 @@ def hide_serial(item): response['serialNumber'] = hide_serial(serial) elif isinstance(item, str): response = "{}{}{}".format(item[0], - "*"*(len(item)-4), + "*" * (len(item) - 4), item[-3:]) return response @@ -98,9 +102,10 @@ async def async_setup(hass, config, discovery_info=None): hass.data[DATA_ALEXAMEDIA]['accounts'][email] = {"config": []} login = AlexaLogin(url, email, password, hass.config.path, account.get(CONF_DEBUG)) + (hass.data[DATA_ALEXAMEDIA]['accounts'][email]['login_obj']) = login await login.login_with_cookie() - test_login_status(hass, account, login, - setup_platform_callback) + await test_login_status(hass, account, login, + setup_platform_callback) return True @@ -121,18 +126,18 @@ async def setup_platform_callback(hass, config, login, callback_data): callback_data.get('authselectoption'), callback_data.get('verificationcode')) await login.login(data=callback_data) - test_login_status(hass, config, login, - setup_platform_callback) + await test_login_status(hass, config, login, + setup_platform_callback) -def request_configuration(hass, config, login, setup_platform_callback): +async def request_configuration(hass, config, login, setup_platform_callback): """Request configuration steps from the user using the configurator.""" - configurator = hass.components.configurator - - def configuration_callback(callback_data): + async def configuration_callback(callback_data): """Handle the submitted configuration.""" - hass.add_job(setup_platform_callback, hass, config, - login, callback_data) + await hass.async_add_job(setup_platform_callback, hass, config, + login, callback_data) + + configurator = hass.components.configurator status = login.status email = login.email # links = "" @@ -146,21 +151,21 @@ def configuration_callback(callback_data): # Get Captcha if (status and 'captcha_image_url' in status and status['captcha_image_url'] is not None): - config_id = configurator.request_config( + 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.request_config( + config_id = configurator.async_request_config( "Alexa Media Player - 2FA - {}".format(email), configuration_callback, description=('Please enter your Two-Factor Security code.' @@ -173,7 +178,7 @@ def configuration_callback(callback_data): status['claimspicker_required']): # Get picker method options = status['claimspicker_message'] if options: - config_id = configurator.request_config( + config_id = configurator.async_request_config( "Alexa Media Player - Verification Method - {}".format(email), configuration_callback, description=('Please select the verification method. ' @@ -184,12 +189,12 @@ def configuration_callback(callback_data): fields=[{'id': 'claimsoption', 'name': 'Option'}] ) else: - configuration_callback({}) + 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.request_config( + config_id = configurator.async_request_config( "Alexa Media Player - OTP Method - {}".format(email), configuration_callback, description=('Please select the OTP method. ' @@ -200,7 +205,7 @@ def configuration_callback(callback_data): fields=[{'id': 'authselectoption', 'name': 'Option'}] ) else: - configuration_callback({}) + await configuration_callback({}) elif (status and 'verificationcode_required' in status and status['verificationcode_required']): # Get picker method config_id = configurator.request_config( @@ -213,7 +218,7 @@ def configuration_callback(callback_data): fields=[{'id': 'verificationcode', 'name': 'Verification Code'}] ) else: # Check login - config_id = configurator.request_config( + config_id = configurator.async_request_config( "Alexa Media Player - Begin - {}".format(email), configuration_callback, description=('Please hit confirm to begin login attempt.'), @@ -222,21 +227,22 @@ def configuration_callback(callback_data): ) hass.data[DATA_ALEXAMEDIA]['accounts'][email]['config'].append(config_id) if 'error_message' in status and status['error_message']: - configurator.notify_errors( # use sync to delay next pop + configurator.async_notify_errors( config_id, status['error_message']) if len(hass.data[DATA_ALEXAMEDIA]['accounts'][email]['config']) > 1: - configurator.request_done((hass.data[DATA_ALEXAMEDIA] - ['accounts'][email]['config']).pop(0)) + configurator.async_request_done( + (hass.data[DATA_ALEXAMEDIA]['accounts'][email]['config']).pop(0)) -def test_login_status(hass, config, login, - setup_platform_callback): +async def test_login_status(hass, config, 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") - hass.async_add_job(setup_alexa, hass, config, - login) + await hass.async_add_job(setup_alexa, hass, config, + login) return if ('captcha_required' in login.status and login.status['captcha_required']): @@ -256,8 +262,8 @@ def test_login_status(hass, config, login, elif ('login_failed' in login.status and login.status['login_failed']): _LOGGER.debug("Creating configurator to start new login attempt") - hass.async_add_job(request_configuration, hass, config, login, - setup_platform_callback) + await hass.async_add_job(request_configuration, hass, config, login, + setup_platform_callback) async def setup_alexa(hass, config, login_obj): @@ -287,7 +293,8 @@ async def update_devices(): [email] ['entities'] ['media_player'].values()) - if (hass.data[DATA_ALEXAMEDIA]['accounts'][email]['websocket'] + if ('websocket' in hass.data[DATA_ALEXAMEDIA]['accounts'][email] + and hass.data[DATA_ALEXAMEDIA]['accounts'][email]['websocket'] and not (hass.data[DATA_ALEXAMEDIA] ['accounts'][email]['new_devices'])): return @@ -305,7 +312,8 @@ async def update_devices(): ['accounts'][email]['config'])): _LOGGER.debug("Alexa API disconnected; attempting to relogin") await login_obj.login() - test_login_status(hass, config, login_obj, setup_platform_callback) + await test_login_status(hass, + config, login_obj, setup_platform_callback) return new_alexa_clients = [] # list of newly discovered device names @@ -380,11 +388,12 @@ async def update_devices(): if new_alexa_clients: for component in ALEXA_COMPONENTS: - hass.async_create_task(async_load_platform(hass, - component, - DOMAIN, - {CONF_NAME: DOMAIN}, - config)) + hass.async_create_task( + async_load_platform(hass, + component, + DOMAIN, + {CONF_NAME: DOMAIN}, + config)) # Process last_called data to fire events await update_last_called(login_obj) @@ -416,8 +425,10 @@ async def update_last_called(login_obj, last_called=None): hide_serial(stored_data['last_called'] if 'last_called' in stored_data else None), hide_serial(last_called)) - hass.bus.fire(('{}_{}'.format(DOMAIN, hide_email(email)))[0:32], - {'last_called_change': last_called}) + hass.bus.async_fire( + ('{}_{}'.format(DOMAIN, + hide_email(email)))[0:32], + {'last_called_change': last_called}) (hass.data[DATA_ALEXAMEDIA] ['accounts'] [email] @@ -455,13 +466,12 @@ async def last_call_handler(call): login_obj = account_dict['login_obj'] await update_last_called(login_obj) - def ws_connect(): + async def ws_connect() -> WebsocketEchoClient: """Open WebSocket connection. This will only attempt one login before failing. """ - from alexapy import WebsocketEchoClient - websocket = None + websocket: Optional[WebsocketEchoClient] = None try: websocket = WebsocketEchoClient(login_obj, ws_handler, @@ -470,14 +480,14 @@ def ws_connect(): ws_error_handler) _LOGGER.debug("%s: Websocket created: %s", hide_email(email), websocket) - hass.async_create_task(websocket.async_run()) + await websocket.async_run() except BaseException as exception_: _LOGGER.debug("%s: Websocket creation failed: %s", hide_email(email), exception_) return websocket - def ws_handler(message_obj): + async def ws_handler(message_obj): """Handle websocket messages. This allows push notifications from Alexa to update last_called @@ -495,7 +505,11 @@ def ws_handler(message_obj): ['accounts'] [email] ['entities'] - ['media_player'].keys()) + ['media_player'].keys() if 'entities' in ( + hass.data[DATA_ALEXAMEDIA] + ['accounts'] + [email]) + else []) if command and json_payload: _LOGGER.debug("%s: Received websocket command: %s : %s", hide_email(email), @@ -514,56 +528,63 @@ def ws_handler(message_obj): 'timestamp': json_payload['timestamp'] } if (serial and serial in existing_serials): - hass.async_create_task(update_last_called( - login_obj, last_called)) - hass.bus.fire(('{}_{}'.format(DOMAIN, - hide_email(email)))[0:32], - {'push_activity': json_payload}) + await update_last_called(login_obj, + last_called) + hass.bus.async_fire( + ('{}_{}'.format(DOMAIN, + hide_email(email)))[0:32], + {'push_activity': json_payload}) elif command == 'PUSH_AUDIO_PLAYER_STATE': # Player update serial = (json_payload['dopplerId']['deviceSerialNumber']) if (serial and serial in existing_serials): _LOGGER.debug("Updating media_player: %s", json_payload) - hass.bus.fire(('{}_{}'.format(DOMAIN, - hide_email(email)))[0:32], - {'player_state': json_payload}) + hass.bus.async_fire( + ('{}_{}'.format(DOMAIN, + hide_email(email)))[0:32], + {'player_state': json_payload}) elif command == 'PUSH_VOLUME_CHANGE': # Player volume update serial = (json_payload['dopplerId']['deviceSerialNumber']) if (serial and serial in existing_serials): _LOGGER.debug("Updating media_player volume: %s", json_payload) - hass.bus.fire(('{}_{}'.format(DOMAIN, - hide_email(email)))[0:32], - {'player_state': json_payload}) + hass.bus.async_fire( + ('{}_{}'.format(DOMAIN, + hide_email(email)))[0:32], + {'player_state': json_payload}) elif command == 'PUSH_DOPPLER_CONNECTION_CHANGE': # Player availability update serial = (json_payload['dopplerId']['deviceSerialNumber']) if (serial and serial in existing_serials): _LOGGER.debug("Updating media_player availability %s", json_payload) - hass.bus.fire(('{}_{}'.format(DOMAIN, - hide_email(email)))[0:32], - {'player_state': json_payload}) + hass.bus.async_fire( + ('{}_{}'.format(DOMAIN, + hide_email(email)))[0:32], + {'player_state': json_payload}) elif command == 'PUSH_BLUETOOTH_STATE_CHANGE': # Player bluetooth update serial = (json_payload['dopplerId']['deviceSerialNumber']) if (serial and serial in existing_serials): _LOGGER.debug("Updating media_player bluetooth %s", json_payload) - bluetooth_state = update_bluetooth_state(login_obj, serial) - hass.bus.fire(('{}_{}'.format(DOMAIN, - hide_email(email)))[0:32], - {'bluetooth_change': bluetooth_state}) + bluetooth_state = await update_bluetooth_state(login_obj, + serial) + hass.bus.async_fire( + ('{}_{}'.format(DOMAIN, + hide_email(email)))[0:32], + {'bluetooth_change': bluetooth_state}) elif command == 'PUSH_MEDIA_QUEUE_CHANGE': # Player availability update serial = (json_payload['dopplerId']['deviceSerialNumber']) if (serial and serial in existing_serials): _LOGGER.debug("Updating media_player queue %s", json_payload) - hass.bus.fire(('{}_{}'.format(DOMAIN, - hide_email(email)))[0:32], - {'queue_state': json_payload}) + hass.bus.async_fire( + ('{}_{}'.format(DOMAIN, + hide_email(email)))[0:32], + {'queue_state': json_payload}) if (serial and serial not in existing_serials and serial not in (hass.data[DATA_ALEXAMEDIA] ['accounts'] @@ -572,44 +593,44 @@ def ws_handler(message_obj): _LOGGER.debug("Discovered new media_player %s", serial) (hass.data[DATA_ALEXAMEDIA] ['accounts'][email]['new_devices']) = True - hass.async_create_task(update_devices(no_throttle=True)) + await update_devices(no_throttle=True) - def ws_open_handler(): + async def ws_open_handler(): """Handle websocket open.""" - email = login_obj.email + email: Text = login_obj.email _LOGGER.debug("%s: Websocket succesfully connected", hide_email(email)) (hass.data[DATA_ALEXAMEDIA] ['accounts'][email]['websocketerror']) = 0 # set errors to 0 - def ws_close_handler(): + async def ws_close_handler(): """Handle websocket close. This should attempt to reconnect up to 5 times """ - from time import sleep - email = login_obj.email - errors = (hass.data - [DATA_ALEXAMEDIA]['accounts'][email]['websocketerror']) - delay = 5*2**errors + from asyncio import sleep + email: Text = login_obj.email + errors: int = (hass.data + [DATA_ALEXAMEDIA]['accounts'][email]['websocketerror']) + delay: int = 5 * 2 ** errors if (errors < 5): _LOGGER.debug("%s: Websocket closed; reconnect #%i in %is", hide_email(email), errors, delay) - sleep(delay) + await sleep(delay) if (not (hass.data [DATA_ALEXAMEDIA]['accounts'][email]['websocket'])): - (hass.data[DATA_ALEXAMEDIA]['accounts'] - [email]['websocket']) = ws_connect() + (hass.data[DATA_ALEXAMEDIA]['accounts'] + [email]['websocket']) = await ws_connect() else: _LOGGER.debug("%s: Websocket closed; retries exceeded; polling", hide_email(email)) (hass.data[DATA_ALEXAMEDIA]['accounts'] [email]['websocket']) = None - hass.async_create_task(update_devices()) + await update_devices() - def ws_error_handler(message): + async def ws_error_handler(message): """Handle websocket error. This currently logs the error. In the future, this should invalidate @@ -628,10 +649,8 @@ def ws_error_handler(message): ['accounts'][email]['websocketerror']) = errors + 1 include = config.get(CONF_INCLUDE_DEVICES) exclude = config.get(CONF_EXCLUDE_DEVICES) - scan_interval = config.get(CONF_SCAN_INTERVAL) - email = login_obj.email - (hass.data[DATA_ALEXAMEDIA]['accounts'][email]['websocket']) = ws_connect() - (hass.data[DATA_ALEXAMEDIA]['accounts'][email]['login_obj']) = login_obj + scan_interval: int = config.get(CONF_SCAN_INTERVAL) + email: Text = login_obj.email if 'devices' not in hass.data[DATA_ALEXAMEDIA]['accounts'][email]: (hass.data[DATA_ALEXAMEDIA]['accounts'][email] ['devices']) = {'media_player': {}} @@ -643,7 +662,9 @@ def ws_error_handler(message): ['entities']) = {'media_player': {}} (hass.data[DATA_ALEXAMEDIA] ['accounts'][email]['new_devices']) = True # force initial update - hass.async_create_task(update_devices()) + (hass.data[DATA_ALEXAMEDIA]['accounts'][email]['websocket']) = \ + await ws_connect() + await update_devices() hass.services.async_register(DOMAIN, SERVICE_UPDATE_LAST_CALLED, last_call_handler, schema=LAST_CALL_UPDATE_SCHEMA) From 33f780f4ea4557c198a2ea9a228485f4b50716e6 Mon Sep 17 00:00:00 2001 From: "Alan D. Tse" Date: Sun, 18 Aug 2019 22:56:33 -0700 Subject: [PATCH 04/11] fix: properly close aiohttp session on HA shutdown --- custom_components/alexa_media/__init__.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/custom_components/alexa_media/__init__.py b/custom_components/alexa_media/__init__.py index 519a395f..90e601cb 100644 --- a/custom_components/alexa_media/__init__.py +++ b/custom_components/alexa_media/__init__.py @@ -85,12 +85,26 @@ def hide_serial(item): async def async_setup(hass, config, discovery_info=None): """Set up the Alexa domain.""" + async def close_alexa_media(event) -> None: + """Clean up Alexa connections.""" + _LOGGER.debug("Received shutdown request: %s", event) + for email, account_dict in (hass.data + [DATA_ALEXAMEDIA]['accounts'].items()): + login_obj = account_dict['login_obj'] + if not login_obj._session.closed: + if login_obj._session._connector_owner: + await login_obj._session._connector.close() + login_obj._session._connector = None + _LOGGER.debug("%s: Connection closed: %s", + hide_email(email), + login_obj._session.closed) if DATA_ALEXAMEDIA not in hass.data: hass.data[DATA_ALEXAMEDIA] = {} hass.data[DATA_ALEXAMEDIA]['accounts'] = {} from alexapy import AlexaLogin, __version__ as alexapy_version _LOGGER.info(STARTUP) _LOGGER.info("Loaded alexapy==%s", alexapy_version) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, close_alexa_media) domainconfig = config.get(DOMAIN) for account in domainconfig[CONF_ACCOUNTS]: # if account[CONF_EMAIL] in configured_instances(hass): From d8f08d700555929ef7025b008897fcb1abc06888 Mon Sep 17 00:00:00 2001 From: "Alan D. Tse" Date: Sat, 17 Aug 2019 16:23:53 -0700 Subject: [PATCH 05/11] feat: migrate to async --- custom_components/alexa_media/__init__.py | 80 ++++---- .../alexa_media/alarm_control_panel.py | 75 +++---- custom_components/alexa_media/media_player.py | 192 ++++++++++-------- custom_components/alexa_media/notify.py | 37 ++-- custom_components/alexa_media/switch.py | 99 +++++---- 5 files changed, 262 insertions(+), 221 deletions(-) diff --git a/custom_components/alexa_media/__init__.py b/custom_components/alexa_media/__init__.py index 605b7036..97a88161 100644 --- a/custom_components/alexa_media/__init__.py +++ b/custom_components/alexa_media/__init__.py @@ -15,8 +15,8 @@ from homeassistant.const import ( CONF_EMAIL, CONF_NAME, CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_URL) from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.event import track_time_interval -from homeassistant.helpers.discovery import load_platform +from homeassistant.helpers.event import async_call_later +from homeassistant.helpers.discovery import async_load_platform from .const import ( ALEXA_COMPONENTS, CONF_DEBUG, CONF_ACCOUNTS, CONF_INCLUDE_DEVICES, CONF_EXCLUDE_DEVICES, DATA_ALEXAMEDIA, DOMAIN, MIN_TIME_BETWEEN_SCANS, @@ -79,7 +79,7 @@ def hide_serial(item): return response -def setup(hass, config, discovery_info=None): +async def async_setup(hass, config, discovery_info=None): """Set up the Alexa domain.""" if DATA_ALEXAMEDIA not in hass.data: hass.data[DATA_ALEXAMEDIA] = {} @@ -87,8 +87,8 @@ def setup(hass, config, discovery_info=None): from alexapy import AlexaLogin, __version__ as alexapy_version _LOGGER.info(STARTUP) _LOGGER.info("Loaded alexapy==%s", alexapy_version) - config = config.get(DOMAIN) - for account in config[CONF_ACCOUNTS]: + domainconfig = config.get(DOMAIN) + for account in domainconfig[CONF_ACCOUNTS]: # if account[CONF_EMAIL] in configured_instances(hass): # continue @@ -98,13 +98,13 @@ def setup(hass, config, discovery_info=None): hass.data[DATA_ALEXAMEDIA]['accounts'][email] = {"config": []} login = AlexaLogin(url, email, password, hass.config.path, account.get(CONF_DEBUG)) - + await login.login_with_cookie() test_login_status(hass, account, login, setup_platform_callback) return True -def setup_platform_callback(hass, config, login, callback_data): +async def setup_platform_callback(hass, config, login, callback_data): """Handle response from configurator. Args: @@ -120,7 +120,7 @@ def setup_platform_callback(hass, config, login, callback_data): callback_data.get('claimsoption'), callback_data.get('authselectoption'), callback_data.get('verificationcode')) - login.login(data=callback_data) + await login.login(data=callback_data) test_login_status(hass, config, login, setup_platform_callback) @@ -235,8 +235,8 @@ def test_login_status(hass, config, login, """Test the login status and spawn requests for info.""" if 'login_successful' in login.status and login.status['login_successful']: _LOGGER.debug("Setting up Alexa devices") - hass.add_job(setup_alexa, hass, config, - login) + hass.async_add_job(setup_alexa, hass, config, + login) return if ('captcha_required' in login.status and login.status['captcha_required']): @@ -256,14 +256,14 @@ def test_login_status(hass, config, login, elif ('login_failed' in login.status and login.status['login_failed']): _LOGGER.debug("Creating configurator to start new login attempt") - hass.add_job(request_configuration, hass, config, login, - setup_platform_callback) + hass.async_add_job(request_configuration, hass, config, login, + setup_platform_callback) -def setup_alexa(hass, config, login_obj): +async def setup_alexa(hass, config, login_obj): """Set up a alexa api based on host parameter.""" @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS) - def update_devices(): + async def update_devices(): """Ping Alexa API to identify all devices, bluetooth, and last called device. This will add new devices and services when discovered. By default this @@ -292,10 +292,10 @@ def update_devices(): ['accounts'][email]['new_devices'])): return hass.data[DATA_ALEXAMEDIA]['accounts'][email]['new_devices'] = False - devices = AlexaAPI.get_devices(login_obj) - bluetooth = AlexaAPI.get_bluetooth(login_obj) - preferences = AlexaAPI.get_device_preferences(login_obj) - dnd = AlexaAPI.get_dnd_state(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) _LOGGER.debug("%s: Found %s devices, %s bluetooth", hide_email(email), len(devices) if devices is not None else '', @@ -304,7 +304,7 @@ def update_devices(): and not (hass.data[DATA_ALEXAMEDIA] ['accounts'][email]['config'])): _LOGGER.debug("Alexa API disconnected; attempting to relogin") - login_obj.login_with_cookie() + await login_obj.login() test_login_status(hass, config, login_obj, setup_platform_callback) return @@ -380,13 +380,19 @@ def update_devices(): if new_alexa_clients: for component in ALEXA_COMPONENTS: - load_platform(hass, component, DOMAIN, {CONF_NAME: DOMAIN}, - config) + hass.async_create_task(async_load_platform(hass, + component, + DOMAIN, + {CONF_NAME: DOMAIN}, + config)) # Process last_called data to fire events - update_last_called(login_obj) + await update_last_called(login_obj) + scan_interval = config.get(CONF_SCAN_INTERVAL) + async_call_later(hass, scan_interval.total_seconds(), lambda _: + hass.async_create_task(update_devices())) - def update_last_called(login_obj, last_called=None): + async def update_last_called(login_obj, last_called=None): """Update the last called device for the login_obj. This will store the last_called in hass.data and also fire an event @@ -396,7 +402,7 @@ def update_last_called(login_obj, last_called=None): if last_called: last_called = last_called else: - last_called = AlexaAPI.get_last_device_serial(login_obj) + last_called = await AlexaAPI.get_last_device_serial(login_obj) _LOGGER.debug("%s: Updated last_called: %s", hide_email(email), hide_serial(last_called)) @@ -417,10 +423,10 @@ def update_last_called(login_obj, last_called=None): [email] ['last_called']) = last_called - def update_bluetooth_state(login_obj, device_serial): + async def update_bluetooth_state(login_obj, device_serial): """Update the bluetooth state on ws bluetooth event.""" from alexapy import AlexaAPI - bluetooth = AlexaAPI.get_bluetooth(login_obj) + bluetooth = await AlexaAPI.get_bluetooth(login_obj) device = (hass.data[DATA_ALEXAMEDIA] ['accounts'] [email] @@ -433,7 +439,7 @@ def update_bluetooth_state(login_obj, device_serial): device['bluetooth_state'] = b_state return device['bluetooth_state'] - def last_call_handler(call): + async def last_call_handler(call): """Handle last call service request. Args: @@ -447,7 +453,7 @@ def last_call_handler(call): if requested_emails and email not in requested_emails: continue login_obj = account_dict['login_obj'] - update_last_called(login_obj) + await update_last_called(login_obj) def ws_connect(): """Open WebSocket connection. @@ -464,6 +470,7 @@ def ws_connect(): ws_error_handler) _LOGGER.debug("%s: Websocket created: %s", hide_email(email), websocket) + hass.async_create_task(websocket.async_run()) except BaseException as exception_: _LOGGER.debug("%s: Websocket creation failed: %s", hide_email(email), @@ -507,7 +514,8 @@ def ws_handler(message_obj): 'timestamp': json_payload['timestamp'] } if (serial and serial in existing_serials): - update_last_called(login_obj, last_called) + hass.async_create_task(update_last_called( + login_obj, last_called)) hass.bus.fire(('{}_{}'.format(DOMAIN, hide_email(email)))[0:32], {'push_activity': json_payload}) @@ -564,7 +572,7 @@ def ws_handler(message_obj): _LOGGER.debug("Discovered new media_player %s", serial) (hass.data[DATA_ALEXAMEDIA] ['accounts'][email]['new_devices']) = True - update_devices(no_throttle=True) + hass.async_create_task(update_devices(no_throttle=True)) def ws_open_handler(): """Handle websocket open.""" @@ -599,7 +607,7 @@ def ws_close_handler(): hide_email(email)) (hass.data[DATA_ALEXAMEDIA]['accounts'] [email]['websocket']) = None - update_devices() + hass.async_create_task(update_devices()) def ws_error_handler(message): """Handle websocket error. @@ -635,14 +643,14 @@ def ws_error_handler(message): ['entities']) = {'media_player': {}} (hass.data[DATA_ALEXAMEDIA] ['accounts'][email]['new_devices']) = True # force initial update - track_time_interval(hass, lambda now: update_devices(), scan_interval) - update_devices() - hass.services.register(DOMAIN, SERVICE_UPDATE_LAST_CALLED, - last_call_handler, schema=LAST_CALL_UPDATE_SCHEMA) + hass.async_create_task(update_devices()) + hass.services.async_register(DOMAIN, SERVICE_UPDATE_LAST_CALLED, + last_call_handler, + schema=LAST_CALL_UPDATE_SCHEMA) # Clear configurator. We delay till here to avoid leaving a modal orphan for config_id in hass.data[DATA_ALEXAMEDIA]['accounts'][email]['config']: configurator = hass.components.configurator - configurator.request_done(config_id) + configurator.async_request_done(config_id) hass.data[DATA_ALEXAMEDIA]['accounts'][email]['config'] = [] return True diff --git a/custom_components/alexa_media/alarm_control_panel.py b/custom_components/alexa_media/alarm_control_panel.py index 686072f9..fe853eca 100644 --- a/custom_components/alexa_media/alarm_control_panel.py +++ b/custom_components/alexa_media/alarm_control_panel.py @@ -13,9 +13,9 @@ from homeassistant import util from homeassistant.components.alarm_control_panel import AlarmControlPanel from homeassistant.const import (STATE_ALARM_ARMED_AWAY, - STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED) + STATE_ALARM_DISARMED) from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.event import call_later +from homeassistant.helpers.event import async_call_later from . import DATA_ALEXAMEDIA from . import DOMAIN as ALEXA_DOMAIN @@ -26,15 +26,17 @@ DEPENDENCIES = [ALEXA_DOMAIN] -def setup_platform(hass, config, add_devices_callback, - discovery_info=None): +async def async_setup_platform(hass, + config, + add_devices_callback, + discovery_info=None) -> bool: """Set up the Alexa alarm control panel platform.""" devices = [] # type: List[AlexaAlarmControlPanel] for account, account_dict in (hass.data[DATA_ALEXAMEDIA] ['accounts'].items()): - alexa_client = AlexaAlarmControlPanel(account_dict['login_obj'], - hass) \ - # type: AlexaAlarmControlPanel + alexa_client: AlexaAlarmControlPanel = AlexaAlarmControlPanel( + account_dict['login_obj']) + await alexa_client.init() if not (alexa_client and alexa_client.unique_id): _LOGGER.debug("%s: Skipping creation of uninitialized device: %s", hide_email(account), @@ -65,7 +67,7 @@ def setup_platform(hass, config, add_devices_callback, class AlexaAlarmControlPanel(AlarmControlPanel): """Implementation of Alexa Media Player alarm control panel.""" - def __init__(self, login, hass): + def __init__(self, login) -> None: # pylint: disable=unexpected-keyword-arg """Initialize the Alexa device.""" from alexapy import AlexaAPI @@ -74,7 +76,6 @@ def __init__(self, login, hass): self.alexa_api = AlexaAPI(self, login) self.alexa_api_session = login.session self.account = hide_email(login.email) - self.hass = hass # Guard info self._appliance_id = None @@ -84,9 +85,10 @@ def __init__(self, login, hass): self._should_poll = False self._attrs = {} + async def init(self): try: from simplejson import JSONDecodeError - data = self.alexa_api.get_guard_details(self._login) + data = await self.alexa_api.get_guard_details(self._login) guard_dict = (data['locationDetails'] ['locationDetails']['Default_Location'] ['amazonBridgeDetails']['amazonBridgeDetails'] @@ -107,11 +109,14 @@ def __init__(self, login, hass): if not self._appliance_id: _LOGGER.debug("%s: No Alexa Guard entity found", self.account) return None + + async def async_added_to_hass(self): + """Store register state change callback.""" # Register event handler on bus - hass.bus.listen(('{}_{}'.format(ALEXA_DOMAIN, - hide_email(login.email)))[0:32], - self._handle_event) - self.refresh(no_throttle=True) + self.hass.bus.async_listen(('{}_{}'.format( + ALEXA_DOMAIN, + hide_email(self._login.email)))[0:32], + self._handle_event) def _handle_event(self, event): """Handle websocket events. @@ -119,17 +124,18 @@ def _handle_event(self, event): Used instead of polling. """ if 'push_activity' in event.data: - call_later(self.hass, 2, lambda _: - self.refresh(no_throttle=True)) + async_call_later(self.hass, 2, lambda _: + self.hass.async_create_task( + self.async_update(no_throttle=True))) @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS) - def refresh(self): + async def async_update(self): """Update Guard state.""" import json _LOGGER.debug("%s: Refreshing %s", self.account, self.name) state = None - state_json = self.alexa_api.get_guard_state(self._login, - self._appliance_id) + state_json = await self.alexa_api.get_guard_state(self._login, + self._appliance_id) # _LOGGER.debug("%s: state_json %s", self.account, state_json) if (state_json and 'deviceStates' in state_json and state_json['deviceStates']): @@ -156,33 +162,32 @@ def refresh(self): else: self._state = STATE_ALARM_DISARMED _LOGGER.debug("%s: Alarm State: %s", self.account, self.state) - self.schedule_update_ha_state() + self.async_schedule_update_ha_state() - def alarm_disarm(self, code=None): + async def async_alarm_disarm(self, code=None) -> None: # pylint: disable=unexpected-keyword-arg """Send disarm command. We use the arm_home state as Alexa does not have disarm state. """ - self.alarm_arm_home() - self.schedule_update_ha_state() + await self.async_alarm_arm_home() - def alarm_arm_home(self, code=None): + async def async_alarm_arm_home(self, code=None) -> None: """Send arm home command.""" - self.alexa_api.set_guard_state(self._login, - self._guard_entity_id, - "ARMED_STAY") - self.refresh(no_throttle=True) - self.schedule_update_ha_state() + await self.alexa_api.set_guard_state(self._login, + self._guard_entity_id, + "ARMED_STAY") + await self.async_update(no_throttle=True) + self.async_schedule_update_ha_state() - def alarm_arm_away(self, code=None): + async def async_alarm_arm_away(self, code=None) -> None: """Send arm away command.""" # pylint: disable=unexpected-keyword-arg - self.alexa_api.set_guard_state(self._login, - self._guard_entity_id, - "ARMED_AWAY") - self.refresh(no_throttle=True) - self.schedule_update_ha_state() + 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() @property def unique_id(self): diff --git a/custom_components/alexa_media/media_player.py b/custom_components/alexa_media/media_player.py index 4e3c5ad2..081749f0 100644 --- a/custom_components/alexa_media/media_player.py +++ b/custom_components/alexa_media/media_player.py @@ -32,13 +32,15 @@ STATE_STANDBY) from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.event import call_later +from homeassistant.helpers.event import async_call_later from homeassistant.helpers.service import extract_entity_ids +from homeassistant.helpers.discovery import async_load_platform from .const import ATTR_MESSAGE, PLAY_SCAN_INTERVAL from . import ( DOMAIN as ALEXA_DOMAIN, + CONF_NAME, DATA_ALEXAMEDIA, MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS, hide_email, hide_serial) @@ -53,8 +55,8 @@ DEPENDENCIES = [ALEXA_DOMAIN] -def setup_platform(hass, config, add_devices_callback, - discovery_info=None): +async def async_setup_platform(hass, config, add_devices_callback, + discovery_info=None): """Set up the Alexa media player platform.""" devices = [] # type: List[AlexaClient] for account, account_dict in (hass.data[DATA_ALEXAMEDIA] @@ -62,8 +64,9 @@ def setup_platform(hass, config, add_devices_callback, for key, device in account_dict['devices']['media_player'].items(): if key not in account_dict['entities']['media_player']: alexa_client = AlexaClient(device, - account_dict['login_obj'], - hass) + account_dict['login_obj'] + ) + await alexa_client.init(device) devices.append(alexa_client) (hass.data[DATA_ALEXAMEDIA] ['accounts'] @@ -87,14 +90,14 @@ def setup_platform(hass, config, add_devices_callback, class AlexaClient(MediaPlayerDevice): """Representation of a Alexa device.""" - def __init__(self, device, login, hass): + def __init__(self, device, login): """Initialize the Alexa device.""" from alexapy import AlexaAPI # Class info self._login = login self.alexa_api = AlexaAPI(self, login) - self.auth = AlexaAPI.get_authentication(login) + self.auth = None self.alexa_api_session = login.session self.account = hide_email(login.email) @@ -104,7 +107,6 @@ def __init__(self, device, login, hass): self._customer_email = None self._customer_id = None self._customer_name = None - self._set_authentication_details(self.auth) # Device info self._device = None @@ -141,13 +143,21 @@ def __init__(self, device, login, hass): # Polling state self._should_poll = True self._last_update = 0 - self.refresh(device) + + async def init(self, device): + from alexapy import AlexaAPI + self.auth = await AlexaAPI.get_authentication(self._login) + await self._set_authentication_details(self.auth) + await self.refresh(device) + + async def async_added_to_hass(self): + """Store register state change callback.""" # Register event handler on bus - hass.bus.listen(('{}_{}'.format(ALEXA_DOMAIN, - hide_email(login.email)))[0:32], - self._handle_event) + self.hass.bus.async_listen(('{}_{}'.format(ALEXA_DOMAIN, + hide_email(self._login.email)))[0:32], + self._handle_event) - def _handle_event(self, event): + async def _handle_event(self, event): """Handle events. This will update last_called and player_state events. @@ -173,19 +183,20 @@ def _handle_event(self, event): self._last_called = True else: self._last_called = False - if (self.hass and self.schedule_update_ha_state): + if (self.hass and self.async_schedule_update_ha_state): email = self._login.email force_refresh = not (self.hass.data[DATA_ALEXAMEDIA] ['accounts'][email]['websocket']) - self.schedule_update_ha_state(force_refresh=force_refresh) + self.async_schedule_update_ha_state( + force_refresh=force_refresh) elif 'bluetooth_change' in event.data: if (event.data['bluetooth_change']['deviceSerialNumber'] == self.device_serial_number): self._bluetooth_state = event.data['bluetooth_change'] self._source = self._get_source() self._source_list = self._get_source_list() - if (self.hass and self.schedule_update_ha_state): - self.schedule_update_ha_state() + if (self.hass and self.async_schedule_update_ha_state): + await self.async_schedule_update_ha_state() elif 'player_state' in event.data: player_state = event.data['player_state'] if (player_state['dopplerId'] @@ -194,19 +205,19 @@ def _handle_event(self, event): _LOGGER.debug("%s state update: %s", self.name, player_state['audioPlayerState']) - self.update() # refresh is necessary to pull all data + await self.async_update() # refresh is necessary to pull all data elif 'volumeSetting' in player_state: _LOGGER.debug("%s volume updated: %s", self.name, player_state['volumeSetting']) self._media_vol_level = player_state['volumeSetting']/100 if (self.hass and self.schedule_update_ha_state): - self.schedule_update_ha_state() + self.async_schedule_update_ha_state() elif 'dopplerConnectionState' in player_state: self._available = (player_state['dopplerConnectionState'] == "ONLINE") if (self.hass and self.schedule_update_ha_state): - self.schedule_update_ha_state() + self.async_schedule_update_ha_state() if 'queue_state' in event.data: queue_state = event.data['queue_state'] if (queue_state['dopplerId'] @@ -228,7 +239,7 @@ def _handle_event(self, event): self._shuffle, queue_state['playBackOrder']) - def _clear_media_details(self): + async def _clear_media_details(self): """Set all Media Items to None.""" # General self._media_duration = None @@ -241,7 +252,7 @@ def _clear_media_details(self): self._media_is_muted = None self._media_vol_level = None - def _set_authentication_details(self, auth): + async def _set_authentication_details(self, auth): """Set Authentication based off auth.""" self._authenticated = auth['authenticated'] self._can_access_prime_music = auth['canAccessPrimeMusicContent'] @@ -250,7 +261,7 @@ def _set_authentication_details(self, auth): self._customer_name = auth['customerName'] @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS) - def refresh(self, device=None): + async def refresh(self, device=None): """Refresh device data. This is a per device refresh and for many Alexa devices can result in @@ -279,13 +290,13 @@ def refresh(self, device=None): self._dnd = device['dnd'] if 'dnd' in device else None if self._available is True: _LOGGER.debug("%s: Refreshing %s", self.account, self.name) - self._source = self._get_source() - self._source_list = self._get_source_list() - self._last_called = self._get_last_called() - session = self.alexa_api.get_state() + self._source = await self._get_source() + self._source_list = await self._get_source_list() + self._last_called = await self._get_last_called() + session = await self.alexa_api.get_state() else: session = None - self._clear_media_details() + await self._clear_media_details() # update the session if it exists; not doing relogin here if session is not None: self._session = session @@ -357,10 +368,10 @@ def source_list(self): """List of available input sources.""" return self._source_list - def select_source(self, source): + async def select_source(self, source): """Select input source.""" if source == 'Local Speaker': - self.alexa_api.disconnect_bluetooth() + await self.alexa_api.disconnect_bluetooth() self._source = 'Local Speaker' elif self._bluetooth_state['pairedDeviceList'] is not None: for devices in self._bluetooth_state['pairedDeviceList']: @@ -368,7 +379,7 @@ def select_source(self, source): self.alexa_api.set_bluetooth(devices['address']) self._source = source - def _get_source(self): + async def _get_source(self): source = 'Local Speaker' if self._bluetooth_state['pairedDeviceList'] is not None: for device in self._bluetooth_state['pairedDeviceList']: @@ -376,7 +387,7 @@ def _get_source(self): return device['friendlyName'] return source - def _get_source_list(self): + async def _get_source_list(self): sources = [] if self._bluetooth_state['pairedDeviceList'] is not None: for devices in self._bluetooth_state['pairedDeviceList']: @@ -385,7 +396,7 @@ def _get_source_list(self): sources.append(devices['friendlyName']) return ['Local Speaker'] + sources - def _get_last_called(self): + async def _get_last_called(self): try: last_called_serial = (None if self.hass is None else (self.hass.data[DATA_ALEXAMEDIA] @@ -445,7 +456,7 @@ def state(self): return STATE_IDLE return STATE_STANDBY - def update(self): + async def async_update(self): """Get the latest details on a media player. Because media players spend the majority of time idle, an adaptive @@ -464,8 +475,8 @@ def update(self): ['devices'] ['media_player'] [self.unique_id]) - self.refresh(device, # pylint: disable=unexpected-keyword-arg - no_throttle=True) + await self.refresh(device, # pylint: disable=unexpected-keyword-arg + no_throttle=True) if (self.state in [STATE_PLAYING] and # only enable polling if websocket not connected (not self.hass.data[DATA_ALEXAMEDIA] @@ -476,8 +487,9 @@ def update(self): > PLAY_SCAN_INTERVAL): _LOGGER.debug("%s playing; scheduling update in %s seconds", self.name, PLAY_SCAN_INTERVAL) - call_later(self.hass, PLAY_SCAN_INTERVAL, lambda _: - self.schedule_update_ha_state(force_refresh=True)) + async_call_later(self.hass, PLAY_SCAN_INTERVAL, lambda _: + self.async_schedule_update_ha_state( + force_refresh=True)) elif self._should_poll: # Not playing, one last poll self._should_poll = False if not (self.hass.data[DATA_ALEXAMEDIA] @@ -485,13 +497,14 @@ def update(self): _LOGGER.debug("Disabling polling and scheduling last update in" " 300 seconds for %s", self.name) - call_later(self.hass, 300, lambda _: - self.schedule_update_ha_state(force_refresh=True)) + async_call_later(self.hass, 300, lambda _: + self.async_schedule_update_ha_state( + force_refresh=True)) else: _LOGGER.debug("Disabling polling for %s", self.name) self._last_update = util.utcnow() - self.schedule_update_ha_state() + self.async_schedule_update_ha_state() @property def media_content_type(self): @@ -550,9 +563,9 @@ def dnd_state(self, state): """Set the Do Not Disturb state.""" self._dnd = state - def set_shuffle(self, shuffle): + async def set_shuffle(self, shuffle): """Enable/disable shuffle mode.""" - self.alexa_api.shuffle(shuffle) + await self.alexa_api.shuffle(shuffle) self.shuffle_state = shuffle @property @@ -580,15 +593,15 @@ def supported_features(self): """Flag media player features that are supported.""" return SUPPORT_ALEXA - def set_volume_level(self, volume): + async def set_volume_level(self, volume): """Set volume level, range 0..1.""" if not self.available: return - self.alexa_api.set_volume(volume) + await self.alexa_api.set_volume(volume) self._media_vol_level = volume if not (self.hass.data[DATA_ALEXAMEDIA] ['accounts'][self._login.email]['websocket']): - self.update() + await self.async_update() @property def volume_level(self): @@ -602,7 +615,7 @@ def is_volume_muted(self): return True return False - def mute_volume(self, mute): + async def mute_volume(self, mute): """Mute the volume. Since we can't actually mute, we'll: @@ -615,111 +628,114 @@ def mute_volume(self, mute): self._media_is_muted = mute if mute: self._previous_volume = self.volume_level - self.alexa_api.set_volume(0) + await self.alexa_api.set_volume(0) else: if self._previous_volume is not None: - self.alexa_api.set_volume(self._previous_volume) + await self.alexa_api.set_volume(self._previous_volume) else: - self.alexa_api.set_volume(50) + await self.alexa_api.set_volume(50) if not (self.hass.data[DATA_ALEXAMEDIA] ['accounts'][self._login.email]['websocket']): - self.update() + await self.async_update() - def media_play(self): + async def async_media_play(self): """Send play command.""" if not (self.state in [STATE_PLAYING, STATE_PAUSED] and self.available): return - self.alexa_api.play() + await self.alexa_api.play() if not (self.hass.data[DATA_ALEXAMEDIA] ['accounts'][self._login.email]['websocket']): - self.update() + await self.async_update() - def media_pause(self): + async def async_media_pause(self): """Send pause command.""" if not (self.state in [STATE_PLAYING, STATE_PAUSED] and self.available): return - self.alexa_api.pause() + await self.alexa_api.pause() if not (self.hass.data[DATA_ALEXAMEDIA] ['accounts'][self._login.email]['websocket']): - self.update() + await self.async_update() - def turn_off(self): + async def async_turn_off(self): """Turn the client off. While Alexa's do not have on/off capability, we can use this as another trigger to do updates. For turning off, we can clear media_details. """ self._should_poll = False - self.media_pause() - self._clear_media_details() + await self.async_media_pause() + await self._clear_media_details() - def turn_on(self): + async def async_turn_on(self): """Turn the client on. While Alexa's do not have on/off capability, we can use this as another trigger to do updates. """ self._should_poll = True - self.media_pause() + await self.async_media_pause() - def media_next_track(self): + async def async_media_next_track(self): """Send next track command.""" if not (self.state in [STATE_PLAYING, STATE_PAUSED] and self.available): return - self.alexa_api.next() + await self.alexa_api.next() if not (self.hass.data[DATA_ALEXAMEDIA] ['accounts'][self._login.email]['websocket']): - self.update() + await self.async_update() - def media_previous_track(self): + async def async_media_previous_track(self): """Send previous track command.""" if not (self.state in [STATE_PLAYING, STATE_PAUSED] and self.available): return - self.alexa_api.previous() + await self.alexa_api.previous() if not (self.hass.data[DATA_ALEXAMEDIA] ['accounts'][self._login.email]['websocket']): - self.update() + await self.async_update() - def send_tts(self, message): + async def async_send_tts(self, message): """Send TTS to Device. NOTE: Does not work on WHA Groups. """ - self.alexa_api.send_tts(message, customer_id=self._customer_id) + await self.alexa_api.send_tts(message, customer_id=self._customer_id) - def send_announcement(self, message, **kwargs): + async def async_send_announcement(self, message, **kwargs): """Send announcement to the media player.""" - self.alexa_api.send_announcement(message, - customer_id=self._customer_id, - **kwargs) + await self.alexa_api.send_announcement(message, + customer_id=self._customer_id, + **kwargs) - def send_mobilepush(self, message, **kwargs): + async def async_send_mobilepush(self, message, **kwargs): """Send push to the media player's associated mobile devices.""" - self.alexa_api.send_mobilepush(message, - customer_id=self._customer_id, - **kwargs) + await self.alexa_api.send_mobilepush(message, + customer_id=self._customer_id, + **kwargs) - def play_media(self, media_type, media_id, enqueue=None, **kwargs): + async def async_play_media(self, + media_type, media_id, enqueue=None, **kwargs): """Send the play_media command to the media player.""" if media_type == "music": - self.alexa_api.send_tts("Sorry, text to speech can only be called " - " with the media player alexa tts service") + await self.alexa_api.send_tts( + "Sorry, text to speech can only be called " + " with the media player alexa tts service") elif media_type == "sequence": - self.alexa_api.send_sequence(media_id, - customer_id=self._customer_id, - **kwargs) + await self.alexa_api.send_sequence(media_id, + customer_id=self._customer_id, + **kwargs) elif media_type == "routine": - self.alexa_api.run_routine(media_id) + await self.alexa_api.run_routine(media_id) else: - self.alexa_api.play_music(media_type, media_id, - customer_id=self._customer_id, **kwargs) + await self.alexa_api.play_music( + media_type, media_id, + customer_id=self._customer_id, **kwargs) if not (self.hass.data[DATA_ALEXAMEDIA] ['accounts'][self._login.email]['websocket']): - self.update() + await self.async_update() @property def device_state_attributes(self): diff --git a/custom_components/alexa_media/notify.py b/custom_components/alexa_media/notify.py index aa0217df..ac87a5e4 100644 --- a/custom_components/alexa_media/notify.py +++ b/custom_components/alexa_media/notify.py @@ -26,7 +26,7 @@ EVENT_NOTIFY = "notify" -def get_service(hass, config, discovery_info=None): +async def async_get_service(hass, config, discovery_info=None): # pylint: disable=unused-argument """Get the demo notification service.""" return AlexaNotificationService(hass) @@ -39,7 +39,7 @@ def __init__(self, hass): """Initialize the service.""" self.hass = hass - def convert(self, names, type_="entities", filter_matches=False): + async 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 @@ -116,7 +116,7 @@ def devices(self): ['entities']['media_player'].values()) return devices - def send_message(self, message="", **kwargs): + async def send_message(self, message="", **kwargs): """Send a message to a Alexa device.""" _LOGGER.debug("Message: %s, kwargs: %s", message, @@ -128,22 +128,22 @@ def send_message(self, message="", **kwargs): data = kwargs.get(ATTR_DATA) if isinstance(targets, str): targets = [targets] - entities = self.convert(targets, type_="entities") + entities = await self.convert(targets, type_="entities") try: entities.extend(self.hass.components.group.expand_entity_ids( entities)) except ValueError: _LOGGER.debug("Invalid Home Assistant entity in %s", entities) if data['type'] == "tts": - targets = self.convert(entities, type_="entities", - filter_matches=True) + targets = await self.convert(entities, type_="entities", + filter_matches=True) _LOGGER.debug("TTS entities: %s", targets) for alexa in targets: _LOGGER.debug("TTS by %s : %s", alexa, message) - alexa.send_tts(message) + await alexa.async_send_tts(message) elif data['type'] == "announce": - targets = self.convert(entities, type_="serialnumbers", - filter_matches=True) + targets = await self.convert(entities, type_="serialnumbers", + filter_matches=True) _LOGGER.debug("Announce targets: %s entities: %s", list(map(hide_serial, targets)), entities) @@ -158,16 +158,17 @@ def send_message(self, message="", **kwargs): alexa, list(map(hide_serial, targets)), message) - alexa.send_announcement(message, - targets=targets, - title=title, - method=(data['method'] if - 'method' in data - else 'all')) + await alexa.async_send_announcement( + message, + targets=targets, + title=title, + method=(data['method'] if + 'method' in data + else 'all')) break elif data['type'] == "push": - targets = self.convert(entities, type_="entities", - filter_matches=True) + targets = await self.convert(entities, type_="entities", + filter_matches=True) for alexa in targets: _LOGGER.debug("Push by %s : %s %s", alexa, title, message) - alexa.send_mobilepush(message, title=title) + await alexa.async_send_mobilepush(message, title=title) diff --git a/custom_components/alexa_media/switch.py b/custom_components/alexa_media/switch.py index d25656cd..4f531531 100644 --- a/custom_components/alexa_media/switch.py +++ b/custom_components/alexa_media/switch.py @@ -13,7 +13,7 @@ from homeassistant import util from homeassistant.components.switch import SwitchDevice from homeassistant.exceptions import NoEntitySpecifiedError -from homeassistant.helpers.event import call_later +from homeassistant.helpers.event import async_call_later from . import DATA_ALEXAMEDIA from . import DOMAIN as ALEXA_DOMAIN @@ -25,8 +25,8 @@ _LOGGER = logging.getLogger(__name__) -def setup_platform(hass, config, add_devices_callback, - discovery_info=None): +async def async_setup_platform(hass, config, add_devices_callback, + discovery_info=None): """Set up the Alexa switch platform.""" _LOGGER.debug("Loading switches") devices = [] # type: List[DNDSwitch] @@ -46,11 +46,12 @@ def setup_platform(hass, config, add_devices_callback, ['switch']) = {} if key not in account_dict['entities']['media_player']: _LOGGER.debug("Media Players not loaded yet; delaying load") - call_later(hass, 5, lambda _: - setup_platform(hass, - config, - add_devices_callback, - discovery_info)) + async_call_later(hass, 5, lambda _: + hass.async_create_task( + async_setup_platform(hass, + config, + add_devices_callback, + discovery_info))) return True elif key not in account_dict['entities']['switch']: (hass.data[DATA_ALEXAMEDIA] @@ -62,7 +63,6 @@ def setup_platform(hass, config, add_devices_callback, alexa_client = class_(account_dict['entities'] ['media_player'] [key], - hass, account) # type: AlexaMediaSwitch (hass.data[DATA_ALEXAMEDIA] ['accounts'] @@ -84,7 +84,6 @@ class AlexaMediaSwitch(SwitchDevice): """Representation of a Alexa Media switch.""" def __init__(self, - hass, client, switch_property, switch_function, @@ -99,10 +98,15 @@ def __init__(self, self._state = False self._switch_function = switch_function _LOGGER.debug("Creating %s switch for %s", name, client) + + async def async_added_to_hass(self): + """Store register state change callback.""" # Register event handler on bus - hass.bus.listen(('{}_{}'.format(ALEXA_DOMAIN, - client.account))[0:32], - self._handle_event) + self.hass.bus.async_listen( + ('{}_{}'.format( + ALEXA_DOMAIN, + hide_email(self._account)))[0:32], + self._handle_event) def _handle_event(self, event): """Handle events. @@ -115,10 +119,10 @@ def _handle_event(self, event): if (queue_state['dopplerId'] ['deviceSerialNumber'] == self._client.unique_id): self._state = getattr(self._client, self._switch_property) - self.schedule_update_ha_state() + self.async_schedule_update_ha_state() - def _set_switch(self, state, **kwargs): - success = self._switch_function(state) + async def _set_switch(self, state, **kwargs): + success = await self._switch_function(state) # if function returns success, make immediate state change if success: setattr(self._client, self._switch_property, state) @@ -126,20 +130,27 @@ def _set_switch(self, state, **kwargs): getattr(self._client, self._switch_property), state) - self.schedule_update_ha_state() + self.async_schedule_update_ha_state() + elif self.should_poll: + # if we need to poll, refresh media_client + _LOGGER.debug("Requesting update of %s due to %s switch to %s", + self._client, + self._name, + state) + await self._client.async_update() @property def is_on(self): """Return true if on.""" return getattr(self._client, self._switch_property) - def turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Turn on switch.""" - self._set_switch(True, **kwargs) + await self._set_switch(True, **kwargs) - def turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs): """Turn off switch.""" - self._set_switch(False, **kwargs) + await self._set_switch(False, **kwargs) @property def unique_id(self): @@ -157,10 +168,10 @@ def should_poll(self): return not (self.hass.data[DATA_ALEXAMEDIA] ['accounts'][self._account]['websocket']) - def update(self): + async def async_update(self): """Update state.""" try: - self.schedule_update_ha_state() + self.async_schedule_update_ha_state() except NoEntitySpecifiedError: pass # we ignore this due to a harmless startup race condition @@ -168,40 +179,40 @@ def update(self): class DNDSwitch(AlexaMediaSwitch): """Representation of a Alexa Media Do Not Disturb switch.""" - def __init__(self, client, hass, account): + def __init__(self, client, account): """Initialize the Alexa Switch.""" # Class info - super().__init__(hass, - client, - 'dnd_state', - client.alexa_api.set_dnd_state, - account, - "do not disturb") + super().__init__( + client, + 'dnd_state', + client.alexa_api.set_dnd_state, + account, + "do not disturb") class ShuffleSwitch(AlexaMediaSwitch): """Representation of a Alexa Media Shuffle switch.""" - def __init__(self, client, hass, account): + def __init__(self, client, account): """Initialize the Alexa Switch.""" # Class info - super().__init__(hass, - client, - 'shuffle_state', - client.alexa_api.shuffle, - account, - "shuffle") + super().__init__( + client, + 'shuffle_state', + client.alexa_api.shuffle, + account, + "shuffle") class RepeatSwitch(AlexaMediaSwitch): """Representation of a Alexa Media Repeat switch.""" - def __init__(self, client, hass, account): + def __init__(self, client, account): """Initialize the Alexa Switch.""" # Class info - super().__init__(hass, - client, - 'repeat_state', - client.alexa_api.repeat, - account, - "repeat") + super().__init__( + client, + 'repeat_state', + client.alexa_api.repeat, + account, + "repeat") From 0d2b3174439d4d287d0606b87e0974f58fd0f0ca Mon Sep 17 00:00:00 2001 From: "Alan D. Tse" Date: Sun, 18 Aug 2019 22:55:43 -0700 Subject: [PATCH 06/11] fix: complete migration to async --- custom_components/alexa_media/__init__.py | 191 ++++++++++++---------- 1 file changed, 106 insertions(+), 85 deletions(-) diff --git a/custom_components/alexa_media/__init__.py b/custom_components/alexa_media/__init__.py index 97a88161..519a395f 100644 --- a/custom_components/alexa_media/__init__.py +++ b/custom_components/alexa_media/__init__.py @@ -11,12 +11,16 @@ import voluptuous as vol +from typing import Optional, Text from homeassistant import util from homeassistant.const import ( - CONF_EMAIL, CONF_NAME, CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_URL) + CONF_EMAIL, CONF_NAME, CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_URL, + EVENT_HOMEASSISTANT_STOP) from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import async_call_later from homeassistant.helpers.discovery import async_load_platform +from alexapy import WebsocketEchoClient + from .const import ( ALEXA_COMPONENTS, CONF_DEBUG, CONF_ACCOUNTS, CONF_INCLUDE_DEVICES, CONF_EXCLUDE_DEVICES, DATA_ALEXAMEDIA, DOMAIN, MIN_TIME_BETWEEN_SCANS, @@ -59,7 +63,7 @@ def hide_email(email): """Obfuscate email.""" part = email.split('@') return "{}{}{}@{}".format(part[0][0], - "*"*(len(part[0])-2), + "*" * (len(part[0]) - 2), part[0][-1], part[1]) @@ -74,7 +78,7 @@ def hide_serial(item): response['serialNumber'] = hide_serial(serial) elif isinstance(item, str): response = "{}{}{}".format(item[0], - "*"*(len(item)-4), + "*" * (len(item) - 4), item[-3:]) return response @@ -98,9 +102,10 @@ async def async_setup(hass, config, discovery_info=None): hass.data[DATA_ALEXAMEDIA]['accounts'][email] = {"config": []} login = AlexaLogin(url, email, password, hass.config.path, account.get(CONF_DEBUG)) + (hass.data[DATA_ALEXAMEDIA]['accounts'][email]['login_obj']) = login await login.login_with_cookie() - test_login_status(hass, account, login, - setup_platform_callback) + await test_login_status(hass, account, login, + setup_platform_callback) return True @@ -121,18 +126,18 @@ async def setup_platform_callback(hass, config, login, callback_data): callback_data.get('authselectoption'), callback_data.get('verificationcode')) await login.login(data=callback_data) - test_login_status(hass, config, login, - setup_platform_callback) + await test_login_status(hass, config, login, + setup_platform_callback) -def request_configuration(hass, config, login, setup_platform_callback): +async def request_configuration(hass, config, login, setup_platform_callback): """Request configuration steps from the user using the configurator.""" - configurator = hass.components.configurator - - def configuration_callback(callback_data): + async def configuration_callback(callback_data): """Handle the submitted configuration.""" - hass.add_job(setup_platform_callback, hass, config, - login, callback_data) + await hass.async_add_job(setup_platform_callback, hass, config, + login, callback_data) + + configurator = hass.components.configurator status = login.status email = login.email # links = "" @@ -146,21 +151,21 @@ def configuration_callback(callback_data): # Get Captcha if (status and 'captcha_image_url' in status and status['captcha_image_url'] is not None): - config_id = configurator.request_config( + 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.request_config( + config_id = configurator.async_request_config( "Alexa Media Player - 2FA - {}".format(email), configuration_callback, description=('Please enter your Two-Factor Security code.' @@ -173,7 +178,7 @@ def configuration_callback(callback_data): status['claimspicker_required']): # Get picker method options = status['claimspicker_message'] if options: - config_id = configurator.request_config( + config_id = configurator.async_request_config( "Alexa Media Player - Verification Method - {}".format(email), configuration_callback, description=('Please select the verification method. ' @@ -184,12 +189,12 @@ def configuration_callback(callback_data): fields=[{'id': 'claimsoption', 'name': 'Option'}] ) else: - configuration_callback({}) + 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.request_config( + config_id = configurator.async_request_config( "Alexa Media Player - OTP Method - {}".format(email), configuration_callback, description=('Please select the OTP method. ' @@ -200,7 +205,7 @@ def configuration_callback(callback_data): fields=[{'id': 'authselectoption', 'name': 'Option'}] ) else: - configuration_callback({}) + await configuration_callback({}) elif (status and 'verificationcode_required' in status and status['verificationcode_required']): # Get picker method config_id = configurator.request_config( @@ -213,7 +218,7 @@ def configuration_callback(callback_data): fields=[{'id': 'verificationcode', 'name': 'Verification Code'}] ) else: # Check login - config_id = configurator.request_config( + config_id = configurator.async_request_config( "Alexa Media Player - Begin - {}".format(email), configuration_callback, description=('Please hit confirm to begin login attempt.'), @@ -222,21 +227,22 @@ def configuration_callback(callback_data): ) hass.data[DATA_ALEXAMEDIA]['accounts'][email]['config'].append(config_id) if 'error_message' in status and status['error_message']: - configurator.notify_errors( # use sync to delay next pop + configurator.async_notify_errors( config_id, status['error_message']) if len(hass.data[DATA_ALEXAMEDIA]['accounts'][email]['config']) > 1: - configurator.request_done((hass.data[DATA_ALEXAMEDIA] - ['accounts'][email]['config']).pop(0)) + configurator.async_request_done( + (hass.data[DATA_ALEXAMEDIA]['accounts'][email]['config']).pop(0)) -def test_login_status(hass, config, login, - setup_platform_callback): +async def test_login_status(hass, config, 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") - hass.async_add_job(setup_alexa, hass, config, - login) + await hass.async_add_job(setup_alexa, hass, config, + login) return if ('captcha_required' in login.status and login.status['captcha_required']): @@ -256,8 +262,8 @@ def test_login_status(hass, config, login, elif ('login_failed' in login.status and login.status['login_failed']): _LOGGER.debug("Creating configurator to start new login attempt") - hass.async_add_job(request_configuration, hass, config, login, - setup_platform_callback) + await hass.async_add_job(request_configuration, hass, config, login, + setup_platform_callback) async def setup_alexa(hass, config, login_obj): @@ -287,7 +293,8 @@ async def update_devices(): [email] ['entities'] ['media_player'].values()) - if (hass.data[DATA_ALEXAMEDIA]['accounts'][email]['websocket'] + if ('websocket' in hass.data[DATA_ALEXAMEDIA]['accounts'][email] + and hass.data[DATA_ALEXAMEDIA]['accounts'][email]['websocket'] and not (hass.data[DATA_ALEXAMEDIA] ['accounts'][email]['new_devices'])): return @@ -305,7 +312,8 @@ async def update_devices(): ['accounts'][email]['config'])): _LOGGER.debug("Alexa API disconnected; attempting to relogin") await login_obj.login() - test_login_status(hass, config, login_obj, setup_platform_callback) + await test_login_status(hass, + config, login_obj, setup_platform_callback) return new_alexa_clients = [] # list of newly discovered device names @@ -380,11 +388,12 @@ async def update_devices(): if new_alexa_clients: for component in ALEXA_COMPONENTS: - hass.async_create_task(async_load_platform(hass, - component, - DOMAIN, - {CONF_NAME: DOMAIN}, - config)) + hass.async_create_task( + async_load_platform(hass, + component, + DOMAIN, + {CONF_NAME: DOMAIN}, + config)) # Process last_called data to fire events await update_last_called(login_obj) @@ -416,8 +425,10 @@ async def update_last_called(login_obj, last_called=None): hide_serial(stored_data['last_called'] if 'last_called' in stored_data else None), hide_serial(last_called)) - hass.bus.fire(('{}_{}'.format(DOMAIN, hide_email(email)))[0:32], - {'last_called_change': last_called}) + hass.bus.async_fire( + ('{}_{}'.format(DOMAIN, + hide_email(email)))[0:32], + {'last_called_change': last_called}) (hass.data[DATA_ALEXAMEDIA] ['accounts'] [email] @@ -455,13 +466,12 @@ async def last_call_handler(call): login_obj = account_dict['login_obj'] await update_last_called(login_obj) - def ws_connect(): + async def ws_connect() -> WebsocketEchoClient: """Open WebSocket connection. This will only attempt one login before failing. """ - from alexapy import WebsocketEchoClient - websocket = None + websocket: Optional[WebsocketEchoClient] = None try: websocket = WebsocketEchoClient(login_obj, ws_handler, @@ -470,14 +480,14 @@ def ws_connect(): ws_error_handler) _LOGGER.debug("%s: Websocket created: %s", hide_email(email), websocket) - hass.async_create_task(websocket.async_run()) + await websocket.async_run() except BaseException as exception_: _LOGGER.debug("%s: Websocket creation failed: %s", hide_email(email), exception_) return websocket - def ws_handler(message_obj): + async def ws_handler(message_obj): """Handle websocket messages. This allows push notifications from Alexa to update last_called @@ -495,7 +505,11 @@ def ws_handler(message_obj): ['accounts'] [email] ['entities'] - ['media_player'].keys()) + ['media_player'].keys() if 'entities' in ( + hass.data[DATA_ALEXAMEDIA] + ['accounts'] + [email]) + else []) if command and json_payload: _LOGGER.debug("%s: Received websocket command: %s : %s", hide_email(email), @@ -514,56 +528,63 @@ def ws_handler(message_obj): 'timestamp': json_payload['timestamp'] } if (serial and serial in existing_serials): - hass.async_create_task(update_last_called( - login_obj, last_called)) - hass.bus.fire(('{}_{}'.format(DOMAIN, - hide_email(email)))[0:32], - {'push_activity': json_payload}) + await update_last_called(login_obj, + last_called) + hass.bus.async_fire( + ('{}_{}'.format(DOMAIN, + hide_email(email)))[0:32], + {'push_activity': json_payload}) elif command == 'PUSH_AUDIO_PLAYER_STATE': # Player update serial = (json_payload['dopplerId']['deviceSerialNumber']) if (serial and serial in existing_serials): _LOGGER.debug("Updating media_player: %s", json_payload) - hass.bus.fire(('{}_{}'.format(DOMAIN, - hide_email(email)))[0:32], - {'player_state': json_payload}) + hass.bus.async_fire( + ('{}_{}'.format(DOMAIN, + hide_email(email)))[0:32], + {'player_state': json_payload}) elif command == 'PUSH_VOLUME_CHANGE': # Player volume update serial = (json_payload['dopplerId']['deviceSerialNumber']) if (serial and serial in existing_serials): _LOGGER.debug("Updating media_player volume: %s", json_payload) - hass.bus.fire(('{}_{}'.format(DOMAIN, - hide_email(email)))[0:32], - {'player_state': json_payload}) + hass.bus.async_fire( + ('{}_{}'.format(DOMAIN, + hide_email(email)))[0:32], + {'player_state': json_payload}) elif command == 'PUSH_DOPPLER_CONNECTION_CHANGE': # Player availability update serial = (json_payload['dopplerId']['deviceSerialNumber']) if (serial and serial in existing_serials): _LOGGER.debug("Updating media_player availability %s", json_payload) - hass.bus.fire(('{}_{}'.format(DOMAIN, - hide_email(email)))[0:32], - {'player_state': json_payload}) + hass.bus.async_fire( + ('{}_{}'.format(DOMAIN, + hide_email(email)))[0:32], + {'player_state': json_payload}) elif command == 'PUSH_BLUETOOTH_STATE_CHANGE': # Player bluetooth update serial = (json_payload['dopplerId']['deviceSerialNumber']) if (serial and serial in existing_serials): _LOGGER.debug("Updating media_player bluetooth %s", json_payload) - bluetooth_state = update_bluetooth_state(login_obj, serial) - hass.bus.fire(('{}_{}'.format(DOMAIN, - hide_email(email)))[0:32], - {'bluetooth_change': bluetooth_state}) + bluetooth_state = await update_bluetooth_state(login_obj, + serial) + hass.bus.async_fire( + ('{}_{}'.format(DOMAIN, + hide_email(email)))[0:32], + {'bluetooth_change': bluetooth_state}) elif command == 'PUSH_MEDIA_QUEUE_CHANGE': # Player availability update serial = (json_payload['dopplerId']['deviceSerialNumber']) if (serial and serial in existing_serials): _LOGGER.debug("Updating media_player queue %s", json_payload) - hass.bus.fire(('{}_{}'.format(DOMAIN, - hide_email(email)))[0:32], - {'queue_state': json_payload}) + hass.bus.async_fire( + ('{}_{}'.format(DOMAIN, + hide_email(email)))[0:32], + {'queue_state': json_payload}) if (serial and serial not in existing_serials and serial not in (hass.data[DATA_ALEXAMEDIA] ['accounts'] @@ -572,44 +593,44 @@ def ws_handler(message_obj): _LOGGER.debug("Discovered new media_player %s", serial) (hass.data[DATA_ALEXAMEDIA] ['accounts'][email]['new_devices']) = True - hass.async_create_task(update_devices(no_throttle=True)) + await update_devices(no_throttle=True) - def ws_open_handler(): + async def ws_open_handler(): """Handle websocket open.""" - email = login_obj.email + email: Text = login_obj.email _LOGGER.debug("%s: Websocket succesfully connected", hide_email(email)) (hass.data[DATA_ALEXAMEDIA] ['accounts'][email]['websocketerror']) = 0 # set errors to 0 - def ws_close_handler(): + async def ws_close_handler(): """Handle websocket close. This should attempt to reconnect up to 5 times """ - from time import sleep - email = login_obj.email - errors = (hass.data - [DATA_ALEXAMEDIA]['accounts'][email]['websocketerror']) - delay = 5*2**errors + from asyncio import sleep + email: Text = login_obj.email + errors: int = (hass.data + [DATA_ALEXAMEDIA]['accounts'][email]['websocketerror']) + delay: int = 5 * 2 ** errors if (errors < 5): _LOGGER.debug("%s: Websocket closed; reconnect #%i in %is", hide_email(email), errors, delay) - sleep(delay) + await sleep(delay) if (not (hass.data [DATA_ALEXAMEDIA]['accounts'][email]['websocket'])): - (hass.data[DATA_ALEXAMEDIA]['accounts'] - [email]['websocket']) = ws_connect() + (hass.data[DATA_ALEXAMEDIA]['accounts'] + [email]['websocket']) = await ws_connect() else: _LOGGER.debug("%s: Websocket closed; retries exceeded; polling", hide_email(email)) (hass.data[DATA_ALEXAMEDIA]['accounts'] [email]['websocket']) = None - hass.async_create_task(update_devices()) + await update_devices() - def ws_error_handler(message): + async def ws_error_handler(message): """Handle websocket error. This currently logs the error. In the future, this should invalidate @@ -628,10 +649,8 @@ def ws_error_handler(message): ['accounts'][email]['websocketerror']) = errors + 1 include = config.get(CONF_INCLUDE_DEVICES) exclude = config.get(CONF_EXCLUDE_DEVICES) - scan_interval = config.get(CONF_SCAN_INTERVAL) - email = login_obj.email - (hass.data[DATA_ALEXAMEDIA]['accounts'][email]['websocket']) = ws_connect() - (hass.data[DATA_ALEXAMEDIA]['accounts'][email]['login_obj']) = login_obj + scan_interval: int = config.get(CONF_SCAN_INTERVAL) + email: Text = login_obj.email if 'devices' not in hass.data[DATA_ALEXAMEDIA]['accounts'][email]: (hass.data[DATA_ALEXAMEDIA]['accounts'][email] ['devices']) = {'media_player': {}} @@ -643,7 +662,9 @@ def ws_error_handler(message): ['entities']) = {'media_player': {}} (hass.data[DATA_ALEXAMEDIA] ['accounts'][email]['new_devices']) = True # force initial update - hass.async_create_task(update_devices()) + (hass.data[DATA_ALEXAMEDIA]['accounts'][email]['websocket']) = \ + await ws_connect() + await update_devices() hass.services.async_register(DOMAIN, SERVICE_UPDATE_LAST_CALLED, last_call_handler, schema=LAST_CALL_UPDATE_SCHEMA) From a0fcf322d54d62f8056c75c1d5d18dad514d428f Mon Sep 17 00:00:00 2001 From: "Alan D. Tse" Date: Sun, 18 Aug 2019 22:56:33 -0700 Subject: [PATCH 07/11] fix: properly close aiohttp session on HA shutdown --- custom_components/alexa_media/__init__.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/custom_components/alexa_media/__init__.py b/custom_components/alexa_media/__init__.py index 519a395f..90e601cb 100644 --- a/custom_components/alexa_media/__init__.py +++ b/custom_components/alexa_media/__init__.py @@ -85,12 +85,26 @@ def hide_serial(item): async def async_setup(hass, config, discovery_info=None): """Set up the Alexa domain.""" + async def close_alexa_media(event) -> None: + """Clean up Alexa connections.""" + _LOGGER.debug("Received shutdown request: %s", event) + for email, account_dict in (hass.data + [DATA_ALEXAMEDIA]['accounts'].items()): + login_obj = account_dict['login_obj'] + if not login_obj._session.closed: + if login_obj._session._connector_owner: + await login_obj._session._connector.close() + login_obj._session._connector = None + _LOGGER.debug("%s: Connection closed: %s", + hide_email(email), + login_obj._session.closed) if DATA_ALEXAMEDIA not in hass.data: hass.data[DATA_ALEXAMEDIA] = {} hass.data[DATA_ALEXAMEDIA]['accounts'] = {} from alexapy import AlexaLogin, __version__ as alexapy_version _LOGGER.info(STARTUP) _LOGGER.info("Loaded alexapy==%s", alexapy_version) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, close_alexa_media) domainconfig = config.get(DOMAIN) for account in domainconfig[CONF_ACCOUNTS]: # if account[CONF_EMAIL] in configured_instances(hass): From af0ad6b0a40841a26bf02e9c86b706b7494af5fc Mon Sep 17 00:00:00 2001 From: "Alan D. Tse" Date: Wed, 21 Aug 2019 21:17:27 -0700 Subject: [PATCH 08/11] fix(update_devices): add key existence checks --- custom_components/alexa_media/__init__.py | 37 ++++++++++++----------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/custom_components/alexa_media/__init__.py b/custom_components/alexa_media/__init__.py index 90e601cb..66f51a7d 100644 --- a/custom_components/alexa_media/__init__.py +++ b/custom_components/alexa_media/__init__.py @@ -365,23 +365,26 @@ async def update_devices(): [device['serialNumber']]) = device continue - for b_state in bluetooth['bluetoothStates']: - if device['serialNumber'] == b_state['deviceSerialNumber']: - device['bluetooth_state'] = b_state - - for dev in preferences['devicePreferences']: - if dev['deviceSerialNumber'] == device['serialNumber']: - device['locale'] = dev['locale'] - _LOGGER.debug("Locale %s found for %s", - device['locale'], - hide_serial(device['serialNumber'])) - - for dev in dnd['doNotDisturbDeviceStatusList']: - if dev['deviceSerialNumber'] == device['serialNumber']: - device['dnd'] = dev['enabled'] - _LOGGER.debug("DND %s found for %s", - device['dnd'], - hide_serial(device['serialNumber'])) + if 'bluetoothStates' in bluetooth: + for b_state in bluetooth['bluetoothStates']: + if device['serialNumber'] == b_state['deviceSerialNumber']: + device['bluetooth_state'] = b_state + + if 'devicePreferences' in preferences: + for dev in preferences['devicePreferences']: + if dev['deviceSerialNumber'] == device['serialNumber']: + device['locale'] = dev['locale'] + _LOGGER.debug("Locale %s found for %s", + device['locale'], + hide_serial(device['serialNumber'])) + + if 'doNotDisturbDeviceStatusList' in dnd: + for dev in dnd['doNotDisturbDeviceStatusList']: + if dev['deviceSerialNumber'] == device['serialNumber']: + device['dnd'] = dev['enabled'] + _LOGGER.debug("DND %s found for %s", + device['dnd'], + hide_serial(device['serialNumber'])) (hass.data[DATA_ALEXAMEDIA] ['accounts'] From 7bdf2f9985db136f944f4bc4f3faf79ba50a96e1 Mon Sep 17 00:00:00 2001 From: "Alan D. Tse" Date: Wed, 21 Aug 2019 23:50:19 -0700 Subject: [PATCH 09/11] chore: bump alexapy to 1.0.0 --- 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 edd2de47..cd951a9e 100644 --- a/custom_components/alexa_media/manifest.json +++ b/custom_components/alexa_media/manifest.json @@ -4,5 +4,5 @@ "documentation": "https://github.com/keatontaylor/alexa_media_player/wiki", "dependencies": [], "codeowners": ["@keatontaylor", "@alandtse"], - "requirements": ["alexapy==0.7.1"] + "requirements": ["alexapy==1.0.0"] } From d62e0b5d93622ebed84feb9c7711a42f7a77c72e Mon Sep 17 00:00:00 2001 From: "Alan D. Tse" Date: Wed, 21 Aug 2019 23:50:38 -0700 Subject: [PATCH 10/11] chore: add semantic-release support --- setup.cfg | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 setup.cfg diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 00000000..94fdf0d8 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,3 @@ +[semantic_release] +version_variable=custom_components/alexa_media/const.py:__version__ +upload_to_pypi=false From 85220586e497c75cc10acd3f6ca0098abd10a7cd Mon Sep 17 00:00:00 2001 From: "Alan D. Tse" Date: Wed, 21 Aug 2019 23:50:55 -0700 Subject: [PATCH 11/11] chore: bump version to 2.0.0 --- 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 bc054b4d..fcdbd04c 100644 --- a/custom_components/alexa_media/const.py +++ b/custom_components/alexa_media/const.py @@ -9,7 +9,7 @@ """ from datetime import timedelta -__version__ = '1.4.1' +__version__ = '2.0.0' PROJECT_URL = "https://github.com/keatontaylor/alexa_media_player/" ISSUE_URL = "{}issues".format(PROJECT_URL)