Add native ESPHome API component (#19334)
* Create esphomelib component * Update requirements * Remove python 2 string literals * MVP * Remove config flow * Remove config flow translations * Use dispatcher more * Use data classes * Type Hints * Cleanup on remove * Use helper and cleanup * Fix HA stop listener cleanup * Add config flow * Fix SyntaxError for Py3.5 * Update * Lint * Re-do tests * ✨ Rename to ESPHome * Better error message for resolve errors * Fix tests when aioesphomeapi not installed * Refactor mock * Update requirements * Add strings.json * 🍵 100% config flow test coverage
This commit is contained in:
parent
f9c02889b2
commit
a08bab7b18
11 changed files with 888 additions and 0 deletions
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
30
homeassistant/components/esphome/.translations/en.json
Normal file
30
homeassistant/components/esphome/.translations/en.json
Normal file
|
@ -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"
|
||||
}
|
||||
}
|
409
homeassistant/components/esphome/__init__.py
Normal file
409
homeassistant/components/esphome/__init__.py
Normal file
|
@ -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
|
127
homeassistant/components/esphome/config_flow.py
Normal file
127
homeassistant/components/esphome/config_flow.py
Normal file
|
@ -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
|
30
homeassistant/components/esphome/strings.json
Normal file
30
homeassistant/components/esphome/strings.json
Normal file
|
@ -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"
|
||||
}
|
||||
}
|
62
homeassistant/components/sensor/esphome.py
Normal file
62
homeassistant/components/sensor/esphome.py
Normal file
|
@ -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
|
|
@ -139,6 +139,7 @@ FLOWS = [
|
|||
'daikin',
|
||||
'deconz',
|
||||
'dialogflow',
|
||||
'esphome',
|
||||
'hangouts',
|
||||
'homematicip_cloud',
|
||||
'hue',
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
1
tests/components/esphome/__init__.py
Normal file
1
tests/components/esphome/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
"""Tests for esphome."""
|
220
tests/components/esphome/test_config_flow.py
Normal file
220
tests/components/esphome/test_config_flow.py
Normal file
|
@ -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'
|
||||
}
|
Loading…
Add table
Reference in a new issue