From 00e78fbf192c8c9b197cbdfa5638160935eff385 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 5 Aug 2023 14:51:19 -1000 Subject: [PATCH] Cache envoy auth tokens to ensure integration works if cloud is offline (#97872) --- .../components/enphase_envoy/__init__.py | 12 ++--- .../components/enphase_envoy/config_flow.py | 6 +-- .../components/enphase_envoy/const.py | 9 ++++ .../components/enphase_envoy/coordinator.py | 52 +++++++++++++------ .../components/enphase_envoy/diagnostics.py | 3 +- .../components/enphase_envoy/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/enphase_envoy/conftest.py | 10 +++- .../enphase_envoy/test_diagnostics.py | 1 + 10 files changed, 65 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/enphase_envoy/__init__.py b/homeassistant/components/enphase_envoy/__init__.py index daa6a2f4492..7f06a032128 100644 --- a/homeassistant/components/enphase_envoy/__init__.py +++ b/homeassistant/components/enphase_envoy/__init__.py @@ -4,7 +4,7 @@ from __future__ import annotations from pyenphase import Envoy from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.helpers.httpx_client import get_async_client @@ -16,15 +16,9 @@ from .coordinator import EnphaseUpdateCoordinator async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Enphase Envoy from a config entry.""" - config = entry.data - name = config[CONF_NAME] - host = config[CONF_HOST] - username = config[CONF_USERNAME] - password = config[CONF_PASSWORD] - + host = entry.data[CONF_HOST] envoy = Envoy(host, get_async_client(hass, verify_ssl=False)) - - coordinator = EnphaseUpdateCoordinator(hass, envoy, name, username, password) + coordinator = EnphaseUpdateCoordinator(hass, envoy, entry) await coordinator.async_config_entry_first_refresh() if not entry.unique_id: diff --git a/homeassistant/components/enphase_envoy/config_flow.py b/homeassistant/components/enphase_envoy/config_flow.py index 93eaa9514e9..3ec39739ed7 100644 --- a/homeassistant/components/enphase_envoy/config_flow.py +++ b/homeassistant/components/enphase_envoy/config_flow.py @@ -9,8 +9,6 @@ from awesomeversion import AwesomeVersion from pyenphase import ( AUTH_TOKEN_MIN_VERSION, Envoy, - EnvoyAuthenticationError, - EnvoyAuthenticationRequired, EnvoyError, ) import voluptuous as vol @@ -23,7 +21,7 @@ from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.httpx_client import get_async_client from homeassistant.util.network import is_ipv4_address -from .const import DOMAIN +from .const import DOMAIN, INVALID_AUTH_ERRORS _LOGGER = logging.getLogger(__name__) @@ -31,8 +29,6 @@ ENVOY = "Envoy" CONF_SERIAL = "serial" -INVALID_AUTH_ERRORS = (EnvoyAuthenticationError, EnvoyAuthenticationRequired) - INSTALLER_AUTH_USERNAME = "installer" diff --git a/homeassistant/components/enphase_envoy/const.py b/homeassistant/components/enphase_envoy/const.py index 63af27f3ee2..ed829817bf8 100644 --- a/homeassistant/components/enphase_envoy/const.py +++ b/homeassistant/components/enphase_envoy/const.py @@ -1,6 +1,15 @@ """The enphase_envoy component.""" +from pyenphase import ( + EnvoyAuthenticationError, + EnvoyAuthenticationRequired, +) + from homeassistant.const import Platform DOMAIN = "enphase_envoy" PLATFORMS = [Platform.SENSOR] + +CONF_TOKEN = "token" + +INVALID_AUTH_ERRORS = (EnvoyAuthenticationError, EnvoyAuthenticationRequired) diff --git a/homeassistant/components/enphase_envoy/coordinator.py b/homeassistant/components/enphase_envoy/coordinator.py index 0ba89ee8087..85a7dc4c2f8 100644 --- a/homeassistant/components/enphase_envoy/coordinator.py +++ b/homeassistant/components/enphase_envoy/coordinator.py @@ -7,15 +7,18 @@ from typing import Any from pyenphase import ( Envoy, - EnvoyAuthenticationError, - EnvoyAuthenticationRequired, EnvoyError, + EnvoyTokenAuth, ) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from .const import CONF_TOKEN, INVALID_AUTH_ERRORS + SCAN_INTERVAL = timedelta(seconds=60) _LOGGER = logging.getLogger(__name__) @@ -25,24 +28,18 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): envoy_serial_number: str - def __init__( - self, - hass: HomeAssistant, - envoy: Envoy, - name: str, - username: str, - password: str, - ) -> None: + def __init__(self, hass: HomeAssistant, envoy: Envoy, entry: ConfigEntry) -> None: """Initialize DataUpdateCoordinator for the envoy.""" self.envoy = envoy - self.username = username - self.password = password - self.name = name + entry_data = entry.data + self.entry = entry + self.username = entry_data[CONF_USERNAME] + self.password = entry_data[CONF_PASSWORD] self._setup_complete = False super().__init__( hass, _LOGGER, - name=name, + name=entry_data[CONF_NAME], update_interval=SCAN_INTERVAL, always_update=False, ) @@ -53,7 +50,32 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): 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 + return + 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 async def _async_update_data(self) -> dict[str, Any]: @@ -64,7 +86,7 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): if not self._setup_complete: await self._async_setup_and_authenticate() return (await envoy.update()).raw - except (EnvoyAuthenticationError, EnvoyAuthenticationRequired) as err: + except INVALID_AUTH_ERRORS as err: if self._setup_complete and tries == 0: # token likely expired or firmware changed, try to re-authenticate self._setup_complete = False diff --git a/homeassistant/components/enphase_envoy/diagnostics.py b/homeassistant/components/enphase_envoy/diagnostics.py index 792f681bb53..a6ce86c4857 100644 --- a/homeassistant/components/enphase_envoy/diagnostics.py +++ b/homeassistant/components/enphase_envoy/diagnostics.py @@ -8,7 +8,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_UNIQUE_ID, CONF_USERNAME from homeassistant.core import HomeAssistant -from .const import DOMAIN +from .const import CONF_TOKEN, DOMAIN from .coordinator import EnphaseUpdateCoordinator CONF_TITLE = "title" @@ -20,6 +20,7 @@ TO_REDACT = { CONF_TITLE, CONF_UNIQUE_ID, CONF_USERNAME, + CONF_TOKEN, } diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index 8fcd1667852..40e58348768 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.8.0"], + "requirements": ["pyenphase==0.9.0"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index 1be1e301ba5..8f8ed2852ff 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.8.0 +pyenphase==0.9.0 # homeassistant.components.envisalink pyenvisalink==4.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9c109faa990..2fdafad096c 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.8.0 +pyenphase==0.9.0 # homeassistant.components.everlights pyeverlights==0.1.0 diff --git a/tests/components/enphase_envoy/conftest.py b/tests/components/enphase_envoy/conftest.py index b5ea878ae42..355c247b182 100644 --- a/tests/components/enphase_envoy/conftest.py +++ b/tests/components/enphase_envoy/conftest.py @@ -7,6 +7,7 @@ from pyenphase import ( EnvoyInverter, EnvoySystemConsumption, EnvoySystemProduction, + EnvoyTokenAuth, ) import pytest @@ -43,12 +44,13 @@ def config_fixture(): @pytest.fixture(name="mock_envoy") -def mock_envoy_fixture(serial_number, mock_authenticate, mock_setup): +def mock_envoy_fixture(serial_number, mock_authenticate, mock_setup, mock_auth): """Define a mocked Envoy fixture.""" mock_envoy = Mock(spec=Envoy) mock_envoy.serial_number = serial_number mock_envoy.authenticate = mock_authenticate mock_envoy.setup = mock_setup + mock_envoy.auth = mock_auth mock_envoy.data = EnvoyData( system_consumption=EnvoySystemConsumption( watt_hours_last_7_days=1234, @@ -99,6 +101,12 @@ def mock_authenticate(): return AsyncMock() +@pytest.fixture(name="mock_auth") +def mock_auth(serial_number): + """Define a mocked EnvoyAuth fixture.""" + return EnvoyTokenAuth("127.0.0.1", token="abc", envoy_serial=serial_number) + + @pytest.fixture(name="mock_setup") def mock_setup(): """Define a mocked Envoy.setup fixture.""" diff --git a/tests/components/enphase_envoy/test_diagnostics.py b/tests/components/enphase_envoy/test_diagnostics.py index aa5f08567ae..fb1a54dc522 100644 --- a/tests/components/enphase_envoy/test_diagnostics.py +++ b/tests/components/enphase_envoy/test_diagnostics.py @@ -24,6 +24,7 @@ async def test_entry_diagnostics( "name": REDACTED, "username": REDACTED, "password": REDACTED, + "token": REDACTED, }, "options": {}, "pref_disable_new_entities": False,