Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Reload ESPHome config entries when dashboard info received #86174

Merged
merged 2 commits into from
Jan 18, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 2 additions & 1 deletion homeassistant/components/esphome/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,9 @@
from homeassistant.helpers.template import Template

from .bluetooth import async_connect_scanner
from .const import DOMAIN
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
from .entry_data import RuntimeEntryData
Expand Down
5 changes: 3 additions & 2 deletions homeassistant/components/esphome/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@
from homeassistant.data_entry_flow import FlowResult
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

ERROR_REQUIRES_ENCRYPTION_KEY = "requires_encryption_key"
Expand Down Expand Up @@ -204,7 +205,7 @@ async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowRes

async def async_step_hassio(self, discovery_info: HassioServiceInfo) -> FlowResult:
"""Handle Supervisor service discovery."""
async_set_dashboard_info(
await async_set_dashboard_info(
self.hass,
discovery_info.slug,
discovery_info.config["host"],
Expand Down
3 changes: 3 additions & 0 deletions homeassistant/components/esphome/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""ESPHome constants."""

DOMAIN = "esphome"
59 changes: 32 additions & 27 deletions homeassistant/components/esphome/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,12 @@
import aiohttp
from esphome_dashboard_api import ConfiguredDevice, ESPHomeDashboardAPI

from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant, callback
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"

Expand All @@ -21,23 +24,41 @@ def async_get_dashboard(hass: HomeAssistant) -> ESPHomeDashboard | None:
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
) -> None:
"""Set the dashboard info."""
hass.data[KEY_DASHBOARD] = ESPHomeDashboard(
hass,
addon_slug,
f"http://{host}:{port}",
async_get_clientsession(hass),
)
url = f"http://{host}:{port}"

# Do nothing if we already have this data.
if (
(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
frenck marked this conversation as resolved.
Show resolved Hide resolved

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 to interact with the ESPHome dashboard."""

_first_fetch_lock: asyncio.Lock | None = None

def __init__(
self,
hass: HomeAssistant,
Expand All @@ -53,25 +74,9 @@ def __init__(
update_interval=timedelta(minutes=5),
)
self.addon_slug = addon_slug
self.url = url
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:
"""Fetch device data."""
devices = await self.api.get_devices()
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/esphome/domain_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@
from homeassistant.helpers.json import JSONEncoder
from homeassistant.helpers.storage import Store

from .const import DOMAIN
from .entry_data import RuntimeEntryData

STORAGE_VERSION = 1
DOMAIN = "esphome"
MAX_CACHED_SERVICES = 128

_DomainDataSelfT = TypeVar("_DomainDataSelfT", bound="DomainData")
Expand Down
1 change: 0 additions & 1 deletion homeassistant/components/esphome/update.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@ async def setup_update_entity() -> None:
unsub() # type: ignore[unreachable]

assert dashboard is not None
await dashboard.ensure_data()
async_add_entities([ESPHomeUpdateEntity(entry_data, dashboard)])

if entry_data.available:
Expand Down
11 changes: 11 additions & 0 deletions tests/components/esphome/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,3 +98,14 @@ def mock_constructor(
"homeassistant.components.esphome.config_flow.APIClient", 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
63 changes: 29 additions & 34 deletions tests/components/esphome/test_config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -471,9 +471,10 @@ async def test_reauth_confirm_valid(hass, mock_client, mock_zeroconf):
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."""
dashboard.async_set_dashboard_info(hass, "mock-slug", "mock-host", 6052)

entry = MockConfigEntry(
domain=DOMAIN,
Expand All @@ -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_dashboard["configured"].append(
{
"name": "test",
"configuration": "test.yaml",
}
)

await dashboard.async_set_dashboard_info(hass, "mock-slug", "mock-host", 6052)

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",
return_value=VALID_NOISE_PSK,
) as mock_get_encryption_key:
Expand All @@ -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 entry.data[CONF_NOISE_PSK] == VALID_NOISE_PSK

Expand Down Expand Up @@ -672,7 +672,9 @@ async def test_discovery_hassio(hass):
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."""
service_info = zeroconf.ZeroconfServiceInfo(
host="192.168.43.183",
Expand All @@ -692,7 +694,14 @@ async def test_zeroconf_encryption_key_via_dashboard(hass, mock_client, mock_zer
assert flow["type"] == FlowResultType.FORM
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 = [
RequiresEncryptionAPIError,
Expand All @@ -704,16 +713,6 @@ async def test_zeroconf_encryption_key_via_dashboard(hass, mock_client, mock_zer
]

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",
return_value=VALID_NOISE_PSK,
) as mock_get_encryption_key:
Expand All @@ -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(
hass, mock_client, mock_zeroconf
hass, mock_client, mock_zeroconf, mock_dashboard
):
"""Test encryption key not retrieved from dashboard."""
service_info = zeroconf.ZeroconfServiceInfo(
Expand All @@ -757,17 +756,13 @@ async def test_zeroconf_no_encryption_key_via_dashboard(
assert flow["type"] == FlowResultType.FORM
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

with patch(
"homeassistant.components.esphome.dashboard.ESPHomeDashboardAPI.get_devices",
return_value={"configured": []},
):
result = await hass.config_entries.flow.async_configure(
flow["flow_id"], user_input={}
)
result = await hass.config_entries.flow.async_configure(
flow["flow_id"], user_input={}
)

assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "encryption_key"
22 changes: 22 additions & 0 deletions tests/components/esphome/test_dashboard.py
Original file line number Diff line number Diff line change
@@ -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
7 changes: 3 additions & 4 deletions tests/components/esphome/test_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,21 +37,20 @@ async def test_update_entity(
hass,
mock_config_entry,
mock_device_info,
mock_dashboard,
devices_payload,
expected_state,
expected_attributes,
):
"""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)

with patch(
"homeassistant.components.esphome.update.DomainData.get_entry_data",
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(
mock_config_entry, "update"
Expand Down