ESPHome Climate add preset, custom preset, custom fan mode (#52133)

* ESPHome Climate add preset, custom preset, custom fan mode

* Fix copy paste error

* Bump aioesphomeapi to 3.0.0

* Bump aioesphomeapi to 3.0.1

* Persist api version to prevent exception for offline devices
pull/52248/head
Otto Winter 2021-06-28 13:43:45 +02:00 committed by GitHub
parent 594bcbcf7a
commit 3e1d32f4e0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 72 additions and 21 deletions

View File

@ -10,6 +10,7 @@ from typing import Generic, TypeVar
from aioesphomeapi import ( from aioesphomeapi import (
APIClient, APIClient,
APIConnectionError, APIConnectionError,
APIVersion,
DeviceInfo as EsphomeDeviceInfo, DeviceInfo as EsphomeDeviceInfo,
EntityInfo, EntityInfo,
EntityState, EntityState,
@ -206,6 +207,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
nonlocal device_id nonlocal device_id
try: try:
entry_data.device_info = await cli.device_info() entry_data.device_info = await cli.device_info()
entry_data.api_version = cli.api_version
entry_data.available = True entry_data.available = True
device_id = await _async_setup_device_registry( device_id = await _async_setup_device_registry(
hass, entry, entry_data.device_info hass, entry, entry_data.device_info
@ -785,6 +787,10 @@ class EsphomeBaseEntity(Entity):
def _entry_data(self) -> RuntimeEntryData: def _entry_data(self) -> RuntimeEntryData:
return self.hass.data[DOMAIN][self._entry_id] return self.hass.data[DOMAIN][self._entry_id]
@property
def _api_version(self) -> APIVersion:
return self._entry_data.api_version
@property @property
def _static_info(self) -> EntityInfo: def _static_info(self) -> EntityInfo:
# Check if value is in info database. Use a single lookup. # Check if value is in info database. Use a single lookup.

View File

@ -6,6 +6,7 @@ from aioesphomeapi import (
ClimateFanMode, ClimateFanMode,
ClimateInfo, ClimateInfo,
ClimateMode, ClimateMode,
ClimatePreset,
ClimateState, ClimateState,
ClimateSwingMode, ClimateSwingMode,
) )
@ -30,14 +31,21 @@ from homeassistant.components.climate.const import (
FAN_MIDDLE, FAN_MIDDLE,
FAN_OFF, FAN_OFF,
FAN_ON, FAN_ON,
HVAC_MODE_AUTO,
HVAC_MODE_COOL, HVAC_MODE_COOL,
HVAC_MODE_DRY, HVAC_MODE_DRY,
HVAC_MODE_FAN_ONLY, HVAC_MODE_FAN_ONLY,
HVAC_MODE_HEAT, HVAC_MODE_HEAT,
HVAC_MODE_HEAT_COOL, HVAC_MODE_HEAT_COOL,
HVAC_MODE_OFF, HVAC_MODE_OFF,
PRESET_ACTIVITY,
PRESET_AWAY, PRESET_AWAY,
PRESET_BOOST,
PRESET_COMFORT,
PRESET_ECO,
PRESET_HOME, PRESET_HOME,
PRESET_NONE,
PRESET_SLEEP,
SUPPORT_FAN_MODE, SUPPORT_FAN_MODE,
SUPPORT_PRESET_MODE, SUPPORT_PRESET_MODE,
SUPPORT_SWING_MODE, SUPPORT_SWING_MODE,
@ -80,11 +88,12 @@ async def async_setup_entry(hass, entry, async_add_entities):
_CLIMATE_MODES: EsphomeEnumMapper[ClimateMode] = EsphomeEnumMapper( _CLIMATE_MODES: EsphomeEnumMapper[ClimateMode] = EsphomeEnumMapper(
{ {
ClimateMode.OFF: HVAC_MODE_OFF, ClimateMode.OFF: HVAC_MODE_OFF,
ClimateMode.AUTO: HVAC_MODE_HEAT_COOL, ClimateMode.HEAT_COOL: HVAC_MODE_HEAT_COOL,
ClimateMode.COOL: HVAC_MODE_COOL, ClimateMode.COOL: HVAC_MODE_COOL,
ClimateMode.HEAT: HVAC_MODE_HEAT, ClimateMode.HEAT: HVAC_MODE_HEAT,
ClimateMode.FAN_ONLY: HVAC_MODE_FAN_ONLY, ClimateMode.FAN_ONLY: HVAC_MODE_FAN_ONLY,
ClimateMode.DRY: HVAC_MODE_DRY, ClimateMode.DRY: HVAC_MODE_DRY,
ClimateMode.AUTO: HVAC_MODE_AUTO,
} }
) )
_CLIMATE_ACTIONS: EsphomeEnumMapper[ClimateAction] = EsphomeEnumMapper( _CLIMATE_ACTIONS: EsphomeEnumMapper[ClimateAction] = EsphomeEnumMapper(
@ -118,6 +127,18 @@ _SWING_MODES: EsphomeEnumMapper[ClimateSwingMode] = EsphomeEnumMapper(
ClimateSwingMode.HORIZONTAL: SWING_HORIZONTAL, ClimateSwingMode.HORIZONTAL: SWING_HORIZONTAL,
} }
) )
_PRESETS: EsphomeEnumMapper[ClimatePreset] = EsphomeEnumMapper(
{
ClimatePreset.NONE: PRESET_NONE,
ClimatePreset.HOME: PRESET_HOME,
ClimatePreset.AWAY: PRESET_AWAY,
ClimatePreset.BOOST: PRESET_BOOST,
ClimatePreset.COMFORT: PRESET_COMFORT,
ClimatePreset.ECO: PRESET_ECO,
ClimatePreset.SLEEP: PRESET_SLEEP,
ClimatePreset.ACTIVITY: PRESET_ACTIVITY,
}
)
class EsphomeClimateEntity(EsphomeEntity, ClimateEntity): class EsphomeClimateEntity(EsphomeEntity, ClimateEntity):
@ -155,17 +176,20 @@ class EsphomeClimateEntity(EsphomeEntity, ClimateEntity):
] ]
@property @property
def fan_modes(self): def fan_modes(self) -> list[str]:
"""Return the list of available fan modes.""" """Return the list of available fan modes."""
return [ return [
_FAN_MODES.from_esphome(mode) _FAN_MODES.from_esphome(mode)
for mode in self._static_info.supported_fan_modes for mode in self._static_info.supported_fan_modes
] ] + self._static_info.supported_custom_fan_modes
@property @property
def preset_modes(self): def preset_modes(self) -> list[str]:
"""Return preset modes.""" """Return preset modes."""
return [PRESET_AWAY, PRESET_HOME] if self._static_info.supports_away else [] return [
_PRESETS.from_esphome(preset)
for preset in self._static_info.supported_presets_compat(self._api_version)
] + self._static_info.supported_custom_presets
@property @property
def swing_modes(self): def swing_modes(self):
@ -199,7 +223,7 @@ class EsphomeClimateEntity(EsphomeEntity, ClimateEntity):
features |= SUPPORT_TARGET_TEMPERATURE_RANGE features |= SUPPORT_TARGET_TEMPERATURE_RANGE
else: else:
features |= SUPPORT_TARGET_TEMPERATURE features |= SUPPORT_TARGET_TEMPERATURE
if self._static_info.supports_away: if self.preset_modes:
features |= SUPPORT_PRESET_MODE features |= SUPPORT_PRESET_MODE
if self._static_info.supported_fan_modes: if self._static_info.supported_fan_modes:
features |= SUPPORT_FAN_MODE features |= SUPPORT_FAN_MODE
@ -226,12 +250,16 @@ class EsphomeClimateEntity(EsphomeEntity, ClimateEntity):
@esphome_state_property @esphome_state_property
def fan_mode(self) -> str | None: def fan_mode(self) -> str | None:
"""Return current fan setting.""" """Return current fan setting."""
return _FAN_MODES.from_esphome(self._state.fan_mode) return self._state.custom_fan_mode or _FAN_MODES.from_esphome(
self._state.fan_mode
)
@esphome_state_property @esphome_state_property
def preset_mode(self): def preset_mode(self) -> str | None:
"""Return current preset mode.""" """Return current preset mode."""
return PRESET_AWAY if self._state.away else PRESET_HOME return self._state.custom_preset or _PRESETS.from_esphome(
self._state.preset_compat(self._api_version)
)
@esphome_state_property @esphome_state_property
def swing_mode(self) -> str | None: def swing_mode(self) -> str | None:
@ -277,16 +305,23 @@ class EsphomeClimateEntity(EsphomeEntity, ClimateEntity):
key=self._static_info.key, mode=_CLIMATE_MODES.from_hass(hvac_mode) key=self._static_info.key, mode=_CLIMATE_MODES.from_hass(hvac_mode)
) )
async def async_set_preset_mode(self, preset_mode): async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set preset mode.""" """Set preset mode."""
away = preset_mode == PRESET_AWAY kwargs = {}
await self._client.climate_command(key=self._static_info.key, away=away) if preset_mode in self._static_info.supported_custom_presets:
kwargs["custom_preset"] = preset_mode
else:
kwargs["preset"] = _PRESETS.from_hass(preset_mode)
await self._client.climate_command(key=self._static_info.key, **kwargs)
async def async_set_fan_mode(self, fan_mode: str) -> None: async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set new fan mode.""" """Set new fan mode."""
await self._client.climate_command( kwargs = {}
key=self._static_info.key, fan_mode=_FAN_MODES.from_hass(fan_mode) if fan_mode in self._static_info.supported_custom_fan_modes:
) kwargs["custom_fan_mode"] = fan_mode
else:
kwargs["fan_mode"] = _FAN_MODES.from_hass(fan_mode)
await self._client.climate_command(key=self._static_info.key, **kwargs)
async def async_set_swing_mode(self, swing_mode: str) -> None: async def async_set_swing_mode(self, swing_mode: str) -> None:
"""Set new swing mode.""" """Set new swing mode."""

View File

@ -74,7 +74,7 @@ class EsphomeCover(EsphomeEntity, CoverEntity):
def is_closed(self) -> bool | None: def is_closed(self) -> bool | None:
"""Return if the cover is closed or not.""" """Return if the cover is closed or not."""
# Check closed state with api version due to a protocol change # Check closed state with api version due to a protocol change
return self._state.is_closed(self._client.api_version) return self._state.is_closed(self._api_version)
@esphome_state_property @esphome_state_property
def is_opening(self) -> bool: def is_opening(self) -> bool:

View File

@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Any, Callable
from aioesphomeapi import ( from aioesphomeapi import (
COMPONENT_TYPE_TO_INFO, COMPONENT_TYPE_TO_INFO,
APIVersion,
BinarySensorInfo, BinarySensorInfo,
CameraInfo, CameraInfo,
ClimateInfo, ClimateInfo,
@ -67,6 +68,7 @@ class RuntimeEntryData:
services: dict[int, UserService] = attr.ib(factory=dict) services: dict[int, UserService] = attr.ib(factory=dict)
available: bool = attr.ib(default=False) available: bool = attr.ib(default=False)
device_info: DeviceInfo | None = attr.ib(default=None) device_info: DeviceInfo | None = attr.ib(default=None)
api_version: APIVersion = attr.ib(factory=APIVersion)
cleanup_callbacks: list[Callable[[], None]] = attr.ib(factory=list) cleanup_callbacks: list[Callable[[], None]] = attr.ib(factory=list)
disconnect_callbacks: list[Callable[[], None]] = attr.ib(factory=list) disconnect_callbacks: list[Callable[[], None]] = attr.ib(factory=list)
loaded_platforms: set[str] = attr.ib(factory=set) loaded_platforms: set[str] = attr.ib(factory=set)
@ -141,6 +143,10 @@ class RuntimeEntryData:
self.device_info = _attr_obj_from_dict( self.device_info = _attr_obj_from_dict(
DeviceInfo, **restored.pop("device_info") DeviceInfo, **restored.pop("device_info")
) )
self.api_version = _attr_obj_from_dict(
APIVersion, **restored.pop("api_version", {})
)
infos = [] infos = []
for comp_type, restored_infos in restored.items(): for comp_type, restored_infos in restored.items():
if comp_type not in COMPONENT_TYPE_TO_INFO: if comp_type not in COMPONENT_TYPE_TO_INFO:
@ -155,7 +161,11 @@ class RuntimeEntryData:
async def async_save_to_store(self) -> None: async def async_save_to_store(self) -> None:
"""Generate dynamic data to store and save it to the filesystem.""" """Generate dynamic data to store and save it to the filesystem."""
store_data = {"device_info": attr.asdict(self.device_info), "services": []} store_data = {
"device_info": attr.asdict(self.device_info),
"services": [],
"api_version": attr.asdict(self.api_version),
}
for comp_type, infos in self.info.items(): for comp_type, infos in self.info.items():
store_data[comp_type] = [attr.asdict(info) for info in infos.values()] store_data[comp_type] = [attr.asdict(info) for info in infos.values()]

View File

@ -68,7 +68,7 @@ class EsphomeFan(EsphomeEntity, FanEntity):
@property @property
def _supports_speed_levels(self) -> bool: def _supports_speed_levels(self) -> bool:
api_version = self._client.api_version api_version = self._api_version
return api_version.major == 1 and api_version.minor > 3 return api_version.major == 1 and api_version.minor > 3
async def async_set_percentage(self, percentage: int) -> None: async def async_set_percentage(self, percentage: int) -> None:

View File

@ -3,7 +3,7 @@
"name": "ESPHome", "name": "ESPHome",
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/esphome", "documentation": "https://www.home-assistant.io/integrations/esphome",
"requirements": ["aioesphomeapi==2.9.0"], "requirements": ["aioesphomeapi==3.0.1"],
"zeroconf": ["_esphomelib._tcp.local."], "zeroconf": ["_esphomelib._tcp.local."],
"codeowners": ["@OttoWinter", "@jesserockz"], "codeowners": ["@OttoWinter", "@jesserockz"],
"after_dependencies": ["zeroconf", "tag"], "after_dependencies": ["zeroconf", "tag"],

View File

@ -160,7 +160,7 @@ aioeafm==0.1.2
aioemonitor==1.0.5 aioemonitor==1.0.5
# homeassistant.components.esphome # homeassistant.components.esphome
aioesphomeapi==2.9.0 aioesphomeapi==3.0.1
# homeassistant.components.flo # homeassistant.components.flo
aioflo==0.4.1 aioflo==0.4.1

View File

@ -100,7 +100,7 @@ aioeafm==0.1.2
aioemonitor==1.0.5 aioemonitor==1.0.5
# homeassistant.components.esphome # homeassistant.components.esphome
aioesphomeapi==2.9.0 aioesphomeapi==3.0.1
# homeassistant.components.flo # homeassistant.components.flo
aioflo==0.4.1 aioflo==0.4.1