From 6f9a92de6fda7fd5361e15ae719c11092eaf7228 Mon Sep 17 00:00:00 2001 From: Jens Leinenbach <1786119+jleinenbach@users.noreply.github.com> Date: Thu, 3 Oct 2024 04:10:31 +0200 Subject: [PATCH 1/3] fix: fix KeyError 'accounts' after uninstallation (#2574) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- custom_components/alexa_media/helpers.py | 45 ++++++++------ custom_components/alexa_media/media_player.py | 59 ++++++++++++------- 2 files changed, 63 insertions(+), 41 deletions(-) diff --git a/custom_components/alexa_media/helpers.py b/custom_components/alexa_media/helpers.py index e6da7fee7..699d7859e 100644 --- a/custom_components/alexa_media/helpers.py +++ b/custom_components/alexa_media/helpers.py @@ -225,30 +225,37 @@ def report_relogin_required(hass, login, email) -> bool: def _existing_serials(hass, login_obj) -> list: + """Retrieve existing serial numbers for a given login object.""" email: str = login_obj.email - existing_serials = ( - list( + if ( + DATA_ALEXAMEDIA in hass.data + and "accounts" in hass.data[DATA_ALEXAMEDIA] + and email in hass.data[DATA_ALEXAMEDIA]["accounts"] + ): + existing_serials = list( hass.data[DATA_ALEXAMEDIA]["accounts"][email]["entities"][ "media_player" ].keys() ) - if "entities" in (hass.data[DATA_ALEXAMEDIA]["accounts"][email]) - else [] - ) - for serial in existing_serials: - device = hass.data[DATA_ALEXAMEDIA]["accounts"][email]["devices"][ - "media_player" - ][serial] - if "appDeviceList" in device and device["appDeviceList"]: - apps = list( - map( - lambda x: x["serialNumber"] if "serialNumber" in x else None, - device["appDeviceList"], - ) - ) - # _LOGGER.debug("Combining %s with %s", - # existing_serials, apps) - existing_serials = existing_serials + apps + device_data = ( + hass.data[DATA_ALEXAMEDIA]["accounts"][email] + .get("devices", {}) + .get("media_player", {}) + ) + for serial in existing_serials: + device = device_data.get(serial, {}) + if "appDeviceList" in device and device["appDeviceList"]: + apps = [ + x["serialNumber"] + for x in device["appDeviceList"] + if "serialNumber" in x + ] + existing_serials.extend(apps) + else: + _LOGGER.warning( + "No accounts data found for %s. Skipping serials retrieval.", email + ) + existing_serials = [] return existing_serials diff --git a/custom_components/alexa_media/media_player.py b/custom_components/alexa_media/media_player.py index b5455f9bb..83d21324b 100644 --- a/custom_components/alexa_media/media_player.py +++ b/custom_components/alexa_media/media_player.py @@ -776,8 +776,12 @@ async def async_select_source(self, source): else: await self.alexa_api.set_bluetooth(devices["address"]) self._source = source + # Safely access 'http2' setting if not ( - self.hass.data[DATA_ALEXAMEDIA]["accounts"][self._login.email]["http2"] + self.hass.data.get(DATA_ALEXAMEDIA, {}) + .get("accounts", {}) + .get(self._login.email, {}) + .get("http2") ): await self.async_update() @@ -922,35 +926,47 @@ async def async_update(self): except AttributeError: pass email = self._login.email + + # Check if DATA_ALEXAMEDIA and 'accounts' exist + accounts_data = self.hass.data.get(DATA_ALEXAMEDIA, {}).get("accounts", {}) if ( self.entity_id is None # Device has not initialized yet - or email not in self.hass.data[DATA_ALEXAMEDIA]["accounts"] + or email not in accounts_data or self._login.session.closed ): self._assumed_state = True self.available = False return - device = self.hass.data[DATA_ALEXAMEDIA]["accounts"][email]["devices"][ - "media_player" - ][self.device_serial_number] + + # Safely access the device + device = accounts_data[email]["devices"]["media_player"].get( + self.device_serial_number + ) + if not device: + _LOGGER.warning( + "Device serial number %s not found for account %s. Skipping update.", + self.device_serial_number, + hide_email(email), + ) + self.available = False + return + + # Safely access websocket_commands seen_commands = ( - self.hass.data[DATA_ALEXAMEDIA]["accounts"][email][ - "websocket_commands" - ].keys() - if "websocket_commands" - in (self.hass.data[DATA_ALEXAMEDIA]["accounts"][email]) + accounts_data[email]["websocket_commands"].keys() + if "websocket_commands" in accounts_data[email] else None ) - await self.refresh( # pylint: disable=unexpected-keyword-arg - device, no_throttle=True - ) - push_enabled = ( - self.hass.data[DATA_ALEXAMEDIA]["accounts"].get(email, {}).get("http2") - ) + + await self.refresh(device, no_throttle=True) + + # Safely access 'http2' setting + push_enabled = accounts_data[email].get("http2") + if ( self.state in [MediaPlayerState.PLAYING] and - # only enable polling if websocket not connected + # Only enable polling if websocket not connected ( not push_enabled or not seen_commands @@ -970,7 +986,7 @@ async def async_update(self): ): _LOGGER.debug( "%s: %s playing; scheduling update in %s seconds", - hide_email(self._login.email), + hide_email(email), self.name, PLAY_SCAN_INTERVAL, ) @@ -983,9 +999,8 @@ async def async_update(self): self._should_poll = False if not push_enabled: _LOGGER.debug( - "%s: Disabling polling and scheduling last update in" - " 300 seconds for %s", - hide_email(self._login.email), + "%s: Disabling polling and scheduling last update in 300 seconds for %s", + hide_email(email), self.name, ) async_call_later( @@ -996,7 +1011,7 @@ async def async_update(self): else: _LOGGER.debug( "%s: Disabling polling for %s", - hide_email(self._login.email), + hide_email(email), self.name, ) self._last_update = util.utcnow() From 77e2fce7a1ca5b524a350078e4c4412cf39fee24 Mon Sep 17 00:00:00 2001 From: Jens Leinenbach <1786119+jleinenbach@users.noreply.github.com> Date: Thu, 3 Oct 2024 04:10:49 +0200 Subject: [PATCH 2/3] fix: deprecate `async_load_platform` (#2576) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- custom_components/alexa_media/__init__.py | 34 +++++++---------------- 1 file changed, 10 insertions(+), 24 deletions(-) diff --git a/custom_components/alexa_media/__init__.py b/custom_components/alexa_media/__init__.py index d561e3c5b..eca07b686 100644 --- a/custom_components/alexa_media/__init__.py +++ b/custom_components/alexa_media/__init__.py @@ -644,31 +644,17 @@ async def async_update_data() -> Optional[AlexaEntityData]: cleaned_config = config.copy() cleaned_config.pop(CONF_PASSWORD, None) # CONF_PASSWORD contains sensitive info which is no longer needed - for component in ALEXA_COMPONENTS: - entry_setup = len( - hass.data[DATA_ALEXAMEDIA]["accounts"][email]["entities"][component] + # Load multiple platforms in parallel using async_forward_entry_setups + _LOGGER.debug("Loading platforms: %s", ", ".join(ALEXA_COMPONENTS)) + try: + await hass.config_entries.async_forward_entry_setups( + config_entry, ALEXA_COMPONENTS ) - if not entry_setup: - _LOGGER.debug("Loading config entry for %s", component) - try: - await hass.config_entries.async_forward_entry_setups( - config_entry, [component] - ) - except (asyncio.TimeoutError, TimeoutException) as ex: - raise ConfigEntryNotReady( - f"Timeout while loading config entry for {component}" - ) from ex - else: - _LOGGER.debug("Loading %s", component) - hass.async_create_task( - async_load_platform( - hass, - component, - DOMAIN, - {CONF_NAME: DOMAIN, "config": cleaned_config}, - cleaned_config, - ) - ) + except (asyncio.TimeoutError, TimeoutException) as ex: + _LOGGER.error(f"Error while loading platforms: {ex}") + raise ConfigEntryNotReady( + f"Timeout while loading platforms: {ex}" + ) from ex hass.data[DATA_ALEXAMEDIA]["accounts"][email]["new_devices"] = False # prune stale devices From 0cfe14523d3a023222c5a61203efa40f40b89c07 Mon Sep 17 00:00:00 2001 From: Jens Leinenbach <1786119+jleinenbach@users.noreply.github.com> Date: Thu, 3 Oct 2024 04:11:27 +0200 Subject: [PATCH 3/3] fix: remove @util.Throttle use (#2573) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- custom_components/alexa_media/__init__.py | 70 +++++++++++++++++++++-- 1 file changed, 65 insertions(+), 5 deletions(-) diff --git a/custom_components/alexa_media/__init__.py b/custom_components/alexa_media/__init__.py index eca07b686..2a6f10c8f 100644 --- a/custom_components/alexa_media/__init__.py +++ b/custom_components/alexa_media/__init__.py @@ -371,6 +371,11 @@ async def setup_alexa(hass, config_entry, login_obj: AlexaLogin): # pylint: disable=too-many-statements,too-many-locals """Set up a alexa api based on host parameter.""" + # Initialize throttling state and lock + last_dnd_update_times: dict[str, datetime] = {} + pending_dnd_updates: dict[str, bool] = {} + dnd_update_lock = asyncio.Lock() + async def async_update_data() -> Optional[AlexaEntityData]: # noqa pylint: disable=too-many-branches """Fetch data from API endpoint. @@ -829,12 +834,67 @@ async def update_bluetooth_state(login_obj, device_serial): ) return None - @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS) + async def schedule_update_dnd_state(email: str): + """Schedule an update_dnd_state call after MIN_TIME_BETWEEN_FORCED_SCANS.""" + await asyncio.sleep(MIN_TIME_BETWEEN_FORCED_SCANS) + async with dnd_update_lock: + if pending_dnd_updates.get(email, False): + pending_dnd_updates[email] = False + _LOGGER.debug( + "Executing scheduled forced DND update for %s", hide_email(email) + ) + # Assume login_obj can be retrieved or passed appropriately + login_obj = hass.data[DATA_ALEXAMEDIA]["accounts"][email]["login_obj"] + await update_dnd_state(login_obj) + @_catch_login_errors async def update_dnd_state(login_obj) -> None: - """Update the dnd state on ws dnd combo event.""" - dnd = await AlexaAPI.get_dnd_state(login_obj) + """Update the DND state on websocket DND combo event.""" + email = login_obj.email + now = datetime.utcnow() + + async with dnd_update_lock: + last_run = last_dnd_update_times.get(email) + cooldown = timedelta(seconds=MIN_TIME_BETWEEN_SCANS) + + if last_run and (now - last_run) < cooldown: + # If within cooldown, mark a pending update if not already marked + if not pending_dnd_updates.get(email, False): + pending_dnd_updates[email] = True + _LOGGER.debug( + "Throttling active for %s, scheduling a forced DND update.", + hide_email(email), + ) + asyncio.create_task(schedule_update_dnd_state(email)) + else: + _LOGGER.debug( + "Throttling active for %s, forced DND update already scheduled.", + hide_email(email), + ) + return + + # Update the last run time + last_dnd_update_times[email] = now + + _LOGGER.debug("Updating DND state for %s", hide_email(email)) + + try: + # Fetch the DND state using the Alexa API + dnd = await AlexaAPI.get_dnd_state(login_obj) + except asyncio.TimeoutError: + _LOGGER.error( + "Timeout occurred while fetching DND state for %s", hide_email(email) + ) + return + except Exception as e: + _LOGGER.error( + "Unexpected error while fetching DND state for %s: %s", + hide_email(email), + e, + ) + return + # Check if DND data is valid and dispatch an update event if dnd is not None and "doNotDisturbDeviceStatusList" in dnd: async_dispatcher_send( hass, @@ -842,8 +902,8 @@ async def update_dnd_state(login_obj) -> None: {"dnd_update": dnd["doNotDisturbDeviceStatusList"]}, ) return - _LOGGER.debug("%s: get_dnd_state failed: dnd:%s", hide_email(email), dnd) - return + else: + _LOGGER.debug("%s: get_dnd_state failed: dnd:%s", hide_email(email), dnd) async def http2_connect() -> HTTP2EchoClient: """Open HTTP2 Push connection.