Simplify esphome (#22868)

* Add ESPHome climate support

* Adjust line length

* Update .coveragerc

* Update climate.py

* Simplify esphome integration

* Undo change

* Update cover.py
pull/23151/head
Otto Winter 2019-04-16 22:48:46 +02:00 committed by Paulus Schoutsen
parent 10e8f4f70a
commit 3186109172
9 changed files with 113 additions and 135 deletions

View File

@ -1,6 +1,7 @@
"""Support for esphome devices."""
import asyncio
import logging
import math
from typing import Any, Dict, List, Optional, TYPE_CHECKING, Callable, Tuple
import attr
@ -520,6 +521,51 @@ async def platform_async_setup_entry(hass: HomeAssistantType,
)
def esphome_state_property(func):
"""Wrap a state property of an esphome entity.
This checks if the state object in the entity is set, and
prevents writing NAN values to the Home Assistant state machine.
"""
@property
def _wrapper(self):
if self._state is None:
return None
val = func(self)
if isinstance(val, float) and math.isnan(val):
# Home Assistant doesn't use NAN values in state machine
# (not JSON serializable)
return None
return val
return _wrapper
class EsphomeEnumMapper:
"""Helper class to convert between hass and esphome enum values."""
def __init__(self, func: Callable[[], Dict[int, str]]):
"""Construct a EsphomeEnumMapper."""
self._func = func
def from_esphome(self, value: int) -> str:
"""Convert from an esphome int representation to a hass string."""
return self._func()[value]
def from_hass(self, value: str) -> int:
"""Convert from a hass string to a esphome int representation."""
inverse = {v: k for k, v in self._func().items()}
return inverse[value]
def esphome_map_enum(func: Callable[[], Dict[int, str]]):
"""Map esphome int enum values to hass string constants.
This class has to be used as a decorator. This ensures the aioesphomeapi
import is only happening at runtime.
"""
return EsphomeEnumMapper(func)
class EsphomeEntity(Entity):
"""Define a generic esphome entity."""
@ -555,11 +601,11 @@ class EsphomeEntity(Entity):
self.async_schedule_update_ha_state)
)
async def _on_update(self):
async def _on_update(self) -> None:
"""Update the entity state when state or static info changed."""
self.async_schedule_update_ha_state()
async def async_will_remove_from_hass(self):
async def async_will_remove_from_hass(self) -> None:
"""Unregister callbacks."""
for remove_callback in self._remove_callbacks:
remove_callback()
@ -608,7 +654,7 @@ class EsphomeEntity(Entity):
return self._static_info.unique_id
@property
def device_info(self):
def device_info(self) -> Dict[str, Any]:
"""Return device registry information for this entity."""
return {
'connections': {(dr.CONNECTION_NETWORK_MAC,

View File

@ -38,7 +38,7 @@ class EsphomeBinarySensor(EsphomeEntity, BinarySensorDevice):
return super()._state
@property
def is_on(self):
def is_on(self) -> Optional[bool]:
"""Return true if the binary sensor is on."""
if self._static_info.is_status_binary_sensor:
# Status binary sensors indicated connected state.
@ -49,12 +49,12 @@ class EsphomeBinarySensor(EsphomeEntity, BinarySensorDevice):
return self._state.state
@property
def device_class(self):
def device_class(self) -> str:
"""Return the class of this device, from component DEVICE_CLASSES."""
return self._static_info.device_class
@property
def available(self):
def available(self) -> bool:
"""Return True if entity is available."""
if self._static_info.is_status_binary_sensor:
return True

View File

@ -47,7 +47,7 @@ class EsphomeCamera(Camera, EsphomeEntity):
def _state(self) -> Optional['CameraState']:
return super()._state
async def _on_update(self):
async def _on_update(self) -> None:
"""Notify listeners of new image when update arrives."""
await super()._on_update()
async with self._image_cond:

View File

@ -1,6 +1,5 @@
"""Support for ESPHome climate devices."""
import logging
import math
from typing import TYPE_CHECKING, List, Optional
from homeassistant.components.climate import ClimateDevice
@ -13,7 +12,8 @@ from homeassistant.const import (
ATTR_TEMPERATURE, PRECISION_HALVES, PRECISION_TENTHS, PRECISION_WHOLE,
STATE_OFF, TEMP_CELSIUS)
from . import EsphomeEntity, platform_async_setup_entry
from . import EsphomeEntity, platform_async_setup_entry, \
esphome_state_property, esphome_map_enum
if TYPE_CHECKING:
# pylint: disable=unused-import
@ -35,18 +35,8 @@ async def async_setup_entry(hass, entry, async_add_entities):
)
def _ha_climate_mode_to_esphome(mode: str) -> 'ClimateMode':
# pylint: disable=redefined-outer-name
from aioesphomeapi import ClimateMode # noqa
return {
STATE_OFF: ClimateMode.OFF,
STATE_AUTO: ClimateMode.AUTO,
STATE_COOL: ClimateMode.COOL,
STATE_HEAT: ClimateMode.HEAT,
}[mode]
def _esphome_climate_mode_to_ha(mode: 'ClimateMode') -> str:
@esphome_map_enum
def _climate_modes():
# pylint: disable=redefined-outer-name
from aioesphomeapi import ClimateMode # noqa
return {
@ -54,7 +44,7 @@ def _esphome_climate_mode_to_ha(mode: 'ClimateMode') -> str:
ClimateMode.AUTO: STATE_AUTO,
ClimateMode.COOL: STATE_COOL,
ClimateMode.HEAT: STATE_HEAT,
}[mode]
}
class EsphomeClimateDevice(EsphomeEntity, ClimateDevice):
@ -87,12 +77,12 @@ class EsphomeClimateDevice(EsphomeEntity, ClimateDevice):
def operation_list(self) -> List[str]:
"""Return the list of available operation modes."""
return [
_esphome_climate_mode_to_ha(mode)
_climate_modes.from_esphome(mode)
for mode in self._static_info.supported_modes
]
@property
def target_temperature_step(self):
def target_temperature_step(self) -> float:
"""Return the supported step of target temperature."""
# Round to one digit because of floating point math
return round(self._static_info.visual_temperature_step, 1)
@ -120,61 +110,41 @@ class EsphomeClimateDevice(EsphomeEntity, ClimateDevice):
features |= SUPPORT_AWAY_MODE
return features
@property
@esphome_state_property
def current_operation(self) -> Optional[str]:
"""Return current operation ie. heat, cool, idle."""
if self._state is None:
return None
return _esphome_climate_mode_to_ha(self._state.mode)
return _climate_modes.from_esphome(self._state.mode)
@property
@esphome_state_property
def current_temperature(self) -> Optional[float]:
"""Return the current temperature."""
if self._state is None:
return None
if math.isnan(self._state.current_temperature):
return None
return self._state.current_temperature
@property
@esphome_state_property
def target_temperature(self) -> Optional[float]:
"""Return the temperature we try to reach."""
if self._state is None:
return None
if math.isnan(self._state.target_temperature):
return None
return self._state.target_temperature
@property
def target_temperature_low(self):
@esphome_state_property
def target_temperature_low(self) -> Optional[float]:
"""Return the lowbound target temperature we try to reach."""
if self._state is None:
return None
if math.isnan(self._state.target_temperature_low):
return None
return self._state.target_temperature_low
@property
def target_temperature_high(self):
@esphome_state_property
def target_temperature_high(self) -> Optional[float]:
"""Return the highbound target temperature we try to reach."""
if self._state is None:
return None
if math.isnan(self._state.target_temperature_high):
return None
return self._state.target_temperature_high
@property
def is_away_mode_on(self):
@esphome_state_property
def is_away_mode_on(self) -> Optional[bool]:
"""Return true if away mode is on."""
if self._state is None:
return None
return self._state.away
async def async_set_temperature(self, **kwargs):
async def async_set_temperature(self, **kwargs) -> None:
"""Set new target temperature (and operation mode if set)."""
data = {'key': self._static_info.key}
if ATTR_OPERATION_MODE in kwargs:
data['mode'] = _ha_climate_mode_to_esphome(
data['mode'] = _climate_modes.from_hass(
kwargs[ATTR_OPERATION_MODE])
if ATTR_TEMPERATURE in kwargs:
data['target_temperature'] = kwargs[ATTR_TEMPERATURE]
@ -184,14 +154,14 @@ class EsphomeClimateDevice(EsphomeEntity, ClimateDevice):
data['target_temperature_high'] = kwargs[ATTR_TARGET_TEMP_HIGH]
await self._client.climate_command(**data)
async def async_set_operation_mode(self, operation_mode):
async def async_set_operation_mode(self, operation_mode) -> None:
"""Set new target operation mode."""
await self._client.climate_command(
key=self._static_info.key,
mode=_ha_climate_mode_to_esphome(operation_mode),
mode=_climate_modes.from_hass(operation_mode),
)
async def async_turn_away_mode_on(self):
async def async_turn_away_mode_on(self) -> None:
"""Turn away mode on."""
await self._client.climate_command(key=self._static_info.key,
away=True)

View File

@ -9,7 +9,7 @@ from homeassistant.components.cover import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.typing import HomeAssistantType
from . import EsphomeEntity, platform_async_setup_entry
from . import EsphomeEntity, platform_async_setup_entry, esphome_state_property
if TYPE_CHECKING:
# pylint: disable=unused-import
@ -51,7 +51,7 @@ class EsphomeCover(EsphomeEntity, CoverDevice):
return flags
@property
def device_class(self):
def device_class(self) -> str:
"""Return the class of this device, from component DEVICE_CLASSES."""
return self._static_info.device_class
@ -64,41 +64,35 @@ class EsphomeCover(EsphomeEntity, CoverDevice):
def _state(self) -> Optional['CoverState']:
return super()._state
@property
@esphome_state_property
def is_closed(self) -> Optional[bool]:
"""Return if the cover is closed or not."""
if self._state is None:
return None
# Check closed state with api version due to a protocol change
return self._state.is_closed(self._client.api_version)
@property
def is_opening(self):
@esphome_state_property
def is_opening(self) -> bool:
"""Return if the cover is opening or not."""
from aioesphomeapi import CoverOperation
if self._state is None:
return None
return self._state.current_operation == CoverOperation.IS_OPENING
@property
def is_closing(self):
@esphome_state_property
def is_closing(self) -> bool:
"""Return if the cover is closing or not."""
from aioesphomeapi import CoverOperation
if self._state is None:
return None
return self._state.current_operation == CoverOperation.IS_CLOSING
@property
@esphome_state_property
def current_cover_position(self) -> Optional[float]:
"""Return current position of cover. 0 is closed, 100 is open."""
if self._state is None or not self._static_info.supports_position:
if not self._static_info.supports_position:
return None
return self._state.position * 100.0
@property
@esphome_state_property
def current_cover_tilt_position(self) -> Optional[float]:
"""Return current position of cover tilt. 0 is closed, 100 is open."""
if self._state is None or not self._static_info.supports_tilt:
if not self._static_info.supports_tilt:
return None
return self._state.tilt * 100.0

View File

@ -8,7 +8,8 @@ from homeassistant.components.fan import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.typing import HomeAssistantType
from . import EsphomeEntity, platform_async_setup_entry
from . import EsphomeEntity, platform_async_setup_entry, \
esphome_state_property, esphome_map_enum
if TYPE_CHECKING:
# pylint: disable=unused-import
@ -31,24 +32,15 @@ async def async_setup_entry(hass: HomeAssistantType,
)
def _ha_fan_speed_to_esphome(speed: str) -> 'FanSpeed':
# pylint: disable=redefined-outer-name
from aioesphomeapi import FanSpeed # noqa
return {
SPEED_LOW: FanSpeed.LOW,
SPEED_MEDIUM: FanSpeed.MEDIUM,
SPEED_HIGH: FanSpeed.HIGH,
}[speed]
def _esphome_fan_speed_to_ha(speed: 'FanSpeed') -> str:
@esphome_map_enum
def _fan_speeds():
# pylint: disable=redefined-outer-name
from aioesphomeapi import FanSpeed # noqa
return {
FanSpeed.LOW: SPEED_LOW,
FanSpeed.MEDIUM: SPEED_MEDIUM,
FanSpeed.HIGH: SPEED_HIGH,
}[speed]
}
class EsphomeFan(EsphomeEntity, FanEntity):
@ -69,7 +61,7 @@ class EsphomeFan(EsphomeEntity, FanEntity):
return
await self._client.fan_command(
self._static_info.key, speed=_ha_fan_speed_to_esphome(speed))
self._static_info.key, speed=_fan_speeds.from_hass(speed))
async def async_turn_on(self, speed: Optional[str] = None,
**kwargs) -> None:
@ -79,7 +71,7 @@ class EsphomeFan(EsphomeEntity, FanEntity):
return
data = {'key': self._static_info.key, 'state': True}
if speed is not None:
data['speed'] = _ha_fan_speed_to_esphome(speed)
data['speed'] = _fan_speeds.from_hass(speed)
await self._client.fan_command(**data)
# pylint: disable=arguments-differ
@ -87,32 +79,26 @@ class EsphomeFan(EsphomeEntity, FanEntity):
"""Turn off the fan."""
await self._client.fan_command(key=self._static_info.key, state=False)
async def async_oscillate(self, oscillating: bool):
async def async_oscillate(self, oscillating: bool) -> None:
"""Oscillate the fan."""
await self._client.fan_command(key=self._static_info.key,
oscillating=oscillating)
@property
@esphome_state_property
def is_on(self) -> Optional[bool]:
"""Return true if the entity is on."""
if self._state is None:
return None
return self._state.state
@property
@esphome_state_property
def speed(self) -> Optional[str]:
"""Return the current speed."""
if self._state is None:
return None
if not self._static_info.supports_speed:
return None
return _esphome_fan_speed_to_ha(self._state.speed)
return _fan_speeds.from_esphome(self._state.speed)
@property
@esphome_state_property
def oscillating(self) -> None:
"""Return the oscillation state."""
if self._state is None:
return None
if not self._static_info.supports_oscillation:
return None
return self._state.oscillating

View File

@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.typing import HomeAssistantType
import homeassistant.util.color as color_util
from . import EsphomeEntity, platform_async_setup_entry
from . import EsphomeEntity, platform_async_setup_entry, esphome_state_property
if TYPE_CHECKING:
# pylint: disable=unused-import
@ -51,11 +51,9 @@ class EsphomeLight(EsphomeEntity, Light):
def _state(self) -> Optional['LightState']:
return super()._state
@property
@esphome_state_property
def is_on(self) -> Optional[bool]:
"""Return true if the switch is on."""
if self._state is None:
return None
return self._state.state
async def async_turn_on(self, **kwargs) -> None:
@ -88,42 +86,32 @@ class EsphomeLight(EsphomeEntity, Light):
data['transition_length'] = kwargs[ATTR_TRANSITION]
await self._client.light_command(**data)
@property
@esphome_state_property
def brightness(self) -> Optional[int]:
"""Return the brightness of this light between 0..255."""
if self._state is None:
return None
return round(self._state.brightness * 255)
@property
@esphome_state_property
def hs_color(self) -> Optional[Tuple[float, float]]:
"""Return the hue and saturation color value [float, float]."""
if self._state is None:
return None
return color_util.color_RGB_to_hs(
self._state.red * 255,
self._state.green * 255,
self._state.blue * 255)
@property
@esphome_state_property
def color_temp(self) -> Optional[float]:
"""Return the CT color value in mireds."""
if self._state is None:
return None
return self._state.color_temperature
@property
@esphome_state_property
def white_value(self) -> Optional[int]:
"""Return the white value of this light between 0..255."""
if self._state is None:
return None
return round(self._state.white * 255)
@property
@esphome_state_property
def effect(self) -> Optional[str]:
"""Return the current effect."""
if self._state is None:
return None
return self._state.effect
@property

View File

@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, Optional
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.typing import HomeAssistantType
from . import EsphomeEntity, platform_async_setup_entry
from . import EsphomeEntity, platform_async_setup_entry, esphome_state_property
if TYPE_CHECKING:
# pylint: disable=unused-import
@ -53,11 +53,9 @@ class EsphomeSensor(EsphomeEntity):
"""Return the icon."""
return self._static_info.icon
@property
@esphome_state_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(
@ -85,9 +83,7 @@ class EsphomeTextSensor(EsphomeEntity):
"""Return the icon."""
return self._static_info.icon
@property
@esphome_state_property
def state(self) -> Optional[str]:
"""Return the state of the entity."""
if self._state is None:
return None
return self._state.state

View File

@ -6,7 +6,7 @@ from homeassistant.components.switch import SwitchDevice
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.typing import HomeAssistantType
from . import EsphomeEntity, platform_async_setup_entry
from . import EsphomeEntity, platform_async_setup_entry, esphome_state_property
if TYPE_CHECKING:
# pylint: disable=unused-import
@ -50,17 +50,15 @@ class EsphomeSwitch(EsphomeEntity, SwitchDevice):
"""Return true if we do optimistic updates."""
return self._static_info.assumed_state
@property
def is_on(self):
@esphome_state_property
def is_on(self) -> Optional[bool]:
"""Return true if the switch is on."""
if self._state is None:
return None
return self._state.state
async def async_turn_on(self, **kwargs):
async def async_turn_on(self, **kwargs) -> None:
"""Turn the entity on."""
await self._client.switch_command(self._static_info.key, True)
async def async_turn_off(self, **kwargs):
async def async_turn_off(self, **kwargs) -> None:
"""Turn the entity off."""
await self._client.switch_command(self._static_info.key, False)