diff --git a/homeassistant/components/aranet/sensor.py b/homeassistant/components/aranet/sensor.py index 6ac27b1652b..4596a7fd8af 100644 --- a/homeassistant/components/aranet/sensor.py +++ b/homeassistant/components/aranet/sensor.py @@ -28,7 +28,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity import DEVICE_CLASS_NAME, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN @@ -105,6 +105,13 @@ def sensor_update_to_bluetooth_data_update( adv: Aranet4Advertisement, ) -> PassiveBluetoothDataUpdate: """Convert a sensor update to a Bluetooth data update.""" + entity_names: dict[PassiveBluetoothEntityKey, str | None] = {} + for key, desc in SENSOR_DESCRIPTIONS.items(): + # PassiveBluetoothDataUpdate does not support DEVICE_CLASS_NAME + # the assert satisfies the type checker and will catch attempts + # to use DEVICE_CLASS_NAME in the entity descriptions. + assert desc.name is not DEVICE_CLASS_NAME + entity_names[_device_key_to_bluetooth_entity_key(adv.device, key)] = desc.name return PassiveBluetoothDataUpdate( devices={adv.device.address: _sensor_device_info_to_hass(adv)}, entity_descriptions={ @@ -117,10 +124,7 @@ def sensor_update_to_bluetooth_data_update( ) for key in SENSOR_DESCRIPTIONS }, - entity_names={ - _device_key_to_bluetooth_entity_key(adv.device, key): desc.name - for key, desc in SENSOR_DESCRIPTIONS.items() - }, + entity_names=entity_names, ) diff --git a/homeassistant/components/balboa/entity.py b/homeassistant/components/balboa/entity.py index e50c35db477..1b2c8d48f0b 100644 --- a/homeassistant/components/balboa/entity.py +++ b/homeassistant/components/balboa/entity.py @@ -4,7 +4,7 @@ from __future__ import annotations from pybalboa import EVENT_UPDATE, SpaClient from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity import DeviceClassName, DeviceInfo, Entity from .const import DOMAIN @@ -12,7 +12,9 @@ from .const import DOMAIN class BalboaBaseEntity(Entity): """Balboa base entity.""" - def __init__(self, client: SpaClient, name: str | None = None) -> None: + def __init__( + self, client: SpaClient, name: str | DeviceClassName | None = None + ) -> None: """Initialize the control.""" mac = client.mac_address model = client.model diff --git a/homeassistant/components/bond/button.py b/homeassistant/components/bond/button.py index 32b76c6fcae..d3fc58a35be 100644 --- a/homeassistant/components/bond/button.py +++ b/homeassistant/components/bond/button.py @@ -35,6 +35,11 @@ class BondButtonEntityDescription( ): """Class to describe a Bond Button entity.""" + # BondEntity does not support DEVICE_CLASS_NAME + # Restrict the type to satisfy the type checker and catch attempts + # to use DEVICE_CLASS_NAME in the entity descriptions. + name: str | None = None + STOP_BUTTON = BondButtonEntityDescription( key=Action.STOP, diff --git a/homeassistant/components/brunt/cover.py b/homeassistant/components/brunt/cover.py index 3fb328ab7fb..1df34929b1c 100644 --- a/homeassistant/components/brunt/cover.py +++ b/homeassistant/components/brunt/cover.py @@ -88,7 +88,7 @@ class BruntDevice( self._attr_attribution = ATTRIBUTION self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._attr_unique_id)}, # type: ignore[arg-type] - name=self._attr_name, + name=self._thing.name, via_device=(DOMAIN, self._entry_id), manufacturer="Brunt", sw_version=self._thing.fw_version, diff --git a/homeassistant/components/freebox/home_base.py b/homeassistant/components/freebox/home_base.py index c74f072a5be..f10a9e047f7 100644 --- a/homeassistant/components/freebox/home_base.py +++ b/homeassistant/components/freebox/home_base.py @@ -30,8 +30,8 @@ class FreeboxHomeEntity(Entity): self._node = node self._sub_node = sub_node self._id = node["id"] - self._attr_name = node["label"].strip() - self._device_name = self._attr_name + self._device_name = node["label"].strip() + self._attr_name = self._device_name self._attr_unique_id = f"{self._router.mac}-node_{self._id}" if sub_node is not None: diff --git a/homeassistant/components/incomfort/sensor.py b/homeassistant/components/incomfort/sensor.py index b1b391aaaab..0205e690c42 100644 --- a/homeassistant/components/incomfort/sensor.py +++ b/homeassistant/components/incomfort/sensor.py @@ -28,6 +28,10 @@ class IncomfortSensorEntityDescription(SensorEntityDescription): """Describes Incomfort sensor entity.""" extra_key: str | None = None + # IncomfortSensor does not support DEVICE_CLASS_NAME + # Restrict the type to satisfy the type checker and catch attempts + # to use DEVICE_CLASS_NAME in the entity descriptions. + name: str | None = None SENSOR_TYPES: tuple[IncomfortSensorEntityDescription, ...] = ( diff --git a/homeassistant/components/modbus/binary_sensor.py b/homeassistant/components/modbus/binary_sensor.py index 4f416874f9d..f78e3ef9d31 100644 --- a/homeassistant/components/modbus/binary_sensor.py +++ b/homeassistant/components/modbus/binary_sensor.py @@ -14,6 +14,7 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import DEVICE_CLASS_NAME from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -73,6 +74,11 @@ class ModbusBinarySensor(BasePlatform, RestoreEntity, BinarySensorEntity): # this ensures that idx = bit position of value in result # polling is done with the base class name = self._attr_name if self._attr_name else "modbus_sensor" + + # DataUpdateCoordinator does not support DEVICE_CLASS_NAME + # the assert satisfies the type checker and will catch attempts + # to use DEVICE_CLASS_NAME in _attr_name. + assert name is not DEVICE_CLASS_NAME self._coordinator = DataUpdateCoordinator( hass, _LOGGER, diff --git a/homeassistant/components/modbus/sensor.py b/homeassistant/components/modbus/sensor.py index ca8246577fd..7c1c3b0a791 100644 --- a/homeassistant/components/modbus/sensor.py +++ b/homeassistant/components/modbus/sensor.py @@ -17,6 +17,7 @@ from homeassistant.const import ( CONF_UNIT_OF_MEASUREMENT, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import DEVICE_CLASS_NAME from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import ( @@ -79,6 +80,11 @@ class ModbusRegisterSensor(BaseStructPlatform, RestoreSensor, SensorEntity): # this ensures that idx = bit position of value in result # polling is done with the base class name = self._attr_name if self._attr_name else "modbus_sensor" + + # DataUpdateCoordinator does not support DEVICE_CLASS_NAME + # the assert satisfies the type checker and will catch attempts + # to use DEVICE_CLASS_NAME in _attr_name. + assert name is not DEVICE_CLASS_NAME self._coordinator = DataUpdateCoordinator( hass, _LOGGER, diff --git a/homeassistant/components/point/alarm_control_panel.py b/homeassistant/components/point/alarm_control_panel.py index bfffb934407..21469c2c5a5 100644 --- a/homeassistant/components/point/alarm_control_panel.py +++ b/homeassistant/components/point/alarm_control_panel.py @@ -67,7 +67,7 @@ class MinutPointAlarmControl(AlarmControlPanelEntity): self._attr_device_info = DeviceInfo( identifiers={(POINT_DOMAIN, home_id)}, manufacturer="Minut", - name=self._attr_name, + name=self._home["name"], ) async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 55c08620e81..6b781918b4f 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -19,6 +19,7 @@ from homeassistant.helpers.device_registry import ( async_get as dr_async_get, format_mac, ) +from homeassistant.helpers.entity import DEVICE_CLASS_NAME, DeviceClassName from homeassistant.helpers.entity_registry import async_get as er_async_get from homeassistant.helpers.typing import EventType from homeassistant.util.dt import utcnow @@ -72,12 +73,16 @@ def get_number_of_channels(device: BlockDevice, block: Block) -> int: def get_block_entity_name( device: BlockDevice, block: Block | None, - description: str | None = None, + description: str | DeviceClassName | None = None, ) -> str: """Naming for block based switch and sensors.""" channel_name = get_block_channel_name(device, block) if description: + # It's not possible to do string manipulations on DEVICE_CLASS_NAME + # the assert satisfies the type checker and will catch attempts + # to use DEVICE_CLASS_NAME as description. + assert description is not DEVICE_CLASS_NAME return f"{channel_name} {description.lower()}" return channel_name @@ -301,12 +306,16 @@ def get_rpc_channel_name(device: RpcDevice, key: str) -> str: def get_rpc_entity_name( - device: RpcDevice, key: str, description: str | None = None + device: RpcDevice, key: str, description: str | DeviceClassName | None = None ) -> str: """Naming for RPC based switch and sensors.""" channel_name = get_rpc_channel_name(device, key) if description: + # It's not possible to do string manipulations on DEVICE_CLASS_NAME + # the assert satisfies the type checker and will catch attempts + # to use DEVICE_CLASS_NAME as description. + assert description is not DEVICE_CLASS_NAME return f"{channel_name} {description.lower()}" return channel_name diff --git a/homeassistant/components/system_bridge/__init__.py b/homeassistant/components/system_bridge/__init__.py index 05e607d56ed..1c5d4a4204e 100644 --- a/homeassistant/components/system_bridge/__init__.py +++ b/homeassistant/components/system_bridge/__init__.py @@ -30,7 +30,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity import DEVICE_CLASS_NAME, DeviceClassName, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, MODULES @@ -279,13 +279,17 @@ class SystemBridgeEntity(CoordinatorEntity[SystemBridgeDataUpdateCoordinator]): coordinator: SystemBridgeDataUpdateCoordinator, api_port: int, key: str, - name: str | None, + name: str | DeviceClassName | None, ) -> None: """Initialize the System Bridge entity.""" super().__init__(coordinator) self._hostname = coordinator.data.system.hostname self._key = f"{self._hostname}_{key}" + # It's not possible to do string manipulations on DEVICE_CLASS_NAME + # the assert satisfies the type checker and will catch attempts + # to use DEVICE_CLASS_NAME as name. + assert name is not DEVICE_CLASS_NAME self._name = f"{self._hostname} {name}" self._configuration_url = ( f"http://{self._hostname}:{api_port}/app/settings.html" diff --git a/homeassistant/components/tomorrowio/sensor.py b/homeassistant/components/tomorrowio/sensor.py index 2b7d466d2f0..23218543744 100644 --- a/homeassistant/components/tomorrowio/sensor.py +++ b/homeassistant/components/tomorrowio/sensor.py @@ -32,6 +32,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DEVICE_CLASS_NAME from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import slugify from homeassistant.util.unit_conversion import DistanceConverter, SpeedConverter @@ -349,6 +350,10 @@ class BaseTomorrowioSensorEntity(TomorrowioEntity, SensorEntity): """Initialize Tomorrow.io Sensor Entity.""" super().__init__(config_entry, coordinator, api_version) self.entity_description = description + # It's not possible to do string manipulations on DEVICE_CLASS_NAME + # the assert satisfies the type checker and will catch attempts + # to use DEVICE_CLASS_NAME in the entity descriptions. + assert description.name is not DEVICE_CLASS_NAME self._attr_name = f"{self._config_entry.data[CONF_NAME]} - {description.name}" self._attr_unique_id = ( f"{self._config_entry.unique_id}_{slugify(description.name)}" diff --git a/homeassistant/components/unifiprotect/entity.py b/homeassistant/components/unifiprotect/entity.py index 134b55c4b0d..9ca27c7174e 100644 --- a/homeassistant/components/unifiprotect/entity.py +++ b/homeassistant/components/unifiprotect/entity.py @@ -22,7 +22,12 @@ from pyunifiprotect.data import ( from homeassistant.core import callback import homeassistant.helpers.device_registry as dr -from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription +from homeassistant.helpers.entity import ( + DEVICE_CLASS_NAME, + DeviceInfo, + Entity, + EntityDescription, +) from .const import ( ATTR_EVENT_ID, @@ -199,6 +204,10 @@ class ProtectDeviceEntity(Entity): self.entity_description = description self._attr_unique_id = f"{self.device.mac}_{description.key}" name = description.name or "" + # It's not possible to do string manipulations on DEVICE_CLASS_NAME + # the assert satisfies the type checker and will catch attempts + # to use DEVICE_CLASS_NAME in the entity descriptions. + assert name is not DEVICE_CLASS_NAME self._attr_name = f"{self.device.display_name} {name.title()}" self._attr_attribution = DEFAULT_ATTRIBUTION diff --git a/homeassistant/components/vizio/media_player.py b/homeassistant/components/vizio/media_player.py index a989cea488f..bf52f053b32 100644 --- a/homeassistant/components/vizio/media_player.py +++ b/homeassistant/components/vizio/media_player.py @@ -131,6 +131,8 @@ async def async_setup_entry( class VizioDevice(MediaPlayerEntity): """Media Player implementation which performs REST requests to device.""" + _attr_name: str + def __init__( self, config_entry: ConfigEntry, diff --git a/homeassistant/components/wemo/sensor.py b/homeassistant/components/wemo/sensor.py index 634d4a9e41d..3cfc9a16f30 100644 --- a/homeassistant/components/wemo/sensor.py +++ b/homeassistant/components/wemo/sensor.py @@ -28,6 +28,10 @@ from .wemo_device import DeviceCoordinator class AttributeSensorDescription(SensorEntityDescription): """SensorEntityDescription for WeMo AttributeSensor entities.""" + # AttributeSensor does not support DEVICE_CLASS_NAME + # the assert satisfies the type checker and will catch attempts + # to use DEVICE_CLASS_NAME in the entity descriptions. + name: str | None = None state_conversion: Callable[[StateType], StateType] | None = None unique_id_suffix: str | None = None diff --git a/homeassistant/components/zwave_js/entity.py b/homeassistant/components/zwave_js/entity.py index 2a57257ffbf..1f43f9f5bdb 100644 --- a/homeassistant/components/zwave_js/entity.py +++ b/homeassistant/components/zwave_js/entity.py @@ -8,7 +8,7 @@ from zwave_js_server.model.value import Value as ZwaveValue, get_value_id_str from homeassistant.config_entries import ConfigEntry from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity import DEVICE_CLASS_NAME, DeviceInfo, Entity from .const import DOMAIN, LOGGER from .discovery import ZwaveDiscoveryInfo @@ -136,6 +136,10 @@ class ZWaveBaseEntity(Entity): and self.entity_description and self.entity_description.name ): + # It's not possible to do string manipulations on DEVICE_CLASS_NAME + # the assert satisfies the type checker and will catch attempts + # to use DEVICE_CLASS_NAME in the entity descriptions. + assert self.entity_description.name is not DEVICE_CLASS_NAME name = self.entity_description.name if name_prefix: diff --git a/homeassistant/components/zwave_me/__init__.py b/homeassistant/components/zwave_me/__init__.py index 1740820d0ba..f49d203cb26 100644 --- a/homeassistant/components/zwave_me/__init__.py +++ b/homeassistant/components/zwave_me/__init__.py @@ -115,7 +115,7 @@ async def async_setup_platforms( class ZWaveMeEntity(Entity): """Representation of a ZWaveMe device.""" - def __init__(self, controller, device): + def __init__(self, controller: ZWaveMeController, device: ZWaveMeData) -> None: """Initialize the device.""" self.controller = controller self.device = device @@ -124,13 +124,9 @@ class ZWaveMeEntity(Entity): f"{self.controller.config.unique_id}-{self.device.id}" ) self._attr_should_poll = False - - @property - def device_info(self) -> DeviceInfo: - """Return device specific attributes.""" - return DeviceInfo( + self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self.device.deviceIdentifier)}, - name=self._attr_name, + name=device.title, manufacturer=self.device.manufacturer, sw_version=self.device.firmware, suggested_area=self.device.locationName, diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 00171350594..3cc655a7fd1 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -52,6 +52,16 @@ DATA_ENTITY_SOURCE = "entity_info" SOURCE_CONFIG_ENTRY = "config_entry" SOURCE_PLATFORM_CONFIG = "platform_config" + +class DeviceClassName(Enum): + """Singleton to use device class name.""" + + _singleton = 0 + + +DEVICE_CLASS_NAME = DeviceClassName._singleton # pylint: disable=protected-access + + # Used when converting float states to string: limit precision according to machine # epsilon to make the string representation readable FLOAT_PRECISION = abs(int(math.floor(math.log10(abs(sys.float_info.epsilon))))) - 1 @@ -219,7 +229,7 @@ class EntityDescription: force_update: bool = False icon: str | None = None has_entity_name: bool = False - name: str | None = None + name: str | DeviceClassName | None = None translation_key: str | None = None unit_of_measurement: str | None = None @@ -288,7 +298,7 @@ class Entity(ABC): _attr_extra_state_attributes: MutableMapping[str, Any] _attr_force_update: bool _attr_icon: str | None - _attr_name: str | None + _attr_name: str | DeviceClassName | None _attr_should_poll: bool = True _attr_state: StateType = STATE_UNKNOWN _attr_supported_features: int | None = None @@ -318,10 +328,24 @@ class Entity(ABC): return self.entity_description.has_entity_name return False + def _device_class_name(self) -> str | None: + """Return a translated name of the entity based on its device class.""" + assert self.platform + if not self.has_entity_name: + return None + device_class_key = self.device_class or "_" + name_translation_key = ( + f"component.{self.platform.domain}.entity_component." + f"{device_class_key}.name" + ) + return self.platform.component_translations.get(name_translation_key) + @property def name(self) -> str | None: """Return the name of the entity.""" if hasattr(self, "_attr_name"): + if self._attr_name is DEVICE_CLASS_NAME: + return self._device_class_name() return self._attr_name if self.translation_key is not None and self.has_entity_name: assert self.platform @@ -329,10 +353,12 @@ class Entity(ABC): f"component.{self.platform.platform_name}.entity.{self.platform.domain}" f".{self.translation_key}.name" ) - if name_translation_key in self.platform.entity_translations: - name: str = self.platform.entity_translations[name_translation_key] + if name_translation_key in self.platform.platform_translations: + name: str = self.platform.platform_translations[name_translation_key] return name if hasattr(self, "entity_description"): + if self.entity_description.name is DEVICE_CLASS_NAME: + return self._device_class_name() return self.entity_description.name return None diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 0f93dca6939..25140d0516f 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -125,7 +125,8 @@ class EntityPlatform: self.entity_namespace = entity_namespace self.config_entry: config_entries.ConfigEntry | None = None self.entities: dict[str, Entity] = {} - self.entity_translations: dict[str, Any] = {} + self.component_translations: dict[str, Any] = {} + self.platform_translations: dict[str, Any] = {} self._tasks: list[asyncio.Task[None]] = [] # Stop tracking tasks after setup is completed self._setup_complete = False @@ -279,7 +280,15 @@ class EntityPlatform: full_name = f"{self.domain}.{self.platform_name}" try: - self.entity_translations = await translation.async_get_translations( + self.component_translations = await translation.async_get_translations( + hass, hass.config.language, "entity_component", {self.domain} + ) + except Exception as err: # pylint: disable=broad-exception-caught + _LOGGER.debug( + "Could not load translations for %s", self.domain, exc_info=err + ) + try: + self.platform_translations = await translation.async_get_translations( hass, hass.config.language, "entity", {self.platform_name} ) except Exception as err: # pylint: disable=broad-exception-caught diff --git a/homeassistant/helpers/template_entity.py b/homeassistant/helpers/template_entity.py index c4eb8a1343d..2b2774eea66 100644 --- a/homeassistant/helpers/template_entity.py +++ b/homeassistant/helpers/template_entity.py @@ -172,6 +172,8 @@ class _TemplateAttribute: class TemplateEntity(Entity): """Entity that uses templates to calculate attributes.""" + _attr_name: str | None + _attr_available = True _attr_entity_picture = None _attr_icon = None diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index 4c01c2f7c5a..381b9e99f03 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -1,9 +1,10 @@ """esphome session fixtures.""" from __future__ import annotations +from asyncio import Event from unittest.mock import AsyncMock, Mock, patch -from aioesphomeapi import APIClient, APIVersion, DeviceInfo +from aioesphomeapi import APIClient, APIVersion, DeviceInfo, ReconnectLogic import pytest from zeroconf import Zeroconf @@ -158,10 +159,18 @@ async def mock_voice_assistant_v1_entry( mock_client.device_info = AsyncMock(return_value=device_info) mock_client.subscribe_voice_assistant = AsyncMock(return_value=Mock()) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - await hass.async_block_till_done() - await hass.async_block_till_done() + try_connect_done = Event() + real_try_connect = ReconnectLogic._try_connect + + async def mock_try_connect(self): + """Set an event when ReconnectLogic._try_connect has been awaited.""" + result = await real_try_connect(self) + try_connect_done.set() + return result + + with patch.object(ReconnectLogic, "_try_connect", mock_try_connect): + await hass.config_entries.async_setup(entry.entry_id) + await try_connect_done.wait() return entry