mirror of
https://github.com/home-assistant/core.git
synced 2025-07-21 12:17:07 +00:00
Cache envoy auth tokens to ensure integration works if cloud is offline (#97872)
This commit is contained in:
parent
6a65a97715
commit
00e78fbf19
@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
from pyenphase import Envoy
|
from pyenphase import Envoy
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
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.core import HomeAssistant
|
||||||
from homeassistant.helpers import device_registry as dr
|
from homeassistant.helpers import device_registry as dr
|
||||||
from homeassistant.helpers.httpx_client import get_async_client
|
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:
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"""Set up Enphase Envoy from a config entry."""
|
"""Set up Enphase Envoy from a config entry."""
|
||||||
|
|
||||||
config = entry.data
|
host = entry.data[CONF_HOST]
|
||||||
name = config[CONF_NAME]
|
|
||||||
host = config[CONF_HOST]
|
|
||||||
username = config[CONF_USERNAME]
|
|
||||||
password = config[CONF_PASSWORD]
|
|
||||||
|
|
||||||
envoy = Envoy(host, get_async_client(hass, verify_ssl=False))
|
envoy = Envoy(host, get_async_client(hass, verify_ssl=False))
|
||||||
|
coordinator = EnphaseUpdateCoordinator(hass, envoy, entry)
|
||||||
coordinator = EnphaseUpdateCoordinator(hass, envoy, name, username, password)
|
|
||||||
|
|
||||||
await coordinator.async_config_entry_first_refresh()
|
await coordinator.async_config_entry_first_refresh()
|
||||||
if not entry.unique_id:
|
if not entry.unique_id:
|
||||||
|
@ -9,8 +9,6 @@ from awesomeversion import AwesomeVersion
|
|||||||
from pyenphase import (
|
from pyenphase import (
|
||||||
AUTH_TOKEN_MIN_VERSION,
|
AUTH_TOKEN_MIN_VERSION,
|
||||||
Envoy,
|
Envoy,
|
||||||
EnvoyAuthenticationError,
|
|
||||||
EnvoyAuthenticationRequired,
|
|
||||||
EnvoyError,
|
EnvoyError,
|
||||||
)
|
)
|
||||||
import voluptuous as vol
|
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.helpers.httpx_client import get_async_client
|
||||||
from homeassistant.util.network import is_ipv4_address
|
from homeassistant.util.network import is_ipv4_address
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN, INVALID_AUTH_ERRORS
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -31,8 +29,6 @@ ENVOY = "Envoy"
|
|||||||
|
|
||||||
CONF_SERIAL = "serial"
|
CONF_SERIAL = "serial"
|
||||||
|
|
||||||
INVALID_AUTH_ERRORS = (EnvoyAuthenticationError, EnvoyAuthenticationRequired)
|
|
||||||
|
|
||||||
INSTALLER_AUTH_USERNAME = "installer"
|
INSTALLER_AUTH_USERNAME = "installer"
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,6 +1,15 @@
|
|||||||
"""The enphase_envoy component."""
|
"""The enphase_envoy component."""
|
||||||
|
from pyenphase import (
|
||||||
|
EnvoyAuthenticationError,
|
||||||
|
EnvoyAuthenticationRequired,
|
||||||
|
)
|
||||||
|
|
||||||
from homeassistant.const import Platform
|
from homeassistant.const import Platform
|
||||||
|
|
||||||
DOMAIN = "enphase_envoy"
|
DOMAIN = "enphase_envoy"
|
||||||
|
|
||||||
PLATFORMS = [Platform.SENSOR]
|
PLATFORMS = [Platform.SENSOR]
|
||||||
|
|
||||||
|
CONF_TOKEN = "token"
|
||||||
|
|
||||||
|
INVALID_AUTH_ERRORS = (EnvoyAuthenticationError, EnvoyAuthenticationRequired)
|
||||||
|
@ -7,15 +7,18 @@ from typing import Any
|
|||||||
|
|
||||||
from pyenphase import (
|
from pyenphase import (
|
||||||
Envoy,
|
Envoy,
|
||||||
EnvoyAuthenticationError,
|
|
||||||
EnvoyAuthenticationRequired,
|
|
||||||
EnvoyError,
|
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.core import HomeAssistant
|
||||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
|
|
||||||
|
from .const import CONF_TOKEN, INVALID_AUTH_ERRORS
|
||||||
|
|
||||||
SCAN_INTERVAL = timedelta(seconds=60)
|
SCAN_INTERVAL = timedelta(seconds=60)
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -25,24 +28,18 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
|
|
||||||
envoy_serial_number: str
|
envoy_serial_number: str
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, hass: HomeAssistant, envoy: Envoy, entry: ConfigEntry) -> None:
|
||||||
self,
|
|
||||||
hass: HomeAssistant,
|
|
||||||
envoy: Envoy,
|
|
||||||
name: str,
|
|
||||||
username: str,
|
|
||||||
password: str,
|
|
||||||
) -> None:
|
|
||||||
"""Initialize DataUpdateCoordinator for the envoy."""
|
"""Initialize DataUpdateCoordinator for the envoy."""
|
||||||
self.envoy = envoy
|
self.envoy = envoy
|
||||||
self.username = username
|
entry_data = entry.data
|
||||||
self.password = password
|
self.entry = entry
|
||||||
self.name = name
|
self.username = entry_data[CONF_USERNAME]
|
||||||
|
self.password = entry_data[CONF_PASSWORD]
|
||||||
self._setup_complete = False
|
self._setup_complete = False
|
||||||
super().__init__(
|
super().__init__(
|
||||||
hass,
|
hass,
|
||||||
_LOGGER,
|
_LOGGER,
|
||||||
name=name,
|
name=entry_data[CONF_NAME],
|
||||||
update_interval=SCAN_INTERVAL,
|
update_interval=SCAN_INTERVAL,
|
||||||
always_update=False,
|
always_update=False,
|
||||||
)
|
)
|
||||||
@ -53,7 +50,32 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
await envoy.setup()
|
await envoy.setup()
|
||||||
assert envoy.serial_number is not None
|
assert envoy.serial_number is not None
|
||||||
self.envoy_serial_number = envoy.serial_number
|
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)
|
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
|
self._setup_complete = True
|
||||||
|
|
||||||
async def _async_update_data(self) -> dict[str, Any]:
|
async def _async_update_data(self) -> dict[str, Any]:
|
||||||
@ -64,7 +86,7 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
if not self._setup_complete:
|
if not self._setup_complete:
|
||||||
await self._async_setup_and_authenticate()
|
await self._async_setup_and_authenticate()
|
||||||
return (await envoy.update()).raw
|
return (await envoy.update()).raw
|
||||||
except (EnvoyAuthenticationError, EnvoyAuthenticationRequired) as err:
|
except INVALID_AUTH_ERRORS as err:
|
||||||
if self._setup_complete and tries == 0:
|
if self._setup_complete and tries == 0:
|
||||||
# token likely expired or firmware changed, try to re-authenticate
|
# token likely expired or firmware changed, try to re-authenticate
|
||||||
self._setup_complete = False
|
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.const import CONF_NAME, CONF_PASSWORD, CONF_UNIQUE_ID, CONF_USERNAME
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import CONF_TOKEN, DOMAIN
|
||||||
from .coordinator import EnphaseUpdateCoordinator
|
from .coordinator import EnphaseUpdateCoordinator
|
||||||
|
|
||||||
CONF_TITLE = "title"
|
CONF_TITLE = "title"
|
||||||
@ -20,6 +20,7 @@ TO_REDACT = {
|
|||||||
CONF_TITLE,
|
CONF_TITLE,
|
||||||
CONF_UNIQUE_ID,
|
CONF_UNIQUE_ID,
|
||||||
CONF_USERNAME,
|
CONF_USERNAME,
|
||||||
|
CONF_TOKEN,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -6,7 +6,7 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/enphase_envoy",
|
"documentation": "https://www.home-assistant.io/integrations/enphase_envoy",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"loggers": ["pyenphase"],
|
"loggers": ["pyenphase"],
|
||||||
"requirements": ["pyenphase==0.8.0"],
|
"requirements": ["pyenphase==0.9.0"],
|
||||||
"zeroconf": [
|
"zeroconf": [
|
||||||
{
|
{
|
||||||
"type": "_enphase-envoy._tcp.local."
|
"type": "_enphase-envoy._tcp.local."
|
||||||
|
@ -1662,7 +1662,7 @@ pyedimax==0.2.1
|
|||||||
pyefergy==22.1.1
|
pyefergy==22.1.1
|
||||||
|
|
||||||
# homeassistant.components.enphase_envoy
|
# homeassistant.components.enphase_envoy
|
||||||
pyenphase==0.8.0
|
pyenphase==0.9.0
|
||||||
|
|
||||||
# homeassistant.components.envisalink
|
# homeassistant.components.envisalink
|
||||||
pyenvisalink==4.6
|
pyenvisalink==4.6
|
||||||
|
@ -1229,7 +1229,7 @@ pyeconet==0.1.20
|
|||||||
pyefergy==22.1.1
|
pyefergy==22.1.1
|
||||||
|
|
||||||
# homeassistant.components.enphase_envoy
|
# homeassistant.components.enphase_envoy
|
||||||
pyenphase==0.8.0
|
pyenphase==0.9.0
|
||||||
|
|
||||||
# homeassistant.components.everlights
|
# homeassistant.components.everlights
|
||||||
pyeverlights==0.1.0
|
pyeverlights==0.1.0
|
||||||
|
@ -7,6 +7,7 @@ from pyenphase import (
|
|||||||
EnvoyInverter,
|
EnvoyInverter,
|
||||||
EnvoySystemConsumption,
|
EnvoySystemConsumption,
|
||||||
EnvoySystemProduction,
|
EnvoySystemProduction,
|
||||||
|
EnvoyTokenAuth,
|
||||||
)
|
)
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@ -43,12 +44,13 @@ def config_fixture():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture(name="mock_envoy")
|
@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."""
|
"""Define a mocked Envoy fixture."""
|
||||||
mock_envoy = Mock(spec=Envoy)
|
mock_envoy = Mock(spec=Envoy)
|
||||||
mock_envoy.serial_number = serial_number
|
mock_envoy.serial_number = serial_number
|
||||||
mock_envoy.authenticate = mock_authenticate
|
mock_envoy.authenticate = mock_authenticate
|
||||||
mock_envoy.setup = mock_setup
|
mock_envoy.setup = mock_setup
|
||||||
|
mock_envoy.auth = mock_auth
|
||||||
mock_envoy.data = EnvoyData(
|
mock_envoy.data = EnvoyData(
|
||||||
system_consumption=EnvoySystemConsumption(
|
system_consumption=EnvoySystemConsumption(
|
||||||
watt_hours_last_7_days=1234,
|
watt_hours_last_7_days=1234,
|
||||||
@ -99,6 +101,12 @@ def mock_authenticate():
|
|||||||
return AsyncMock()
|
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")
|
@pytest.fixture(name="mock_setup")
|
||||||
def mock_setup():
|
def mock_setup():
|
||||||
"""Define a mocked Envoy.setup fixture."""
|
"""Define a mocked Envoy.setup fixture."""
|
||||||
|
@ -24,6 +24,7 @@ async def test_entry_diagnostics(
|
|||||||
"name": REDACTED,
|
"name": REDACTED,
|
||||||
"username": REDACTED,
|
"username": REDACTED,
|
||||||
"password": REDACTED,
|
"password": REDACTED,
|
||||||
|
"token": REDACTED,
|
||||||
},
|
},
|
||||||
"options": {},
|
"options": {},
|
||||||
"pref_disable_new_entities": False,
|
"pref_disable_new_entities": False,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user