Proactively refresh the enphase envoy token to handle cloud service downtime (#97880)

This commit is contained in:
J. Nick Koston 2023-08-06 08:31:45 -10:00 committed by GitHub
parent a59793df4c
commit ceac5f8d5a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 91 additions and 28 deletions

View File

@ -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:

View File

@ -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."

View File

@ -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

View File

@ -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