From ceac5f8d5a2c26af4e982d759d4248c783a86bb6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 6 Aug 2023 08:31:45 -1000 Subject: [PATCH] Proactively refresh the enphase envoy token to handle cloud service downtime (#97880) --- .../components/enphase_envoy/coordinator.py | 113 ++++++++++++++---- .../components/enphase_envoy/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 91 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/enphase_envoy/coordinator.py b/homeassistant/components/enphase_envoy/coordinator.py index 85a7dc4c2f8..f3ad1705080 100644 --- a/homeassistant/components/enphase_envoy/coordinator.py +++ b/homeassistant/components/enphase_envoy/coordinator.py @@ -1,6 +1,8 @@ """The enphase_envoy component.""" from __future__ import annotations +import contextlib +import datetime from datetime import timedelta import logging from typing import Any @@ -13,13 +15,19 @@ from pyenphase import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import HomeAssistant +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +import homeassistant.util.dt as dt_util from .const import CONF_TOKEN, INVALID_AUTH_ERRORS SCAN_INTERVAL = timedelta(seconds=60) + +TOKEN_REFRESH_CHECK_INTERVAL = timedelta(days=1) +STALE_TOKEN_THRESHOLD = timedelta(days=30).total_seconds() + _LOGGER = logging.getLogger(__name__) @@ -36,6 +44,7 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): self.username = entry_data[CONF_USERNAME] self.password = entry_data[CONF_PASSWORD] self._setup_complete = False + self._cancel_token_refresh: CALLBACK_TYPE | None = None super().__init__( hass, _LOGGER, @@ -44,39 +53,92 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): always_update=False, ) + @callback + def _async_refresh_token_if_needed(self, now: datetime.datetime) -> None: + """Proactively refresh token if its stale in case cloud services goes down.""" + assert isinstance(self.envoy.auth, EnvoyTokenAuth) + expire_time = self.envoy.auth.expire_timestamp + remain = expire_time - now.timestamp() + fresh = remain > STALE_TOKEN_THRESHOLD + name = self.name + _LOGGER.debug("%s: %s seconds remaining on token fresh=%s", name, remain, fresh) + if not fresh: + self.hass.async_create_background_task( + self._async_try_refresh_token(), "{name} token refresh" + ) + + async def _async_try_refresh_token(self) -> None: + """Try to refresh token.""" + assert isinstance(self.envoy.auth, EnvoyTokenAuth) + _LOGGER.debug("%s: Trying to refresh token", self.name) + try: + await self.envoy.auth.refresh() + except EnvoyError as err: + # If we can't refresh the token, we try again later + # If the token actually ends up expiring, we'll + # re-authenticate with username/password and get a new token + # or log an error if that fails + _LOGGER.debug("%s: Error refreshing token: %s", err, self.name) + return + self._async_update_saved_token() + + @callback + def _async_mark_setup_complete(self) -> None: + """Mark setup as complete and setup token refresh if needed.""" + self._setup_complete = True + if self._cancel_token_refresh: + self._cancel_token_refresh() + self._cancel_token_refresh = None + if not isinstance(self.envoy.auth, EnvoyTokenAuth): + return + self._cancel_token_refresh = async_track_time_interval( + self.hass, + self._async_refresh_token_if_needed, + TOKEN_REFRESH_CHECK_INTERVAL, + cancel_on_shutdown=True, + ) + async def _async_setup_and_authenticate(self) -> None: """Set up and authenticate with the envoy.""" envoy = self.envoy await envoy.setup() assert envoy.serial_number is not None self.envoy_serial_number = envoy.serial_number - if token := self.entry.data.get(CONF_TOKEN): - try: - await envoy.authenticate(token=token) - except INVALID_AUTH_ERRORS: - # token likely expired or firmware changed - # so we fall through to authenticate with username/password - pass - else: - self._setup_complete = True + with contextlib.suppress(*INVALID_AUTH_ERRORS): + # Always set the username and password + # so we can refresh the token if needed + await envoy.authenticate( + username=self.username, password=self.password, token=token + ) + # The token is valid, but we still want + # to refresh it if it's stale right away + self._async_refresh_token_if_needed(dt_util.utcnow()) return + # token likely expired or firmware changed + # so we fall through to authenticate with + # username/password + await self.envoy.authenticate(username=self.username, password=self.password) + # Password auth succeeded, so we can update the token + # if we are using EnvoyTokenAuth + self._async_update_saved_token() - await envoy.authenticate(username=self.username, password=self.password) - assert envoy.auth is not None - - if isinstance(envoy.auth, EnvoyTokenAuth): - # update token in config entry so we can - # startup without hitting the Cloud API - # as long as the token is valid - self.hass.config_entries.async_update_entry( - self.entry, - data={ - **self.entry.data, - CONF_TOKEN: envoy.auth.token, - }, - ) - self._setup_complete = True + def _async_update_saved_token(self) -> None: + """Update saved token in config entry.""" + envoy = self.envoy + if not isinstance(envoy.auth, EnvoyTokenAuth): + return + # update token in config entry so we can + # startup without hitting the Cloud API + # as long as the token is valid + _LOGGER.debug("%s: Updating token in config entry from auth", self.name) + self.hass.config_entries.async_update_entry( + self.entry, + data={ + **self.entry.data, + CONF_TOKEN: envoy.auth.token, + }, + ) async def _async_update_data(self) -> dict[str, Any]: """Fetch all device and sensor data from api.""" @@ -85,6 +147,7 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): try: if not self._setup_complete: await self._async_setup_and_authenticate() + self._async_mark_setup_complete() return (await envoy.update()).raw except INVALID_AUTH_ERRORS as err: if self._setup_complete and tries == 0: diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index 40e58348768..b3afbdd29c3 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/enphase_envoy", "iot_class": "local_polling", "loggers": ["pyenphase"], - "requirements": ["pyenphase==0.9.0"], + "requirements": ["pyenphase==0.10.0"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index bb199e1a9c2..5a3cf580803 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1662,7 +1662,7 @@ pyedimax==0.2.1 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==0.9.0 +pyenphase==0.10.0 # homeassistant.components.envisalink pyenvisalink==4.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 056b354f451..7dda254b5f2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1229,7 +1229,7 @@ pyeconet==0.1.20 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==0.9.0 +pyenphase==0.10.0 # homeassistant.components.everlights pyeverlights==0.1.0