Reload ESPHome config entries when dashboard info received (#86174)
parent
c40c37e9ee
commit
29337bc6eb
|
@ -56,8 +56,9 @@ from homeassistant.helpers.service import async_set_service_schema
|
||||||
from homeassistant.helpers.template import Template
|
from homeassistant.helpers.template import Template
|
||||||
|
|
||||||
from .bluetooth import async_connect_scanner
|
from .bluetooth import async_connect_scanner
|
||||||
|
from .const import DOMAIN
|
||||||
from .dashboard import async_get_dashboard
|
from .dashboard import async_get_dashboard
|
||||||
from .domain_data import DOMAIN, DomainData
|
from .domain_data import DomainData
|
||||||
|
|
||||||
# Import config flow so that it's added to the registry
|
# Import config flow so that it's added to the registry
|
||||||
from .entry_data import RuntimeEntryData
|
from .entry_data import RuntimeEntryData
|
||||||
|
|
|
@ -26,7 +26,8 @@ from homeassistant.core import callback
|
||||||
from homeassistant.data_entry_flow import FlowResult
|
from homeassistant.data_entry_flow import FlowResult
|
||||||
from homeassistant.helpers.device_registry import format_mac
|
from homeassistant.helpers.device_registry import format_mac
|
||||||
|
|
||||||
from . import CONF_DEVICE_NAME, CONF_NOISE_PSK, DOMAIN
|
from . import CONF_DEVICE_NAME, CONF_NOISE_PSK
|
||||||
|
from .const import DOMAIN
|
||||||
from .dashboard import async_get_dashboard, async_set_dashboard_info
|
from .dashboard import async_get_dashboard, async_set_dashboard_info
|
||||||
|
|
||||||
ERROR_REQUIRES_ENCRYPTION_KEY = "requires_encryption_key"
|
ERROR_REQUIRES_ENCRYPTION_KEY = "requires_encryption_key"
|
||||||
|
@ -204,7 +205,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||||
|
|
||||||
async def async_step_hassio(self, discovery_info: HassioServiceInfo) -> FlowResult:
|
async def async_step_hassio(self, discovery_info: HassioServiceInfo) -> FlowResult:
|
||||||
"""Handle Supervisor service discovery."""
|
"""Handle Supervisor service discovery."""
|
||||||
async_set_dashboard_info(
|
await async_set_dashboard_info(
|
||||||
self.hass,
|
self.hass,
|
||||||
discovery_info.slug,
|
discovery_info.slug,
|
||||||
discovery_info.config["host"],
|
discovery_info.config["host"],
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
"""ESPHome constants."""
|
||||||
|
|
||||||
|
DOMAIN = "esphome"
|
|
@ -8,9 +8,12 @@ import logging
|
||||||
import aiohttp
|
import aiohttp
|
||||||
from esphome_dashboard_api import ConfiguredDevice, ESPHomeDashboardAPI
|
from esphome_dashboard_api import ConfiguredDevice, ESPHomeDashboardAPI
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntryState
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
|
||||||
KEY_DASHBOARD = "esphome_dashboard"
|
KEY_DASHBOARD = "esphome_dashboard"
|
||||||
|
|
||||||
|
@ -21,23 +24,41 @@ def async_get_dashboard(hass: HomeAssistant) -> ESPHomeDashboard | None:
|
||||||
return hass.data.get(KEY_DASHBOARD)
|
return hass.data.get(KEY_DASHBOARD)
|
||||||
|
|
||||||
|
|
||||||
def async_set_dashboard_info(
|
async def async_set_dashboard_info(
|
||||||
hass: HomeAssistant, addon_slug: str, host: str, port: int
|
hass: HomeAssistant, addon_slug: str, host: str, port: int
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set the dashboard info."""
|
"""Set the dashboard info."""
|
||||||
hass.data[KEY_DASHBOARD] = ESPHomeDashboard(
|
url = f"http://{host}:{port}"
|
||||||
hass,
|
|
||||||
addon_slug,
|
# Do nothing if we already have this data.
|
||||||
f"http://{host}:{port}",
|
if (
|
||||||
async_get_clientsession(hass),
|
(cur_dashboard := hass.data.get(KEY_DASHBOARD))
|
||||||
)
|
and cur_dashboard.addon_slug == addon_slug
|
||||||
|
and cur_dashboard.url == url
|
||||||
|
):
|
||||||
|
return
|
||||||
|
|
||||||
|
dashboard = ESPHomeDashboard(hass, addon_slug, url, async_get_clientsession(hass))
|
||||||
|
try:
|
||||||
|
await dashboard.async_request_refresh()
|
||||||
|
except UpdateFailed as err:
|
||||||
|
logging.getLogger(__name__).error("Ignoring dashboard info: %s", err)
|
||||||
|
return
|
||||||
|
|
||||||
|
hass.data[KEY_DASHBOARD] = dashboard
|
||||||
|
|
||||||
|
reloads = [
|
||||||
|
hass.config_entries.async_reload(entry.entry_id)
|
||||||
|
for entry in hass.config_entries.async_entries(DOMAIN)
|
||||||
|
if entry.state == ConfigEntryState.LOADED
|
||||||
|
]
|
||||||
|
if reloads:
|
||||||
|
await asyncio.gather(*reloads)
|
||||||
|
|
||||||
|
|
||||||
class ESPHomeDashboard(DataUpdateCoordinator[dict[str, ConfiguredDevice]]):
|
class ESPHomeDashboard(DataUpdateCoordinator[dict[str, ConfiguredDevice]]):
|
||||||
"""Class to interact with the ESPHome dashboard."""
|
"""Class to interact with the ESPHome dashboard."""
|
||||||
|
|
||||||
_first_fetch_lock: asyncio.Lock | None = None
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
|
@ -53,25 +74,9 @@ class ESPHomeDashboard(DataUpdateCoordinator[dict[str, ConfiguredDevice]]):
|
||||||
update_interval=timedelta(minutes=5),
|
update_interval=timedelta(minutes=5),
|
||||||
)
|
)
|
||||||
self.addon_slug = addon_slug
|
self.addon_slug = addon_slug
|
||||||
|
self.url = url
|
||||||
self.api = ESPHomeDashboardAPI(url, session)
|
self.api = ESPHomeDashboardAPI(url, session)
|
||||||
|
|
||||||
async def ensure_data(self) -> None:
|
|
||||||
"""Ensure the update coordinator has data when this call finishes."""
|
|
||||||
if self.data:
|
|
||||||
return
|
|
||||||
|
|
||||||
if self._first_fetch_lock is not None:
|
|
||||||
async with self._first_fetch_lock:
|
|
||||||
# We know the data is fetched when lock is done
|
|
||||||
return
|
|
||||||
|
|
||||||
self._first_fetch_lock = asyncio.Lock()
|
|
||||||
|
|
||||||
async with self._first_fetch_lock:
|
|
||||||
await self.async_request_refresh()
|
|
||||||
|
|
||||||
self._first_fetch_lock = None
|
|
||||||
|
|
||||||
async def _async_update_data(self) -> dict:
|
async def _async_update_data(self) -> dict:
|
||||||
"""Fetch device data."""
|
"""Fetch device data."""
|
||||||
devices = await self.api.get_devices()
|
devices = await self.api.get_devices()
|
||||||
|
|
|
@ -13,10 +13,10 @@ from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.json import JSONEncoder
|
from homeassistant.helpers.json import JSONEncoder
|
||||||
from homeassistant.helpers.storage import Store
|
from homeassistant.helpers.storage import Store
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
from .entry_data import RuntimeEntryData
|
from .entry_data import RuntimeEntryData
|
||||||
|
|
||||||
STORAGE_VERSION = 1
|
STORAGE_VERSION = 1
|
||||||
DOMAIN = "esphome"
|
|
||||||
MAX_CACHED_SERVICES = 128
|
MAX_CACHED_SERVICES = 128
|
||||||
|
|
||||||
_DomainDataSelfT = TypeVar("_DomainDataSelfT", bound="DomainData")
|
_DomainDataSelfT = TypeVar("_DomainDataSelfT", bound="DomainData")
|
||||||
|
|
|
@ -49,7 +49,6 @@ async def async_setup_entry(
|
||||||
unsub() # type: ignore[unreachable]
|
unsub() # type: ignore[unreachable]
|
||||||
|
|
||||||
assert dashboard is not None
|
assert dashboard is not None
|
||||||
await dashboard.ensure_data()
|
|
||||||
async_add_entities([ESPHomeUpdateEntity(entry_data, dashboard)])
|
async_add_entities([ESPHomeUpdateEntity(entry_data, dashboard)])
|
||||||
|
|
||||||
if entry_data.available:
|
if entry_data.available:
|
||||||
|
|
|
@ -98,3 +98,14 @@ def mock_client(mock_device_info):
|
||||||
"homeassistant.components.esphome.config_flow.APIClient", mock_client
|
"homeassistant.components.esphome.config_flow.APIClient", mock_client
|
||||||
):
|
):
|
||||||
yield mock_client
|
yield mock_client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_dashboard():
|
||||||
|
"""Mock dashboard."""
|
||||||
|
data = {"configured": [], "importable": []}
|
||||||
|
with patch(
|
||||||
|
"esphome_dashboard_api.ESPHomeDashboardAPI.get_devices",
|
||||||
|
return_value=data,
|
||||||
|
):
|
||||||
|
yield data
|
||||||
|
|
|
@ -471,9 +471,10 @@ async def test_reauth_confirm_valid(hass, mock_client, mock_zeroconf):
|
||||||
assert entry.data[CONF_NOISE_PSK] == VALID_NOISE_PSK
|
assert entry.data[CONF_NOISE_PSK] == VALID_NOISE_PSK
|
||||||
|
|
||||||
|
|
||||||
async def test_reauth_fixed_via_dashboard(hass, mock_client, mock_zeroconf):
|
async def test_reauth_fixed_via_dashboard(
|
||||||
|
hass, mock_client, mock_zeroconf, mock_dashboard
|
||||||
|
):
|
||||||
"""Test reauth fixed automatically via dashboard."""
|
"""Test reauth fixed automatically via dashboard."""
|
||||||
dashboard.async_set_dashboard_info(hass, "mock-slug", "mock-host", 6052)
|
|
||||||
|
|
||||||
entry = MockConfigEntry(
|
entry = MockConfigEntry(
|
||||||
domain=DOMAIN,
|
domain=DOMAIN,
|
||||||
|
@ -488,17 +489,16 @@ async def test_reauth_fixed_via_dashboard(hass, mock_client, mock_zeroconf):
|
||||||
|
|
||||||
mock_client.device_info.return_value = DeviceInfo(uses_password=False, name="test")
|
mock_client.device_info.return_value = DeviceInfo(uses_password=False, name="test")
|
||||||
|
|
||||||
|
mock_dashboard["configured"].append(
|
||||||
|
{
|
||||||
|
"name": "test",
|
||||||
|
"configuration": "test.yaml",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
await dashboard.async_set_dashboard_info(hass, "mock-slug", "mock-host", 6052)
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"homeassistant.components.esphome.dashboard.ESPHomeDashboardAPI.get_devices",
|
|
||||||
return_value={
|
|
||||||
"configured": [
|
|
||||||
{
|
|
||||||
"name": "test",
|
|
||||||
"configuration": "test.yaml",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
), patch(
|
|
||||||
"homeassistant.components.esphome.dashboard.ESPHomeDashboardAPI.get_encryption_key",
|
"homeassistant.components.esphome.dashboard.ESPHomeDashboardAPI.get_encryption_key",
|
||||||
return_value=VALID_NOISE_PSK,
|
return_value=VALID_NOISE_PSK,
|
||||||
) as mock_get_encryption_key:
|
) as mock_get_encryption_key:
|
||||||
|
@ -511,7 +511,7 @@ async def test_reauth_fixed_via_dashboard(hass, mock_client, mock_zeroconf):
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result["type"] == FlowResultType.ABORT
|
assert result["type"] == FlowResultType.ABORT, result
|
||||||
assert result["reason"] == "reauth_successful"
|
assert result["reason"] == "reauth_successful"
|
||||||
assert entry.data[CONF_NOISE_PSK] == VALID_NOISE_PSK
|
assert entry.data[CONF_NOISE_PSK] == VALID_NOISE_PSK
|
||||||
|
|
||||||
|
@ -672,7 +672,9 @@ async def test_discovery_hassio(hass):
|
||||||
assert dash.addon_slug == "mock-slug"
|
assert dash.addon_slug == "mock-slug"
|
||||||
|
|
||||||
|
|
||||||
async def test_zeroconf_encryption_key_via_dashboard(hass, mock_client, mock_zeroconf):
|
async def test_zeroconf_encryption_key_via_dashboard(
|
||||||
|
hass, mock_client, mock_zeroconf, mock_dashboard
|
||||||
|
):
|
||||||
"""Test encryption key retrieved from dashboard."""
|
"""Test encryption key retrieved from dashboard."""
|
||||||
service_info = zeroconf.ZeroconfServiceInfo(
|
service_info = zeroconf.ZeroconfServiceInfo(
|
||||||
host="192.168.43.183",
|
host="192.168.43.183",
|
||||||
|
@ -692,7 +694,14 @@ async def test_zeroconf_encryption_key_via_dashboard(hass, mock_client, mock_zer
|
||||||
assert flow["type"] == FlowResultType.FORM
|
assert flow["type"] == FlowResultType.FORM
|
||||||
assert flow["step_id"] == "discovery_confirm"
|
assert flow["step_id"] == "discovery_confirm"
|
||||||
|
|
||||||
dashboard.async_set_dashboard_info(hass, "mock-slug", "mock-host", 6052)
|
mock_dashboard["configured"].append(
|
||||||
|
{
|
||||||
|
"name": "test8266",
|
||||||
|
"configuration": "test8266.yaml",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
await dashboard.async_set_dashboard_info(hass, "mock-slug", "mock-host", 6052)
|
||||||
|
|
||||||
mock_client.device_info.side_effect = [
|
mock_client.device_info.side_effect = [
|
||||||
RequiresEncryptionAPIError,
|
RequiresEncryptionAPIError,
|
||||||
|
@ -704,16 +713,6 @@ async def test_zeroconf_encryption_key_via_dashboard(hass, mock_client, mock_zer
|
||||||
]
|
]
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"homeassistant.components.esphome.dashboard.ESPHomeDashboardAPI.get_devices",
|
|
||||||
return_value={
|
|
||||||
"configured": [
|
|
||||||
{
|
|
||||||
"name": "test8266",
|
|
||||||
"configuration": "test8266.yaml",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
), patch(
|
|
||||||
"homeassistant.components.esphome.dashboard.ESPHomeDashboardAPI.get_encryption_key",
|
"homeassistant.components.esphome.dashboard.ESPHomeDashboardAPI.get_encryption_key",
|
||||||
return_value=VALID_NOISE_PSK,
|
return_value=VALID_NOISE_PSK,
|
||||||
) as mock_get_encryption_key:
|
) as mock_get_encryption_key:
|
||||||
|
@ -736,7 +735,7 @@ async def test_zeroconf_encryption_key_via_dashboard(hass, mock_client, mock_zer
|
||||||
|
|
||||||
|
|
||||||
async def test_zeroconf_no_encryption_key_via_dashboard(
|
async def test_zeroconf_no_encryption_key_via_dashboard(
|
||||||
hass, mock_client, mock_zeroconf
|
hass, mock_client, mock_zeroconf, mock_dashboard
|
||||||
):
|
):
|
||||||
"""Test encryption key not retrieved from dashboard."""
|
"""Test encryption key not retrieved from dashboard."""
|
||||||
service_info = zeroconf.ZeroconfServiceInfo(
|
service_info = zeroconf.ZeroconfServiceInfo(
|
||||||
|
@ -757,17 +756,13 @@ async def test_zeroconf_no_encryption_key_via_dashboard(
|
||||||
assert flow["type"] == FlowResultType.FORM
|
assert flow["type"] == FlowResultType.FORM
|
||||||
assert flow["step_id"] == "discovery_confirm"
|
assert flow["step_id"] == "discovery_confirm"
|
||||||
|
|
||||||
dashboard.async_set_dashboard_info(hass, "mock-slug", "mock-host", 6052)
|
await dashboard.async_set_dashboard_info(hass, "mock-slug", "mock-host", 6052)
|
||||||
|
|
||||||
mock_client.device_info.side_effect = RequiresEncryptionAPIError
|
mock_client.device_info.side_effect = RequiresEncryptionAPIError
|
||||||
|
|
||||||
with patch(
|
result = await hass.config_entries.flow.async_configure(
|
||||||
"homeassistant.components.esphome.dashboard.ESPHomeDashboardAPI.get_devices",
|
flow["flow_id"], user_input={}
|
||||||
return_value={"configured": []},
|
)
|
||||||
):
|
|
||||||
result = await hass.config_entries.flow.async_configure(
|
|
||||||
flow["flow_id"], user_input={}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result["type"] == FlowResultType.FORM
|
assert result["type"] == FlowResultType.FORM
|
||||||
assert result["step_id"] == "encryption_key"
|
assert result["step_id"] == "encryption_key"
|
||||||
|
|
|
@ -0,0 +1,22 @@
|
||||||
|
"""Test ESPHome dashboard features."""
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from homeassistant.components.esphome import dashboard
|
||||||
|
from homeassistant.config_entries import ConfigEntryState
|
||||||
|
|
||||||
|
|
||||||
|
async def test_new_info_reload_config_entries(hass, init_integration, mock_dashboard):
|
||||||
|
"""Test config entries are reloaded when new info is set."""
|
||||||
|
assert init_integration.state == ConfigEntryState.LOADED
|
||||||
|
|
||||||
|
with patch("homeassistant.components.esphome.async_setup_entry") as mock_setup:
|
||||||
|
await dashboard.async_set_dashboard_info(hass, "test-slug", "test-host", 6052)
|
||||||
|
|
||||||
|
assert len(mock_setup.mock_calls) == 1
|
||||||
|
assert mock_setup.mock_calls[0][1][1] == init_integration
|
||||||
|
|
||||||
|
# Test it's a no-op when the same info is set
|
||||||
|
with patch("homeassistant.components.esphome.async_setup_entry") as mock_setup:
|
||||||
|
await dashboard.async_set_dashboard_info(hass, "test-slug", "test-host", 6052)
|
||||||
|
|
||||||
|
assert len(mock_setup.mock_calls) == 0
|
|
@ -37,21 +37,20 @@ async def test_update_entity(
|
||||||
hass,
|
hass,
|
||||||
mock_config_entry,
|
mock_config_entry,
|
||||||
mock_device_info,
|
mock_device_info,
|
||||||
|
mock_dashboard,
|
||||||
devices_payload,
|
devices_payload,
|
||||||
expected_state,
|
expected_state,
|
||||||
expected_attributes,
|
expected_attributes,
|
||||||
):
|
):
|
||||||
"""Test ESPHome update entity."""
|
"""Test ESPHome update entity."""
|
||||||
async_set_dashboard_info(hass, "mock-addon-slug", "mock-addon-host", 1234)
|
mock_dashboard["configured"] = devices_payload
|
||||||
|
await async_set_dashboard_info(hass, "mock-addon-slug", "mock-addon-host", 1234)
|
||||||
|
|
||||||
mock_config_entry.add_to_hass(hass)
|
mock_config_entry.add_to_hass(hass)
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"homeassistant.components.esphome.update.DomainData.get_entry_data",
|
"homeassistant.components.esphome.update.DomainData.get_entry_data",
|
||||||
return_value=Mock(available=True, device_info=mock_device_info),
|
return_value=Mock(available=True, device_info=mock_device_info),
|
||||||
), patch(
|
|
||||||
"homeassistant.components.esphome.dashboard.ESPHomeDashboardAPI.get_devices",
|
|
||||||
return_value={"configured": devices_payload},
|
|
||||||
):
|
):
|
||||||
assert await hass.config_entries.async_forward_entry_setup(
|
assert await hass.config_entries.async_forward_entry_setup(
|
||||||
mock_config_entry, "update"
|
mock_config_entry, "update"
|
||||||
|
|
Loading…
Reference in New Issue