Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 49 additions & 3 deletions homeassistant/components/esphome/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -520,6 +521,51 @@ def async_entity_state(state: 'EntityState'):
)


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."""

Expand Down Expand Up @@ -555,11 +601,11 @@ async def async_added_to_hass(self) -> None:
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()
Expand Down Expand Up @@ -608,7 +654,7 @@ def unique_id(self) -> Optional[str]:
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,
Expand Down
6 changes: 3 additions & 3 deletions homeassistant/components/esphome/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ def _state(self) -> Optional['BinarySensorState']:
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.
Expand All @@ -49,12 +49,12 @@ def is_on(self):
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
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/esphome/camera.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ def _static_info(self) -> 'CameraInfo':
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:
Expand Down
74 changes: 22 additions & 52 deletions homeassistant/components/esphome/climate.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -13,7 +12,8 @@
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
Expand All @@ -35,26 +35,16 @@ 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 {
ClimateMode.OFF: STATE_OFF,
ClimateMode.AUTO: STATE_AUTO,
ClimateMode.COOL: STATE_COOL,
ClimateMode.HEAT: STATE_HEAT,
}[mode]
}


class EsphomeClimateDevice(EsphomeEntity, ClimateDevice):
Expand Down Expand Up @@ -87,12 +77,12 @@ def temperature_unit(self) -> str:
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)
Expand Down Expand Up @@ -120,61 +110,41 @@ def supported_features(self) -> int:
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]
Expand All @@ -184,14 +154,14 @@ async def async_set_temperature(self, **kwargs):
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)
Expand Down
28 changes: 11 additions & 17 deletions homeassistant/components/esphome/cover.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
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
Expand Down Expand Up @@ -51,7 +51,7 @@ def supported_features(self) -> int:
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

Expand All @@ -64,41 +64,35 @@ def assumed_state(self) -> bool:
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

Expand Down
Loading