diff --git a/.coveragerc b/.coveragerc index 4f3e82042f6..db191405522 100644 --- a/.coveragerc +++ b/.coveragerc @@ -306,7 +306,6 @@ omit = homeassistant/components/escea/climate.py homeassistant/components/escea/discovery.py homeassistant/components/esphome/bluetooth/* - homeassistant/components/esphome/entry_data.py homeassistant/components/esphome/manager.py homeassistant/components/etherscan/sensor.py homeassistant/components/eufy/* diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index fb13e86dd1d..4a36535cc9b 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -59,6 +59,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry_data = RuntimeEntryData( client=cli, entry_id=entry.entry_id, + title=entry.title, store=domain_data.get_or_create_store(hass, entry), original_options=dict(entry.options), ) diff --git a/homeassistant/components/esphome/entity.py b/homeassistant/components/esphome/entity.py index 2cfbc537dbb..6b0a4cd6b26 100644 --- a/homeassistant/components/esphome/entity.py +++ b/homeassistant/components/esphome/entity.py @@ -140,6 +140,7 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): """Define a base esphome entity.""" _attr_should_poll = False + _attr_has_entity_name = True _static_info: _InfoT _state: _StateT _has_state: bool @@ -164,7 +165,6 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): if object_id := entity_info.object_id: # Use the object_id to suggest the entity_id self.entity_id = f"{domain}.{device_info.name}_{object_id}" - self._attr_has_entity_name = bool(device_info.friendly_name) self._attr_device_info = DeviceInfo( connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)} ) diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index 2d147d243f2..b7870e9cca0 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -86,6 +86,7 @@ class RuntimeEntryData: """Store runtime data for esphome config entries.""" entry_id: str + title: str client: APIClient store: ESPHomeStorage state: dict[type[EntityState], dict[int, EntityState]] = field(default_factory=dict) @@ -127,14 +128,16 @@ class RuntimeEntryData: @property def name(self) -> str: """Return the name of the device.""" - return self.device_info.name if self.device_info else self.entry_id + device_info = self.device_info + return (device_info and device_info.name) or self.title @property def friendly_name(self) -> str: """Return the friendly name of the device.""" - if self.device_info and self.device_info.friendly_name: - return self.device_info.friendly_name - return self.name + device_info = self.device_info + return (device_info and device_info.friendly_name) or self.name.title().replace( + "_", " " + ) @property def signal_device_updated(self) -> str: @@ -303,6 +306,7 @@ class RuntimeEntryData: current_state_by_type = self.state[state_type] current_state = current_state_by_type.get(key, _SENTINEL) subscription_key = (state_type, key) + debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG) if ( current_state == state and subscription_key not in stale_state @@ -314,19 +318,21 @@ class RuntimeEntryData: and (cast(SensorInfo, entity_info)).force_update ) ): + if debug_enabled: + _LOGGER.debug( + "%s: ignoring duplicate update with key %s: %s", + self.name, + key, + state, + ) + return + if debug_enabled: _LOGGER.debug( - "%s: ignoring duplicate update with key %s: %s", + "%s: dispatching update with key %s: %s", self.name, key, state, ) - return - _LOGGER.debug( - "%s: dispatching update with key %s: %s", - self.name, - key, - state, - ) stale_state.discard(subscription_key) current_state_by_type[key] = state if subscription := self.state_subscriptions.get(subscription_key): @@ -367,8 +373,8 @@ class RuntimeEntryData: async def async_save_to_store(self) -> None: """Generate dynamic data to store and save it to the filesystem.""" - if self.device_info is None: - raise ValueError("device_info is not set yet") + if TYPE_CHECKING: + assert self.device_info is not None store_data: StoreData = { "device_info": self.device_info.to_dict(), "services": [], @@ -377,9 +383,10 @@ class RuntimeEntryData: for info_type, infos in self.info.items(): comp_type = INFO_TO_COMPONENT_TYPE[info_type] store_data[comp_type] = [info.to_dict() for info in infos.values()] # type: ignore[literal-required] - for service in self.services.values(): - store_data["services"].append(service.to_dict()) + store_data["services"] = [ + service.to_dict() for service in self.services.values() + ] if store_data == self._storage_contents: return diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index 4741eaaa6fb..345be0c4b6d 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -2,7 +2,7 @@ from __future__ import annotations import logging -from typing import Any, NamedTuple +from typing import TYPE_CHECKING, Any, NamedTuple from aioesphomeapi import ( APIClient, @@ -395,9 +395,7 @@ class ESPHomeManager: ) ) - self.device_id = _async_setup_device_registry( - hass, entry, entry_data.device_info - ) + self.device_id = _async_setup_device_registry(hass, entry, entry_data) entry_data.async_update_device_state(hass) entity_infos, services = await cli.list_entities_services() @@ -515,9 +513,12 @@ class ESPHomeManager: @callback def _async_setup_device_registry( - hass: HomeAssistant, entry: ConfigEntry, device_info: EsphomeDeviceInfo + hass: HomeAssistant, entry: ConfigEntry, entry_data: RuntimeEntryData ) -> str: """Set up device registry feature for a particular config entry.""" + device_info = entry_data.device_info + if TYPE_CHECKING: + assert device_info is not None sw_version = device_info.esphome_version if device_info.compilation_time: sw_version += f" ({device_info.compilation_time})" @@ -544,7 +545,7 @@ def _async_setup_device_registry( config_entry_id=entry.entry_id, configuration_url=configuration_url, connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)}, - name=device_info.friendly_name or device_info.name, + name=entry_data.friendly_name, manufacturer=manufacturer, model=model, sw_version=sw_version, diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index e809089da11..f373c2fdb17 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -211,13 +211,13 @@ async def _mock_generic_device_entry( mock_device = MockESPHomeDevice(entry) - device_info = DeviceInfo( - name="test", - friendly_name="Test", - mac_address="11:22:33:44:55:aa", - esphome_version="1.0.0", - **mock_device_info, - ) + default_device_info = { + "name": "test", + "friendly_name": "Test", + "esphome_version": "1.0.0", + "mac_address": "11:22:33:44:55:aa", + } + device_info = DeviceInfo(**(default_device_info | mock_device_info)) async def _subscribe_states(callback: Callable[[EntityState], None]) -> None: """Subscribe to state.""" diff --git a/tests/components/esphome/test_entity.py b/tests/components/esphome/test_entity.py index e268d065e21..e55d4583275 100644 --- a/tests/components/esphome/test_entity.py +++ b/tests/components/esphome/test_entity.py @@ -184,3 +184,38 @@ async def test_deep_sleep_device( state = hass.states.get("binary_sensor.test_mybinary_sensor") assert state is not None assert state.state == STATE_ON + + +async def test_esphome_device_without_friendly_name( + hass: HomeAssistant, + mock_client: APIClient, + hass_storage: dict[str, Any], + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test a device without friendly_name set.""" + entity_info = [ + BinarySensorInfo( + object_id="mybinary_sensor", + key=1, + name="my binary_sensor", + unique_id="my_binary_sensor", + ), + ] + states = [ + BinarySensorState(key=1, state=True, missing_state=False), + BinarySensorState(key=2, state=True, missing_state=False), + ] + user_service = [] + await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + device_info={"friendly_name": None}, + ) + state = hass.states.get("binary_sensor.test_mybinary_sensor") + assert state is not None + assert state.state == STATE_ON diff --git a/tests/components/esphome/test_sensor.py b/tests/components/esphome/test_sensor.py index 6c034e674ee..83661a58280 100644 --- a/tests/components/esphome/test_sensor.py +++ b/tests/components/esphome/test_sensor.py @@ -1,30 +1,45 @@ """Test ESPHome sensors.""" +from collections.abc import Awaitable, Callable +import logging import math from aioesphomeapi import ( APIClient, EntityCategory as ESPHomeEntityCategory, + EntityInfo, + EntityState, LastResetType, SensorInfo, SensorState, SensorStateClass as ESPHomeSensorStateClass, TextSensorInfo, TextSensorState, + UserService, ) from homeassistant.components.sensor import ATTR_STATE_CLASS, SensorStateClass -from homeassistant.const import ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, STATE_UNKNOWN +from homeassistant.const import ( + ATTR_ICON, + ATTR_UNIT_OF_MEASUREMENT, + STATE_UNKNOWN, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity import EntityCategory +from .conftest import MockESPHomeDevice + async def test_generic_numeric_sensor( hass: HomeAssistant, mock_client: APIClient, - mock_generic_device_entry, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], ) -> None: """Test a generic sensor entity.""" + logging.getLogger("homeassistant.components.esphome").setLevel(logging.DEBUG) entity_info = [ SensorInfo( object_id="mysensor", @@ -35,7 +50,7 @@ async def test_generic_numeric_sensor( ] states = [SensorState(key=1, state=50)] user_service = [] - await mock_generic_device_entry( + mock_device = await mock_esphome_device( mock_client=mock_client, entity_info=entity_info, user_service=user_service, @@ -45,6 +60,34 @@ async def test_generic_numeric_sensor( assert state is not None assert state.state == "50" + # Test updating state + mock_device.set_state(SensorState(key=1, state=60)) + await hass.async_block_till_done() + state = hass.states.get("sensor.test_mysensor") + assert state is not None + assert state.state == "60" + + # Test sending the same state again + mock_device.set_state(SensorState(key=1, state=60)) + await hass.async_block_till_done() + state = hass.states.get("sensor.test_mysensor") + assert state is not None + assert state.state == "60" + + # Test we can still update after the same state + mock_device.set_state(SensorState(key=1, state=70)) + await hass.async_block_till_done() + state = hass.states.get("sensor.test_mysensor") + assert state is not None + assert state.state == "70" + + # Test invalid data from the underlying api does not crash us + mock_device.set_state(SensorState(key=1, state=object())) + await hass.async_block_till_done() + state = hass.states.get("sensor.test_mysensor") + assert state is not None + assert state.state == "70" + async def test_generic_numeric_sensor_with_entity_category_and_icon( hass: HomeAssistant,