Cache envoy auth tokens to ensure integration works if cloud is offline (#97872)
This commit is contained in:
parent
6a65a97715
commit
00e78fbf19
10 changed files with 65 additions and 34 deletions
|
@ -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:
|
||||
|
|
|
@ -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"
|
||||
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -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."
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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."""
|
||||
|
|
|
@ -24,6 +24,7 @@ async def test_entry_diagnostics(
|
|||
"name": REDACTED,
|
||||
"username": REDACTED,
|
||||
"password": REDACTED,
|
||||
"token": REDACTED,
|
||||
},
|
||||
"options": {},
|
||||
"pref_disable_new_entities": False,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue