diff --git a/.coveragerc b/.coveragerc index c7861d7aa34..4a489a965b7 100644 --- a/.coveragerc +++ b/.coveragerc @@ -118,6 +118,9 @@ omit = homeassistant/components/ecovacs.py homeassistant/components/*/ecovacs.py + homeassistant/components/esphome/__init__.py + homeassistant/components/*/esphome.py + homeassistant/components/eufy.py homeassistant/components/*/eufy.py diff --git a/CODEOWNERS b/CODEOWNERS index 659f434d14b..745f98c09e0 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -184,6 +184,8 @@ homeassistant/components/*/edp_redy.py @abmantis homeassistant/components/edp_redy.py @abmantis homeassistant/components/eight_sleep.py @mezz64 homeassistant/components/*/eight_sleep.py @mezz64 +homeassistant/components/esphome/*.py @OttoWinter +homeassistant/components/*/esphome.py @OttoWinter # H homeassistant/components/hive.py @Rendili @KJonline diff --git a/homeassistant/components/esphome/.translations/en.json b/homeassistant/components/esphome/.translations/en.json new file mode 100644 index 00000000000..53331ebc0a9 --- /dev/null +++ b/homeassistant/components/esphome/.translations/en.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "ESP is already configured" + }, + "error": { + "connection_error": "Can't connect to ESP. Please make sure your YAML file contains an 'api:' line.", + "invalid_password": "Invalid password!", + "resolve_error": "Can't resolve address of the ESP. If this error persists, please set a static IP address: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" + }, + "step": { + "authenticate": { + "data": { + "password": "Password" + }, + "description": "Please enter the password you set in your configuration.", + "title": "Enter Password" + }, + "user": { + "data": { + "host": "Host", + "port": "Port" + }, + "description": "Please enter connection settings of your [ESPHome](https://esphomelib.com/) node.", + "title": "ESPHome" + } + }, + "title": "ESPHome" + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py new file mode 100644 index 00000000000..d5716162f8b --- /dev/null +++ b/homeassistant/components/esphome/__init__.py @@ -0,0 +1,409 @@ +"""Support for esphome devices.""" +import asyncio +import logging +from typing import Any, Dict, List, Optional, TYPE_CHECKING, Callable + +import attr +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, \ + EVENT_HOMEASSISTANT_STOP +from homeassistant.core import callback, Event +from homeassistant.helpers.dispatcher import async_dispatcher_connect, \ + async_dispatcher_send +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.typing import HomeAssistantType, ConfigType + +# Import config flow so that it's added to the registry +from .config_flow import EsphomeFlowHandler # noqa + +if TYPE_CHECKING: + from aioesphomeapi import APIClient, EntityInfo, EntityState, DeviceInfo + +DOMAIN = 'esphome' +REQUIREMENTS = ['aioesphomeapi==1.1.0'] + + +DISPATCHER_UPDATE_ENTITY = 'esphome_{entry_id}_update_{component_key}_{key}' +DISPATCHER_REMOVE_ENTITY = 'esphome_{entry_id}_remove_{component_key}_{key}' +DISPATCHER_ON_LIST = 'esphome_{entry_id}_on_list' +DISPATCHER_ON_DEVICE_UPDATE = 'esphome_{entry_id}_on_device_update' +DISPATCHER_ON_STATE = 'esphome_{entry_id}_on_state' +# The HA component types this integration supports +HA_COMPONENTS = ['sensor'] + +_LOGGER = logging.getLogger(__name__) + +# No config schema - only configuration entry +CONFIG_SCHEMA = vol.Schema({}, extra=vol.ALLOW_EXTRA) + + +@attr.s +class RuntimeEntryData: + """Store runtime data for esphome config entries.""" + + entry_id = attr.ib(type=str) + client = attr.ib(type='APIClient') + reconnect_task = attr.ib(type=Optional[asyncio.Task], default=None) + state = attr.ib(type=Dict[str, Dict[str, Any]], factory=dict) + info = attr.ib(type=Dict[str, Dict[str, Any]], factory=dict) + available = attr.ib(type=bool, default=False) + device_info = attr.ib(type='DeviceInfo', default=None) + cleanup_callbacks = attr.ib(type=List[Callable[[], None]], factory=list) + + def async_update_entity(self, hass: HomeAssistantType, component_key: str, + key: int) -> None: + """Schedule the update of an entity.""" + signal = DISPATCHER_UPDATE_ENTITY.format( + entry_id=self.entry_id, component_key=component_key, key=key) + async_dispatcher_send(hass, signal) + + def async_remove_entity(self, hass: HomeAssistantType, component_key: str, + key: int) -> None: + """Schedule the removal of an entity.""" + signal = DISPATCHER_REMOVE_ENTITY.format( + entry_id=self.entry_id, component_key=component_key, key=key) + async_dispatcher_send(hass, signal) + + def async_update_static_infos(self, hass: HomeAssistantType, + infos: 'List[EntityInfo]') -> None: + """Distribute an update of static infos to all platforms.""" + signal = DISPATCHER_ON_LIST.format(entry_id=self.entry_id) + async_dispatcher_send(hass, signal, infos) + + def async_update_state(self, hass: HomeAssistantType, + state: 'EntityState') -> None: + """Distribute an update of state information to all platforms.""" + signal = DISPATCHER_ON_STATE.format(entry_id=self.entry_id) + async_dispatcher_send(hass, signal, state) + + def async_update_device_state(self, hass: HomeAssistantType) -> None: + """Distribute an update of a core device state like availability.""" + signal = DISPATCHER_ON_DEVICE_UPDATE.format(entry_id=self.entry_id) + async_dispatcher_send(hass, signal) + + +async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: + """Stub to allow setting up this component. + + Configuration through YAML is not supported at this time. + """ + return True + + +async def async_setup_entry(hass: HomeAssistantType, + entry: ConfigEntry) -> bool: + """Set up the esphome component.""" + # pylint: disable=redefined-outer-name + from aioesphomeapi import APIClient, APIConnectionError + + hass.data.setdefault(DOMAIN, {}) + + host = entry.data[CONF_HOST] + port = entry.data[CONF_PORT] + password = entry.data[CONF_PASSWORD] + + cli = APIClient(hass.loop, host, port, password) + await cli.start() + + # Store client in per-config-entry hass.data + entry_data = hass.data[DOMAIN][entry.entry_id] = RuntimeEntryData( + client=cli, + entry_id=entry.entry_id + ) + + async def on_stop(event: Event) -> None: + """Cleanup the socket client on HA stop.""" + await _cleanup_instance(hass, entry) + + entry_data.cleanup_callbacks.append( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_stop) + ) + + try_connect = await _setup_auto_reconnect_logic(hass, cli, entry, host) + + @callback + def async_on_state(state: 'EntityState') -> None: + """Send dispatcher updates when a new state is received.""" + entry_data.async_update_state(hass, state) + + async def on_login() -> None: + """Subscribe to states and list entities on successful API login.""" + try: + entry_data.device_info = await cli.device_info() + entry_data.available = True + entry_data.async_update_device_state(hass) + + entity_infos = await cli.list_entities() + entry_data.async_update_static_infos(hass, entity_infos) + await cli.subscribe_states(async_on_state) + except APIConnectionError as err: + _LOGGER.warning("Error getting initial data: %s", err) + # Re-connection logic will trigger after this + await cli.disconnect() + + cli.on_login = on_login + + # This is a bit of a hack: We schedule complete_setup into the + # event loop and return immediately (return True) + # + # Usually, we should avoid that so that HA can track which components + # have been started successfully and which failed to be set up. + # That doesn't work here for two reasons: + # - We have our own re-connect logic + # - Before we do the first try_connect() call, we need to make sure + # all dispatcher event listeners have been connected, so + # async_forward_entry_setup needs to be awaited. However, if we + # would await async_forward_entry_setup() in async_setup_entry(), + # we would end up with a deadlock. + # + # Solution is: complete the setup outside of the async_setup_entry() + # function. HA will wait until the first connection attempt is made + # before starting up (as it should), but if the first connection attempt + # fails we will schedule all next re-connect attempts outside of the + # tracked tasks (hass.loop.create_task). This way HA won't stall startup + # forever until a connection is successful. + + async def complete_setup() -> None: + """Complete the config entry setup.""" + tasks = [] + for component in HA_COMPONENTS: + tasks.append(hass.config_entries.async_forward_entry_setup( + entry, component)) + await asyncio.wait(tasks) + + # If first connect fails, the next re-connect will be scheduled + # outside of _pending_task, in order not to delay HA startup + # indefinitely + await try_connect(is_disconnect=False) + + hass.async_create_task(complete_setup()) + return True + + +async def _setup_auto_reconnect_logic(hass: HomeAssistantType, + cli: 'APIClient', + entry: ConfigEntry, host: str): + """Set up the re-connect logic for the API client.""" + from aioesphomeapi import APIConnectionError + + async def try_connect(tries: int = 0, is_disconnect: bool = True) -> None: + """Try connecting to the API client. Will retry if not successful.""" + if entry.entry_id not in hass.data[DOMAIN]: + # When removing/disconnecting manually + return + + data = hass.data[DOMAIN][entry.entry_id] # type: RuntimeEntryData + data.available = False + data.async_update_device_state(hass) + + if tries != 0: + # If not first re-try, wait and print message + wait_time = min(2**tries, 300) + _LOGGER.info("Trying to reconnect in %s seconds", wait_time) + await asyncio.sleep(wait_time) + + if is_disconnect and tries == 0: + # This can happen often depending on WiFi signal strength. + # So therefore all these connection warnings are logged + # as infos. The "unavailable" logic will still trigger so the + # user knows if the device is not connected. + _LOGGER.info("Disconnected from API") + + try: + await cli.connect() + await cli.login() + except APIConnectionError as error: + _LOGGER.info("Can't connect to esphome API for '%s' (%s)", + host, error) + # Schedule re-connect in event loop in order not to delay HA + # startup. First connect is scheduled in tracked tasks. + data.reconnect_task = \ + hass.loop.create_task(try_connect(tries + 1, is_disconnect)) + else: + _LOGGER.info("Successfully connected to %s", host) + + cli.on_disconnect = try_connect + return try_connect + + +async def _cleanup_instance(hass: HomeAssistantType, + entry: ConfigEntry) -> None: + """Cleanup the esphome client if it exists.""" + data = hass.data[DOMAIN].pop(entry.entry_id) # type: RuntimeEntryData + if data.reconnect_task is not None: + data.reconnect_task.cancel() + for cleanup_callback in data.cleanup_callbacks: + cleanup_callback() + await data.client.stop() + + +async def async_unload_entry(hass: HomeAssistantType, + entry: ConfigEntry) -> bool: + """Unload an esphome config entry.""" + await _cleanup_instance(hass, entry) + + tasks = [] + for component in HA_COMPONENTS: + tasks.append(hass.config_entries.async_forward_entry_unload( + entry, component)) + await asyncio.wait(tasks) + + return True + + +async def platform_async_setup_entry(hass: HomeAssistantType, + entry: ConfigEntry, + async_add_entities, + *, + component_key: str, + info_type, + entity_type, + state_type + ) -> None: + """Set up an esphome platform. + + This method is in charge of receiving, distributing and storing + info and state updates. + """ + entry_data = hass.data[DOMAIN][entry.entry_id] # type: RuntimeEntryData + entry_data.info[component_key] = {} + entry_data.state[component_key] = {} + + @callback + def async_list_entities(infos: List['EntityInfo']): + """Update entities of this platform when entities are listed.""" + old_infos = entry_data.info[component_key] + new_infos = {} + add_entities = [] + for info in infos: + if not isinstance(info, info_type): + # Filter out infos that don't belong to this platform. + continue + + if info.key in old_infos: + # Update existing entity + old_infos.pop(info.key) + else: + # Create new entity + entity = entity_type(entry.entry_id, component_key, info.key) + add_entities.append(entity) + new_infos[info.key] = info + + # Remove old entities + for info in old_infos.values(): + entry_data.async_remove_entity(hass, component_key, info.key) + entry_data.info[component_key] = new_infos + async_add_entities(add_entities) + + signal = DISPATCHER_ON_LIST.format(entry_id=entry.entry_id) + entry_data.cleanup_callbacks.append( + async_dispatcher_connect(hass, signal, async_list_entities) + ) + + @callback + def async_entity_state(state: 'EntityState'): + """Notify the appropriate entity of an updated state.""" + if not isinstance(state, state_type): + return + entry_data.state[component_key][state.key] = state + entry_data.async_update_entity(hass, component_key, state.key) + + signal = DISPATCHER_ON_STATE.format(entry_id=entry.entry_id) + entry_data.cleanup_callbacks.append( + async_dispatcher_connect(hass, signal, async_entity_state) + ) + + +class EsphomeEntity(Entity): + """Define a generic esphome entity.""" + + def __init__(self, entry_id: str, component_key: str, key: int): + """Initialize.""" + self._entry_id = entry_id + self._component_key = component_key + self._key = key + self._remove_callbacks = [] # type: List[Callable[[], None]] + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + kwargs = { + 'entry_id': self._entry_id, + 'component_key': self._component_key, + 'key': self._key, + } + self._remove_callbacks.append( + async_dispatcher_connect(self.hass, + DISPATCHER_UPDATE_ENTITY.format(**kwargs), + self.async_schedule_update_ha_state) + ) + + self._remove_callbacks.append( + async_dispatcher_connect(self.hass, + DISPATCHER_REMOVE_ENTITY.format(**kwargs), + self.async_schedule_update_ha_state) + ) + + self._remove_callbacks.append( + async_dispatcher_connect( + self.hass, DISPATCHER_ON_DEVICE_UPDATE.format(**kwargs), + self.async_schedule_update_ha_state) + ) + + async def async_will_remove_from_hass(self): + """Unregister callbacks.""" + for remove_callback in self._remove_callbacks: + remove_callback() + + @property + def _entry_data(self) -> RuntimeEntryData: + return self.hass.data[DOMAIN][self._entry_id] + + @property + def _static_info(self) -> 'EntityInfo': + return self._entry_data.info[self._component_key][self._key] + + @property + def _device_info(self) -> 'DeviceInfo': + return self._entry_data.device_info + + @property + def _client(self) -> 'APIClient': + return self._entry_data.client + + @property + def _state(self) -> 'Optional[EntityState]': + try: + return self._entry_data.state[self._component_key][self._key] + except KeyError: + return None + + @property + def available(self) -> bool: + """Return if the entity is available.""" + device = self._device_info + + if device.has_deep_sleep: + # During deep sleep the ESP will not be connectable (by design) + # For these cases, show it as available + return True + + return self._entry_data.available + + @property + def unique_id(self) -> Optional[str]: + """Return a unique id identifying the entity.""" + if not self._static_info.unique_id: + return None + return self._static_info.unique_id + + @property + def name(self) -> str: + """Return the name of the entity.""" + return self._static_info.name + + @property + def should_poll(self) -> bool: + """Disable polling.""" + return False diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py new file mode 100644 index 00000000000..017cf8c8ee6 --- /dev/null +++ b/homeassistant/components/esphome/config_flow.py @@ -0,0 +1,127 @@ +"""Config flow to configure esphome component.""" +from collections import OrderedDict +from typing import Optional + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.helpers import ConfigType + + +@config_entries.HANDLERS.register('esphome') +class EsphomeFlowHandler(config_entries.ConfigFlow): + """Handle a esphome config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH + + def __init__(self): + """Initialize flow.""" + self._host = None # type: Optional[str] + self._port = None # type: Optional[int] + self._password = None # type: Optional[str] + self._name = None # type: Optional[str] + + async def async_step_user(self, user_input: Optional[ConfigType] = None, + error: Optional[str] = None): + """Handle a flow initialized by the user.""" + if user_input is not None: + self._host = user_input['host'] + self._port = user_input['port'] + error, device_info = await self.fetch_device_info() + if error is not None: + return await self.async_step_user(error=error) + self._name = device_info.name + + # Only show authentication step if device uses password + if device_info.uses_password: + return await self.async_step_authenticate() + + return self._async_get_entry() + + fields = OrderedDict() + fields[vol.Required('host', default=self._host or vol.UNDEFINED)] = str + fields[vol.Optional('port', default=self._port or 6053)] = int + + errors = {} + if error is not None: + errors['base'] = error + + return self.async_show_form( + step_id='user', + data_schema=vol.Schema(fields), + errors=errors + ) + + def _async_get_entry(self): + return self.async_create_entry( + title=self._name, + data={ + 'host': self._host, + 'port': self._port, + # The API uses protobuf, so empty string denotes absence + 'password': self._password or '', + } + ) + + async def async_step_authenticate(self, user_input=None, error=None): + """Handle getting password for authentication.""" + if user_input is not None: + self._password = user_input['password'] + error = await self.try_login() + if error: + return await self.async_step_authenticate(error=error) + return self._async_get_entry() + + errors = {} + if error is not None: + errors['base'] = error + + return self.async_show_form( + step_id='authenticate', + data_schema=vol.Schema({ + vol.Required('password'): str + }), + errors=errors + ) + + async def fetch_device_info(self): + """Fetch device info from API and return any errors.""" + from aioesphomeapi import APIClient, APIConnectionError + + cli = APIClient(self.hass.loop, self._host, self._port, '') + + try: + await cli.start() + await cli.connect() + device_info = await cli.device_info() + except APIConnectionError as err: + if 'resolving' in str(err): + return 'resolve_error', None + return 'connection_error', None + finally: + await cli.stop(force=True) + + return None, device_info + + async def try_login(self): + """Try logging in to device and return any errors.""" + from aioesphomeapi import APIClient, APIConnectionError + + cli = APIClient(self.hass.loop, self._host, self._port, self._password) + + try: + await cli.start() + await cli.connect() + except APIConnectionError: + await cli.stop(force=True) + return 'connection_error' + + try: + await cli.login() + except APIConnectionError: + return 'invalid_password' + finally: + await cli.stop(force=True) + + return None diff --git a/homeassistant/components/esphome/strings.json b/homeassistant/components/esphome/strings.json new file mode 100644 index 00000000000..56eeed8ea41 --- /dev/null +++ b/homeassistant/components/esphome/strings.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "ESP is already configured" + }, + "error": { + "resolve_error": "Can't resolve address of the ESP. If this error persists, please set a static IP address: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips", + "connection_error": "Can't connect to ESP. Please make sure your YAML file contains an 'api:' line.", + "invalid_password": "Invalid password!" + }, + "step": { + "user": { + "data": { + "host": "Host", + "port": "Port" + }, + "description": "Please enter connection settings of your [ESPHome](https://esphomelib.com/) node.", + "title": "ESPHome" + }, + "authenticate": { + "data": { + "password": "Password" + }, + "description": "Please enter the password you set in your configuration.", + "title": "Enter Password" + } + }, + "title": "ESPHome" + } +} diff --git a/homeassistant/components/sensor/esphome.py b/homeassistant/components/sensor/esphome.py new file mode 100644 index 00000000000..1ec392cb937 --- /dev/null +++ b/homeassistant/components/sensor/esphome.py @@ -0,0 +1,62 @@ +"""Support for esphome sensors.""" +import logging +import math +from typing import Optional, TYPE_CHECKING + +from homeassistant.components.esphome import EsphomeEntity, \ + platform_async_setup_entry +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.typing import HomeAssistantType + +if TYPE_CHECKING: + # pylint: disable=unused-import + from aioesphomeapi import SensorInfo, SensorState # noqa + +DEPENDENCIES = ['esphome'] +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistantType, + entry: ConfigEntry, async_add_entities) -> None: + """Set up esphome sensors based on a config entry.""" + # pylint: disable=redefined-outer-name + from aioesphomeapi import SensorInfo, SensorState # noqa + + await platform_async_setup_entry( + hass, entry, async_add_entities, + component_key='sensor', + info_type=SensorInfo, entity_type=EsphomeSensor, + state_type=SensorState + ) + + +class EsphomeSensor(EsphomeEntity): + """A sensor implementation for esphome.""" + + @property + def _static_info(self) -> 'SensorInfo': + return super()._static_info + + @property + def _state(self) -> Optional['SensorState']: + return super()._state + + @property + def icon(self) -> str: + """Return the icon.""" + return self._static_info.icon + + @property + def state(self) -> Optional[str]: + """Return the state of the entity.""" + if self._state is None: + return None + if math.isnan(self._state.state): + return None + return '{:.{prec}f}'.format( + self._state.state, prec=self._static_info.accuracy_decimals) + + @property + def unit_of_measurement(self) -> str: + """Return the unit the value is expressed in.""" + return self._static_info.unit_of_measurement diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 0bfb781b305..94718c1b339 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -139,6 +139,7 @@ FLOWS = [ 'daikin', 'deconz', 'dialogflow', + 'esphome', 'hangouts', 'homematicip_cloud', 'hue', diff --git a/requirements_all.txt b/requirements_all.txt index 7e584dbee9a..e48533de068 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -95,6 +95,9 @@ aioautomatic==0.6.5 # homeassistant.components.sensor.dnsip aiodns==1.1.1 +# homeassistant.components.esphome +aioesphomeapi==1.1.0 + # homeassistant.components.device_tracker.freebox aiofreepybox==0.0.5 diff --git a/tests/components/esphome/__init__.py b/tests/components/esphome/__init__.py new file mode 100644 index 00000000000..a3e4985a2d8 --- /dev/null +++ b/tests/components/esphome/__init__.py @@ -0,0 +1 @@ +"""Tests for esphome.""" diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py new file mode 100644 index 00000000000..d90db501a54 --- /dev/null +++ b/tests/components/esphome/test_config_flow.py @@ -0,0 +1,220 @@ +"""Test config flow.""" +from collections import namedtuple +from unittest.mock import patch, MagicMock + +import pytest + +from homeassistant.components.esphome import config_flow +from tests.common import mock_coro + +MockDeviceInfo = namedtuple("DeviceInfo", ["uses_password", "name"]) + + +@pytest.fixture(autouse=True) +def aioesphomeapi_mock(): + """Mock aioesphomeapi.""" + with patch.dict('sys.modules', { + 'aioesphomeapi': MagicMock(), + }): + yield + + +@pytest.fixture +def mock_client(): + """Mock APIClient.""" + with patch('aioesphomeapi.APIClient') as mock_client: + def mock_constructor(loop, host, port, password): + """Fake the client constructor.""" + mock_client.host = host + mock_client.port = port + mock_client.password = password + return mock_client + + mock_client.side_effect = mock_constructor + mock_client.start.return_value = mock_coro() + mock_client.connect.return_value = mock_coro() + mock_client.stop.return_value = mock_coro() + mock_client.login.return_value = mock_coro() + + yield mock_client + + +@pytest.fixture(autouse=True) +def mock_api_connection_error(): + """Mock out the try login method.""" + with patch('aioesphomeapi.APIConnectionError', + new_callable=lambda: OSError) as mock_error: + yield mock_error + + +async def test_user_connection_works(hass, mock_client): + """Test we can finish a config flow.""" + flow = config_flow.EsphomeFlowHandler() + flow.hass = hass + result = await flow.async_step_user(user_input=None) + assert result['type'] == 'form' + + mock_client.device_info.return_value = mock_coro( + MockDeviceInfo(False, "test")) + + result = await flow.async_step_user(user_input={ + 'host': '127.0.0.1', + 'port': 80, + }) + + assert result['type'] == 'create_entry' + assert result['data'] == { + 'host': '127.0.0.1', + 'port': 80, + 'password': '' + } + assert result['title'] == 'test' + assert len(mock_client.start.mock_calls) == 1 + assert len(mock_client.connect.mock_calls) == 1 + assert len(mock_client.device_info.mock_calls) == 1 + assert len(mock_client.stop.mock_calls) == 1 + assert mock_client.host == '127.0.0.1' + assert mock_client.port == 80 + assert mock_client.password == '' + + +async def test_user_resolve_error(hass, mock_api_connection_error, + mock_client): + """Test user step with IP resolve error.""" + flow = config_flow.EsphomeFlowHandler() + flow.hass = hass + await flow.async_step_user(user_input=None) + + class MockResolveError(mock_api_connection_error): + """Create an exception with a specific error message.""" + + def __init__(self): + """Initialize.""" + super().__init__("Error resolving IP address") + + with patch('aioesphomeapi.APIConnectionError', + new_callable=lambda: MockResolveError, + ) as exc: + mock_client.device_info.side_effect = exc + result = await flow.async_step_user(user_input={ + 'host': '127.0.0.1', + 'port': 6053, + }) + + assert result['type'] == 'form' + assert result['step_id'] == 'user' + assert result['errors'] == { + 'base': 'resolve_error' + } + assert len(mock_client.start.mock_calls) == 1 + assert len(mock_client.connect.mock_calls) == 1 + assert len(mock_client.device_info.mock_calls) == 1 + assert len(mock_client.stop.mock_calls) == 1 + + +async def test_user_connection_error(hass, mock_api_connection_error, + mock_client): + """Test user step with connection error.""" + flow = config_flow.EsphomeFlowHandler() + flow.hass = hass + await flow.async_step_user(user_input=None) + + mock_client.device_info.side_effect = mock_api_connection_error + + result = await flow.async_step_user(user_input={ + 'host': '127.0.0.1', + 'port': 6053, + }) + + assert result['type'] == 'form' + assert result['step_id'] == 'user' + assert result['errors'] == { + 'base': 'connection_error' + } + assert len(mock_client.start.mock_calls) == 1 + assert len(mock_client.connect.mock_calls) == 1 + assert len(mock_client.device_info.mock_calls) == 1 + assert len(mock_client.stop.mock_calls) == 1 + + +async def test_user_with_password(hass, mock_client): + """Test user step with password.""" + flow = config_flow.EsphomeFlowHandler() + flow.hass = hass + await flow.async_step_user(user_input=None) + + mock_client.device_info.return_value = mock_coro( + MockDeviceInfo(True, "test")) + + result = await flow.async_step_user(user_input={ + 'host': '127.0.0.1', + 'port': 6053, + }) + + assert result['type'] == 'form' + assert result['step_id'] == 'authenticate' + + result = await flow.async_step_authenticate(user_input={ + 'password': 'password1' + }) + + assert result['type'] == 'create_entry' + assert result['data'] == { + 'host': '127.0.0.1', + 'port': 6053, + 'password': 'password1' + } + assert mock_client.password == 'password1' + + +async def test_user_invalid_password(hass, mock_api_connection_error, + mock_client): + """Test user step with invalid password.""" + flow = config_flow.EsphomeFlowHandler() + flow.hass = hass + await flow.async_step_user(user_input=None) + + mock_client.device_info.return_value = mock_coro( + MockDeviceInfo(True, "test")) + mock_client.login.side_effect = mock_api_connection_error + + await flow.async_step_user(user_input={ + 'host': '127.0.0.1', + 'port': 6053, + }) + result = await flow.async_step_authenticate(user_input={ + 'password': 'invalid' + }) + + assert result['type'] == 'form' + assert result['step_id'] == 'authenticate' + assert result['errors'] == { + 'base': 'invalid_password' + } + + +async def test_user_login_connection_error(hass, mock_api_connection_error, + mock_client): + """Test user step with connection error during login phase.""" + flow = config_flow.EsphomeFlowHandler() + flow.hass = hass + await flow.async_step_user(user_input=None) + + mock_client.device_info.return_value = mock_coro( + MockDeviceInfo(True, "test")) + + await flow.async_step_user(user_input={ + 'host': '127.0.0.1', + 'port': 6053, + }) + + mock_client.connect.side_effect = mock_api_connection_error + result = await flow.async_step_authenticate(user_input={ + 'password': 'invalid' + }) + + assert result['type'] == 'form' + assert result['step_id'] == 'authenticate' + assert result['errors'] == { + 'base': 'connection_error' + }