diff --git a/CODEOWNERS b/CODEOWNERS index cf747b9b69cc..b41b14d46fb2 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -356,8 +356,8 @@ build.json @home-assistant/supervisor /homeassistant/components/eq3btsmart/ @rytilahti /homeassistant/components/escea/ @lazdavila /tests/components/escea/ @lazdavila -/homeassistant/components/esphome/ @OttoWinter @jesserockz -/tests/components/esphome/ @OttoWinter @jesserockz +/homeassistant/components/esphome/ @OttoWinter @jesserockz @bdraco +/tests/components/esphome/ @OttoWinter @jesserockz @bdraco /homeassistant/components/eufylife_ble/ @bdr99 /tests/components/eufylife_ble/ @bdr99 /homeassistant/components/evil_genius_labs/ @balloob diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index d774a1fc6632..bfd023a99802 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -725,11 +725,12 @@ async def platform_async_setup_entry( # Then update the actual info entry_data.info[component_key] = new_infos - async_dispatcher_send( - hass, - entry_data.signal_component_static_info_updated(component_key), - new_infos, - ) + for key, new_info in new_infos.items(): + async_dispatcher_send( + hass, + entry_data.signal_component_key_static_info_updated(component_key, key), + new_info, + ) if add_entities: # Add entities to Home Assistant @@ -785,6 +786,8 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): _attr_should_poll = False _static_info: _InfoT + _state: _StateT + _has_state: bool def __init__( self, @@ -795,150 +798,117 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): ) -> None: """Initialize.""" self._entry_data = entry_data + self._on_entry_data_changed() self._component_key = component_key self._key = entity_info.key - self._static_info = cast(_InfoT, entity_info) self._state_type = state_type - if entry_data.device_info is not None and entry_data.device_info.friendly_name: - self._attr_has_entity_name = True + self._on_static_info_update(entity_info) + assert entry_data.device_info is not None + device_info = entry_data.device_info + self._device_info = device_info + self._attr_has_entity_name = bool(device_info.friendly_name) + self._attr_device_info = DeviceInfo( + connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)} + ) + self._entry_id = entry_data.entry_id async def async_added_to_hass(self) -> None: """Register callbacks.""" + entry_data = self._entry_data + hass = self.hass + component_key = self._component_key + key = self._key + self.async_on_remove( async_dispatcher_connect( - self.hass, - f"esphome_{self._entry_id}_remove_{self._component_key}_{self._key}", + hass, + f"esphome_{self._entry_id}_remove_{component_key}_{key}", functools.partial(self.async_remove, force_remove=True), ) ) - self.async_on_remove( async_dispatcher_connect( - self.hass, - self._entry_data.signal_device_updated, + hass, + entry_data.signal_device_updated, self._on_device_update, ) ) - self.async_on_remove( - self._entry_data.async_subscribe_state_update( - self._state_type, self._key, self._on_state_update + entry_data.async_subscribe_state_update( + self._state_type, key, self._on_state_update ) ) - self.async_on_remove( async_dispatcher_connect( - self.hass, - self._entry_data.signal_component_static_info_updated( - self._component_key - ), + hass, + entry_data.signal_component_key_static_info_updated(component_key, key), self._on_static_info_update, ) ) @callback - def _on_static_info_update(self, static_infos: dict[int, EntityInfo]) -> None: + def _on_static_info_update(self, static_info: EntityInfo) -> None: """Save the static info for this entity when it changes. This method can be overridden in child classes to know when the static info changes. """ - self._static_info = cast(_InfoT, static_infos[self._key]) + static_info = cast(_InfoT, static_info) + self._static_info = static_info + self._attr_unique_id = static_info.unique_id + self._attr_entity_registry_enabled_default = not static_info.disabled_by_default + self._attr_name = static_info.name + if entity_category := static_info.entity_category: + self._attr_entity_category = ENTITY_CATEGORIES.from_esphome(entity_category) + else: + self._attr_entity_category = None + if icon := static_info.icon: + self._attr_icon = cast(str, ICON_SCHEMA(icon)) + else: + self._attr_icon = None @callback def _on_state_update(self) -> None: - # Behavior can be changed in child classes + """Call when state changed. + + Behavior can be changed in child classes + """ + state = self._entry_data.state + key = self._key + state_type = self._state_type + has_state = key in state[state_type] + if has_state: + self._state = cast(_StateT, state[state_type][key]) + self._has_state = has_state self.async_write_ha_state() + @callback + def _on_entry_data_changed(self) -> None: + entry_data = self._entry_data + self._api_version = entry_data.api_version + self._client = entry_data.client + @callback def _on_device_update(self) -> None: - """Update the entity state when device info has changed.""" - if self._entry_data.available: - # Don't update the HA state yet when the device comes online. - # Only update the HA state when the full state arrives + """Call when device updates or entry data changes.""" + self._on_entry_data_changed() + if not self._entry_data.available: + # Only write state if the device has gone unavailable + # since _on_state_update will be called if the device + # is available when the full state arrives # through the next entity state packet. - return - self._on_state_update() - - @property - def _entry_id(self) -> str: - return self._entry_data.entry_id - - @property - def _api_version(self) -> APIVersion: - return self._entry_data.api_version - - @property - def _device_info(self) -> EsphomeDeviceInfo: - assert self._entry_data.device_info is not None - return self._entry_data.device_info - - @property - def _client(self) -> APIClient: - return self._entry_data.client - - @property - def _state(self) -> _StateT: - return cast(_StateT, self._entry_data.state[self._state_type][self._key]) - - @property - def _has_state(self) -> bool: - return self._key in self._entry_data.state[self._state_type] + self.async_write_ha_state() @property def available(self) -> bool: """Return if the entity is available.""" - device = self._device_info - - if device.has_deep_sleep: + if self._device_info.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) -> str | None: - """Return a unique id identifying the entity.""" - if not self._static_info.unique_id: - return None - return self._static_info.unique_id - - @property - def device_info(self) -> DeviceInfo: - """Return device registry information for this entity.""" - return DeviceInfo( - connections={(dr.CONNECTION_NETWORK_MAC, self._device_info.mac_address)} - ) - - @property - def name(self) -> str: - """Return the name of the entity.""" - return self._static_info.name - - @property - def icon(self) -> str | None: - """Return the icon.""" - if not self._static_info.icon: - return None - - return cast(str, ICON_SCHEMA(self._static_info.icon)) - - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added. - - This only applies when fist added to the entity registry. - """ - return not self._static_info.disabled_by_default - - @property - def entity_category(self) -> EntityCategory | None: - """Return the category of the entity, if any.""" - if not self._static_info.entity_category: - return None - return ENTITY_CATEGORIES.from_esphome(self._static_info.entity_category) - class EsphomeAssistEntity(Entity): """Define a base entity for Assist Pipeline entities.""" @@ -949,20 +919,14 @@ class EsphomeAssistEntity(Entity): def __init__(self, entry_data: RuntimeEntryData) -> None: """Initialize the binary sensor.""" self._entry_data: RuntimeEntryData = entry_data + assert entry_data.device_info is not None + device_info = entry_data.device_info + self._device_info = device_info self._attr_unique_id = ( - f"{self._device_info.mac_address}-{self.entity_description.key}" + f"{device_info.mac_address}-{self.entity_description.key}" ) - - @property - def _device_info(self) -> EsphomeDeviceInfo: - assert self._entry_data.device_info is not None - return self._entry_data.device_info - - @property - def device_info(self) -> DeviceInfo: - """Return device registry information for this entity.""" - return DeviceInfo( - connections={(dr.CONNECTION_NETWORK_MAC, self._device_info.mac_address)} + self._attr_device_info = DeviceInfo( + connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)} ) @callback diff --git a/homeassistant/components/esphome/binary_sensor.py b/homeassistant/components/esphome/binary_sensor.py index 77ec780acb35..6d99349a461a 100644 --- a/homeassistant/components/esphome/binary_sensor.py +++ b/homeassistant/components/esphome/binary_sensor.py @@ -1,7 +1,7 @@ """Support for ESPHome binary sensors.""" from __future__ import annotations -from aioesphomeapi import BinarySensorInfo, BinarySensorState +from aioesphomeapi import BinarySensorInfo, BinarySensorState, EntityInfo from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -9,7 +9,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.enum import try_parse_enum @@ -55,10 +55,13 @@ class EsphomeBinarySensor( return None return self._state.state - @property - def device_class(self) -> BinarySensorDeviceClass | None: - """Return the class of this device, from component DEVICE_CLASSES.""" - return try_parse_enum(BinarySensorDeviceClass, self._static_info.device_class) + @callback + def _on_static_info_update(self, static_info: EntityInfo) -> None: + """Set attrs from static info.""" + super()._on_static_info_update(static_info) + self._attr_device_class = try_parse_enum( + BinarySensorDeviceClass, self._static_info.device_class + ) @property def available(self) -> bool: diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index e4daa5240887..4b4b359e15ba 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -129,9 +129,11 @@ class RuntimeEntryData: """Return the signal to listen to for updates on static info.""" return f"esphome_{self.entry_id}_on_list" - def signal_component_static_info_updated(self, component_key: str) -> str: - """Return the signal to listen to for updates on static info for a specific component_key.""" - return f"esphome_{self.entry_id}_static_info_updated_{component_key}" + def signal_component_key_static_info_updated( + self, component_key: str, key: int + ) -> str: + """Return the signal to listen to for updates on static info for a specific component_key and key.""" + return f"esphome_{self.entry_id}_static_info_updated_{component_key}_{key}" @callback def async_update_ble_connection_limits(self, free: int, limit: int) -> None: diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 5a064e9b802b..a5e370aec441 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -2,7 +2,7 @@ "domain": "esphome", "name": "ESPHome", "after_dependencies": ["zeroconf", "tag"], - "codeowners": ["@OttoWinter", "@jesserockz"], + "codeowners": ["@OttoWinter", "@jesserockz", "@bdraco"], "config_flow": true, "dependencies": ["assist_pipeline", "bluetooth"], "dhcp": [ diff --git a/homeassistant/components/esphome/sensor.py b/homeassistant/components/esphome/sensor.py index 7a1234341bea..46b8111ddebf 100644 --- a/homeassistant/components/esphome/sensor.py +++ b/homeassistant/components/esphome/sensor.py @@ -5,6 +5,7 @@ from datetime import datetime import math from aioesphomeapi import ( + EntityInfo, SensorInfo, SensorState, SensorStateClass as EsphomeSensorStateClass, @@ -19,7 +20,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util from homeassistant.util.enum import try_parse_enum @@ -67,10 +68,27 @@ _STATE_CLASSES: EsphomeEnumMapper[ class EsphomeSensor(EsphomeEntity[SensorInfo, SensorState], SensorEntity): """A sensor implementation for esphome.""" - @property - def force_update(self) -> bool: - """Return if this sensor should force a state update.""" - return self._static_info.force_update + @callback + def _on_static_info_update(self, static_info: EntityInfo) -> None: + """Set attrs from static info.""" + super()._on_static_info_update(static_info) + static_info = self._static_info + self._attr_force_update = static_info.force_update + self._attr_native_unit_of_measurement = static_info.unit_of_measurement + self._attr_device_class = try_parse_enum( + SensorDeviceClass, static_info.device_class + ) + if not (state_class := static_info.state_class): + return + if ( + state_class == EsphomeSensorStateClass.MEASUREMENT + and static_info.last_reset_type == LastResetType.AUTO + ): + # Legacy, last_reset_type auto was the equivalent to the + # TOTAL_INCREASING state class + self._attr_state_class = SensorStateClass.TOTAL_INCREASING + else: + self._attr_state_class = _STATE_CLASSES.from_esphome(state_class) @property @esphome_state_property @@ -80,38 +98,10 @@ class EsphomeSensor(EsphomeEntity[SensorInfo, SensorState], SensorEntity): return None if self._state.missing_state: return None - if self.device_class == SensorDeviceClass.TIMESTAMP: + if self._attr_device_class == SensorDeviceClass.TIMESTAMP: return dt_util.utc_from_timestamp(self._state.state) return f"{self._state.state:.{self._static_info.accuracy_decimals}f}" - @property - def native_unit_of_measurement(self) -> str | None: - """Return the unit the value is expressed in.""" - if not self._static_info.unit_of_measurement: - return None - return self._static_info.unit_of_measurement - - @property - def device_class(self) -> SensorDeviceClass | None: - """Return the class of this device, from component DEVICE_CLASSES.""" - return try_parse_enum(SensorDeviceClass, self._static_info.device_class) - - @property - def state_class(self) -> SensorStateClass | None: - """Return the state class of this entity.""" - if not self._static_info.state_class: - return None - state_class = self._static_info.state_class - reset_type = self._static_info.last_reset_type - if ( - state_class == EsphomeSensorStateClass.MEASUREMENT - and reset_type == LastResetType.AUTO - ): - # Legacy, last_reset_type auto was the equivalent to the - # TOTAL_INCREASING state class - return SensorStateClass.TOTAL_INCREASING - return _STATE_CLASSES.from_esphome(self._static_info.state_class) - class EsphomeTextSensor(EsphomeEntity[TextSensorInfo, TextSensorState], SensorEntity): """A text sensor implementation for ESPHome."""