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

Handle missing or incorrect device name and unique id for ESPHome during manual add #95678

Merged
merged 11 commits into from
Jul 2, 2023
Next Next commit
Handle incorrect or missing device name for ESPHome noise encryption
If we did not have the device name during setup we could never
get the key from the dashboard. The device will send us
its name if we try encryption which allows us to find the
right key from the dashboard.

This should help get users unstuck when they change the key
and cannot get the device back online after deleting and
trying to set it up again manually
  • Loading branch information
bdraco committed Jul 1, 2023
commit a29e874243b10d72c7f5d485f14336e6bbf8133e
23 changes: 17 additions & 6 deletions homeassistant/components/esphome/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@
ESPHOME_URL = "https://esphome.io/"
_LOGGER = logging.getLogger(__name__)

ZERO_NOISE_PSK = "MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA="


class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a esphome config flow."""
Expand Down Expand Up @@ -149,10 +151,17 @@ def _name(self, value: str) -> None:
async def _async_try_fetch_device_info(self) -> FlowResult:
error = await self.fetch_device_info()

if (
error == ERROR_REQUIRES_ENCRYPTION_KEY
and await self._retrieve_encryption_key_from_dashboard()
):
if error == ERROR_REQUIRES_ENCRYPTION_KEY:
if not self._device_name and not self._noise_psk:
# If device name is not set we can send a zero noise psk
# to get the device name which will allow us to populate
# the device name and hopefully get the encryption key
# from the dashboard.
self._noise_psk = ZERO_NOISE_PSK
error = await self.fetch_device_info()
self._noise_psk = None

await self._retrieve_encryption_key_from_dashboard()
error = await self.fetch_device_info()
# If the fetched key is invalid, unset it again.
if error == ERROR_INVALID_ENCRYPTION_KEY:
Expand Down Expand Up @@ -323,7 +332,10 @@ async def fetch_device_info(self) -> str | None:
self._device_info = await cli.device_info()
except RequiresEncryptionAPIError:
return ERROR_REQUIRES_ENCRYPTION_KEY
except InvalidEncryptionKeyAPIError:
except InvalidEncryptionKeyAPIError as ex:
if ex.received_name:
self._device_name = ex.received_name
self._name = ex.received_name
return ERROR_INVALID_ENCRYPTION_KEY
except ResolveAPIError:
return "resolve_error"
Expand Down Expand Up @@ -380,7 +392,6 @@ async def _retrieve_encryption_key_from_dashboard(self) -> bool:
return False

await dashboard.async_request_refresh()

if not dashboard.last_update_success:
return False

Expand Down
217 changes: 214 additions & 3 deletions tests/components/esphome/test_config_flow.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Test config flow."""
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch

from aioesphomeapi import (
Expand All @@ -10,6 +11,7 @@
RequiresEncryptionAPIError,
ResolveAPIError,
)
import aiohttp
import pytest

from homeassistant import config_entries, data_entry_flow
Expand All @@ -35,6 +37,7 @@
from tests.common import MockConfigEntry

INVALID_NOISE_PSK = "lSYBYEjQI1bVL8s2Vask4YytGMj1f1epNtmoim2yuTM="
WRONG_NOISE_PSK = "GP+ciK+nVfTQ/gcz6uOdS+oKEdJgesU+jeu8Ssj2how="


@pytest.fixture(autouse=False)
Expand Down Expand Up @@ -217,6 +220,214 @@ async def test_user_invalid_password(
assert result["errors"] == {"base": "invalid_auth"}


async def test_user_dashboard_has_wrong_key(
hass: HomeAssistant,
mock_client,
mock_dashboard,
mock_zeroconf: None,
mock_setup_entry: None,
) -> None:
"""Test user step with key from dashboard that is incorrect."""
mock_client.device_info.side_effect = [
RequiresEncryptionAPIError,
InvalidEncryptionKeyAPIError,
InvalidEncryptionKeyAPIError,
DeviceInfo(
uses_password=False,
name="test",
mac_address="11:22:33:44:55:aa",
),
]

with patch(
"homeassistant.components.esphome.dashboard.ESPHomeDashboardAPI.get_encryption_key",
return_value=WRONG_NOISE_PSK,
):
result = await hass.config_entries.flow.async_init(
"esphome",
context={"source": config_entries.SOURCE_USER},
data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053},
)
await hass.async_block_till_done()

assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "encryption_key"

result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK}
)

assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["data"] == {
CONF_HOST: "127.0.0.1",
CONF_PORT: 6053,
CONF_PASSWORD: "",
CONF_NOISE_PSK: VALID_NOISE_PSK,
CONF_DEVICE_NAME: "test",
}
assert mock_client.noise_psk == VALID_NOISE_PSK


async def test_user_discovers_name_and_gets_key_from_dashboard(
hass: HomeAssistant,
mock_client,
mock_dashboard,
mock_zeroconf: None,
mock_setup_entry: None,
) -> None:
"""Test user step can discover the name and get the key from the dashboard."""
mock_client.device_info.side_effect = [
RequiresEncryptionAPIError,
InvalidEncryptionKeyAPIError("Wrong key", "test"),
DeviceInfo(
uses_password=False,
name="test",
mac_address="11:22:33:44:55:aa",
),
]

mock_dashboard["configured"].append(
{
"name": "test",
"configuration": "test.yaml",
}
)
await dashboard.async_get_dashboard(hass).async_refresh()

with patch(
"homeassistant.components.esphome.dashboard.ESPHomeDashboardAPI.get_encryption_key",
return_value=VALID_NOISE_PSK,
):
result = await hass.config_entries.flow.async_init(
"esphome",
context={"source": config_entries.SOURCE_USER},
data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053},
)
await hass.async_block_till_done()

assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["data"] == {
CONF_HOST: "127.0.0.1",
CONF_PORT: 6053,
CONF_PASSWORD: "",
CONF_NOISE_PSK: VALID_NOISE_PSK,
CONF_DEVICE_NAME: "test",
}
assert mock_client.noise_psk == VALID_NOISE_PSK


async def test_user_discovers_name_and_gets_key_from_dashboard_fails(
hass: HomeAssistant,
mock_client,
mock_dashboard,
mock_zeroconf: None,
mock_setup_entry: None,
) -> None:
"""Test user step can discover the name and get the key from the dashboard."""
mock_client.device_info.side_effect = [
RequiresEncryptionAPIError,
InvalidEncryptionKeyAPIError("Wrong key", "test"),
RequiresEncryptionAPIError,
DeviceInfo(
uses_password=False,
name="test",
mac_address="11:22:33:44:55:aa",
),
]

mock_dashboard["configured"].append(
{
"name": "test",
"configuration": "test.yaml",
}
)
await dashboard.async_get_dashboard(hass).async_refresh()

with patch(
"homeassistant.components.esphome.dashboard.ESPHomeDashboardAPI.get_encryption_key",
side_effect=aiohttp.ClientError,
):
result = await hass.config_entries.flow.async_init(
"esphome",
context={"source": config_entries.SOURCE_USER},
data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053},
)
await hass.async_block_till_done()

assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "encryption_key"

result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK}
)

assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["data"] == {
CONF_HOST: "127.0.0.1",
CONF_PORT: 6053,
CONF_PASSWORD: "",
CONF_NOISE_PSK: VALID_NOISE_PSK,
CONF_DEVICE_NAME: "test",
}
assert mock_client.noise_psk == VALID_NOISE_PSK


async def test_user_discovers_name_and_dashboard_is_unavailable(
hass: HomeAssistant,
mock_client,
mock_dashboard,
mock_zeroconf: None,
mock_setup_entry: None,
) -> None:
"""Test user step can discover the name but the dashboard is unavailable."""
mock_client.device_info.side_effect = [
RequiresEncryptionAPIError,
InvalidEncryptionKeyAPIError("Wrong key", "test"),
RequiresEncryptionAPIError,
DeviceInfo(
uses_password=False,
name="test",
mac_address="11:22:33:44:55:aa",
),
]

mock_dashboard["configured"].append(
{
"name": "test",
"configuration": "test.yaml",
}
)

with patch(
"esphome_dashboard_api.ESPHomeDashboardAPI.get_devices",
side_effect=asyncio.TimeoutError,
):
await dashboard.async_get_dashboard(hass).async_refresh()
result = await hass.config_entries.flow.async_init(
"esphome",
context={"source": config_entries.SOURCE_USER},
data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053},
)
await hass.async_block_till_done()

assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "encryption_key"

result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK}
)

assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["data"] == {
CONF_HOST: "127.0.0.1",
CONF_PORT: 6053,
CONF_PASSWORD: "",
CONF_NOISE_PSK: VALID_NOISE_PSK,
CONF_DEVICE_NAME: "test",
}
assert mock_client.noise_psk == VALID_NOISE_PSK


async def test_login_connection_error(
hass: HomeAssistant, mock_client, mock_zeroconf: None, mock_setup_entry: None
) -> None:
Expand Down Expand Up @@ -398,9 +609,9 @@ async def test_user_requires_psk(
assert result["step_id"] == "encryption_key"
assert result["errors"] == {}

assert len(mock_client.connect.mock_calls) == 1
assert len(mock_client.device_info.mock_calls) == 1
assert len(mock_client.disconnect.mock_calls) == 1
assert len(mock_client.connect.mock_calls) == 3
assert len(mock_client.device_info.mock_calls) == 3
assert len(mock_client.disconnect.mock_calls) == 3


async def test_encryption_key_valid_psk(
Expand Down