diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index 9b1e0691d13..121e210a0a0 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -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, diff --git a/homeassistant/components/esphome/binary_sensor.py b/homeassistant/components/esphome/binary_sensor.py index ff3fc259792..6a6f9bfac1c 100644 --- a/homeassistant/components/esphome/binary_sensor.py +++ b/homeassistant/components/esphome/binary_sensor.py @@ -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 diff --git a/homeassistant/components/esphome/camera.py b/homeassistant/components/esphome/camera.py index bb80ca72724..64e73dc8784 100644 --- a/homeassistant/components/esphome/camera.py +++ b/homeassistant/components/esphome/camera.py @@ -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: diff --git a/homeassistant/components/esphome/climate.py b/homeassistant/components/esphome/climate.py index e95f9e44633..184eb4b6270 100644 --- a/homeassistant/components/esphome/climate.py +++ b/homeassistant/components/esphome/climate.py @@ -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) diff --git a/homeassistant/components/esphome/cover.py b/homeassistant/components/esphome/cover.py index 5eb12aa86ec..a3ef15fa4c7 100644 --- a/homeassistant/components/esphome/cover.py +++ b/homeassistant/components/esphome/cover.py @@ -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 diff --git a/homeassistant/components/esphome/fan.py b/homeassistant/components/esphome/fan.py index 35938de2455..50cf04203f3 100644 --- a/homeassistant/components/esphome/fan.py +++ b/homeassistant/components/esphome/fan.py @@ -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 diff --git a/homeassistant/components/esphome/light.py b/homeassistant/components/esphome/light.py index 3d55713b123..6b4abafe62b 100644 --- a/homeassistant/components/esphome/light.py +++ b/homeassistant/components/esphome/light.py @@ -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 diff --git a/homeassistant/components/esphome/sensor.py b/homeassistant/components/esphome/sensor.py index d8ae91e9243..8d8fb938c68 100644 --- a/homeassistant/components/esphome/sensor.py +++ b/homeassistant/components/esphome/sensor.py @@ -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 diff --git a/homeassistant/components/esphome/switch.py b/homeassistant/components/esphome/switch.py index 41c5663537c..77994d0be58 100644 --- a/homeassistant/components/esphome/switch.py +++ b/homeassistant/components/esphome/switch.py @@ -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)