diff --git a/homeassistant/components/aranet/sensor.py b/homeassistant/components/aranet/sensor.py index 4596a7fd8af..f5b4e1e92c6 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 DEVICE_CLASS_NAME, DeviceInfo +from homeassistant.helpers.entity import DEVICE_CLASS_NAME, DEVICE_NAME, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN @@ -107,10 +107,11 @@ def sensor_update_to_bluetooth_data_update( """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. + # PassiveBluetoothDataUpdate does not support DEVICE_CLASS_NAME or DEVICE_NAME. + # The asserts satisfy the type checker and will catch attempts + # to use DEVICE_CLASS_NAME or DEVICE_NAME in the entity descriptions. assert desc.name is not DEVICE_CLASS_NAME + assert desc.name is not DEVICE_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)}, diff --git a/homeassistant/components/balboa/binary_sensor.py b/homeassistant/components/balboa/binary_sensor.py index 11a0cae0a01..07b18f25abe 100644 --- a/homeassistant/components/balboa/binary_sensor.py +++ b/homeassistant/components/balboa/binary_sensor.py @@ -13,6 +13,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DEVICE_NAME from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN @@ -83,6 +84,9 @@ class BalboaBinarySensorEntity(BalboaEntity, BinarySensorEntity): self, spa: SpaClient, description: BalboaBinarySensorEntityDescription ) -> None: """Initialize a Balboa binary sensor entity.""" + # The assert satisfies the type checker and will catch attempts + # to use DEVICE_NAME in the entity descriptions. + assert description.name is not DEVICE_NAME super().__init__(spa, description.name) self.entity_description = description diff --git a/homeassistant/components/modbus/binary_sensor.py b/homeassistant/components/modbus/binary_sensor.py index f78e3ef9d31..1863c2a2d75 100644 --- a/homeassistant/components/modbus/binary_sensor.py +++ b/homeassistant/components/modbus/binary_sensor.py @@ -14,7 +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 import DEVICE_CLASS_NAME, DEVICE_NAME from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -75,10 +75,11 @@ class ModbusBinarySensor(BasePlatform, RestoreEntity, BinarySensorEntity): # 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. + # DataUpdateCoordinator does not support DEVICE_CLASS_NAME or DEVICE_NAME + # The asserts satisfy the type checker and will catch attempts + # to use DEVICE_CLASS_NAME or DEVICE_NAME in _attr_name. assert name is not DEVICE_CLASS_NAME + assert name is not DEVICE_NAME self._coordinator = DataUpdateCoordinator( hass, _LOGGER, diff --git a/homeassistant/components/modbus/sensor.py b/homeassistant/components/modbus/sensor.py index 7c1c3b0a791..ce0490a3453 100644 --- a/homeassistant/components/modbus/sensor.py +++ b/homeassistant/components/modbus/sensor.py @@ -17,7 +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 import DEVICE_CLASS_NAME, DEVICE_NAME from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import ( @@ -81,10 +81,11 @@ class ModbusRegisterSensor(BaseStructPlatform, RestoreSensor, SensorEntity): # 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. + # DataUpdateCoordinator does not support DEVICE_CLASS_NAME or DEVICE_NAME + # The asserts satisfy the type checker and will catch attempts + # to use DEVICE_CLASS_NAME or DEVICE_NAME in _attr_name. assert name is not DEVICE_CLASS_NAME + assert name is not DEVICE_NAME self._coordinator = DataUpdateCoordinator( hass, _LOGGER, diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 6b781918b4f..dee0ec8070d 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -19,7 +19,12 @@ 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 import ( + DEVICE_CLASS_NAME, + DEVICE_NAME, + DeviceClassName, + DeviceName, +) from homeassistant.helpers.entity_registry import async_get as er_async_get from homeassistant.helpers.typing import EventType from homeassistant.util.dt import utcnow @@ -73,16 +78,17 @@ def get_number_of_channels(device: BlockDevice, block: Block) -> int: def get_block_entity_name( device: BlockDevice, block: Block | None, - description: str | DeviceClassName | None = None, + description: str | DeviceClassName | DeviceName | 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. + # It's not possible to do string manipulations on DEVICE_CLASS_NAME or + # DEVICE_NAME. The asserts satisfy the type checker and will catch attempts + # to use DEVICE_CLASS_NAME or DEVICE_NAME as descriptions. assert description is not DEVICE_CLASS_NAME + assert description is not DEVICE_NAME return f"{channel_name} {description.lower()}" return channel_name @@ -306,16 +312,19 @@ def get_rpc_channel_name(device: RpcDevice, key: str) -> str: def get_rpc_entity_name( - device: RpcDevice, key: str, description: str | DeviceClassName | None = None + device: RpcDevice, + key: str, + description: str | DeviceClassName | DeviceName | 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. + # It's not possible to do string manipulations on DEVICE_CLASS_NAME or + # DEVICE_NAME. The asserts satisfy the type checker and will catch attempts + # to use DEVICE_CLASS_NAME or DEVICE_NAME as descriptions. assert description is not DEVICE_CLASS_NAME + assert description is not DEVICE_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 1c5d4a4204e..c708f1414a3 100644 --- a/homeassistant/components/system_bridge/__init__.py +++ b/homeassistant/components/system_bridge/__init__.py @@ -30,7 +30,13 @@ 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 DEVICE_CLASS_NAME, DeviceClassName, DeviceInfo +from homeassistant.helpers.entity import ( + DEVICE_CLASS_NAME, + DEVICE_NAME, + DeviceClassName, + DeviceInfo, + DeviceName, +) from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, MODULES @@ -279,17 +285,18 @@ class SystemBridgeEntity(CoordinatorEntity[SystemBridgeDataUpdateCoordinator]): coordinator: SystemBridgeDataUpdateCoordinator, api_port: int, key: str, - name: str | DeviceClassName | None, + name: str | DeviceClassName | DeviceName | 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. + # It's not possible to do string manipulations on DEVICE_CLASS_NAME or + # DEVICE_NAME. The asserts satisfy the type checker and will catch attempts + # to use DEVICE_CLASS_NAME or DEVICE_NAME as name. assert name is not DEVICE_CLASS_NAME + assert name is not DEVICE_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 23218543744..e1505afec39 100644 --- a/homeassistant/components/tomorrowio/sensor.py +++ b/homeassistant/components/tomorrowio/sensor.py @@ -32,7 +32,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DEVICE_CLASS_NAME +from homeassistant.helpers.entity import DEVICE_CLASS_NAME, DEVICE_NAME from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import slugify from homeassistant.util.unit_conversion import DistanceConverter, SpeedConverter @@ -350,10 +350,11 @@ 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. + # It's not possible to do string manipulations on DEVICE_CLASS_NAME or + # DEVICE_NAME. The asserts satisfy the type checker and will catch attempts + # to use DEVICE_CLASS_NAME or DEVICE_NAME in the entity descriptions. assert description.name is not DEVICE_CLASS_NAME + assert description.name is not DEVICE_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 9ca27c7174e..6546b54d328 100644 --- a/homeassistant/components/unifiprotect/entity.py +++ b/homeassistant/components/unifiprotect/entity.py @@ -24,6 +24,7 @@ from homeassistant.core import callback import homeassistant.helpers.device_registry as dr from homeassistant.helpers.entity import ( DEVICE_CLASS_NAME, + DEVICE_NAME, DeviceInfo, Entity, EntityDescription, @@ -204,10 +205,11 @@ 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. + # It's not possible to do string manipulations on DEVICE_CLASS_NAME or + # DEVICE_NAME. The asserts satisfy the type checker and will catch attempts + # to use DEVICE_CLASS_NAME or DEVICE_NAME in the entity descriptions. assert name is not DEVICE_CLASS_NAME + assert name is not DEVICE_NAME self._attr_name = f"{self.device.display_name} {name.title()}" self._attr_attribution = DEFAULT_ATTRIBUTION diff --git a/homeassistant/components/zwave_js/entity.py b/homeassistant/components/zwave_js/entity.py index 1f43f9f5bdb..0a05845adf3 100644 --- a/homeassistant/components/zwave_js/entity.py +++ b/homeassistant/components/zwave_js/entity.py @@ -8,7 +8,12 @@ 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 DEVICE_CLASS_NAME, DeviceInfo, Entity +from homeassistant.helpers.entity import ( + DEVICE_CLASS_NAME, + DEVICE_NAME, + DeviceInfo, + Entity, +) from .const import DOMAIN, LOGGER from .discovery import ZwaveDiscoveryInfo @@ -136,10 +141,11 @@ 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. + # It's not possible to do string manipulations on DEVICE_CLASS_NAME or + # DEVICE_NAME. The asserts satisfy the type checker and will catch attempts + # to use DEVICE_CLASS_NAME or DEVICE_NAME in the entity descriptions. assert self.entity_description.name is not DEVICE_CLASS_NAME + assert self.entity_description.name is not DEVICE_NAME name = self.entity_description.name if name_prefix: diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 3cc655a7fd1..f459466af0b 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -53,6 +53,15 @@ SOURCE_CONFIG_ENTRY = "config_entry" SOURCE_PLATFORM_CONFIG = "platform_config" +class DeviceName(Enum): + """Singleton to use device name.""" + + _singleton = 0 + + +DEVICE_NAME = DeviceName._singleton # pylint: disable=protected-access + + class DeviceClassName(Enum): """Singleton to use device class name.""" @@ -229,7 +238,7 @@ class EntityDescription: force_update: bool = False icon: str | None = None has_entity_name: bool = False - name: str | DeviceClassName | None = None + name: str | DeviceClassName | DeviceName | None = None translation_key: str | None = None unit_of_measurement: str | None = None @@ -263,6 +272,9 @@ class Entity(ABC): # it should be using async_write_ha_state. _async_update_ha_state_reported = False + # If we reported this entity is implicitly using device name + _implicit_device_name_reported = False + # Protect for multiple updates _update_staged = False @@ -298,7 +310,7 @@ class Entity(ABC): _attr_extra_state_attributes: MutableMapping[str, Any] _attr_force_update: bool _attr_icon: str | None - _attr_name: str | DeviceClassName | None + _attr_name: str | DeviceClassName | DeviceName | None _attr_should_poll: bool = True _attr_state: StateType = STATE_UNKNOWN _attr_supported_features: int | None = None @@ -319,6 +331,66 @@ class Entity(ABC): """Return a unique ID.""" return self._attr_unique_id + @property + def use_device_name(self) -> bool: + """Return if this entity does not have its own name. + + Should be True if the entity represents the single main feature of a device. + """ + + def report_implicit_device_name() -> None: + """Report entities which use implicit device name.""" + if self._implicit_device_name_reported: + return + report_issue = self._suggest_report_issue() + _LOGGER.warning( + ( + "Entity %s (%s) is implicitly using device name by setting its" + " name to None. Instead, the name should be set to DEVICE_NAME" + ", please %s" + ), + self.entity_id, + type(self), + report_issue, + ) + self._implicit_device_name_reported = True + + if not self.has_entity_name: + return False + if hasattr(self, "_attr_name"): + if (name := self._attr_name) is DEVICE_NAME: + return True + if not name: + # Backwards compatibility with setting _attr_name to None to indicate + # device name. + # Deprecated in HA Core 2023.6, remove in HA Core 2023.7 + report_implicit_device_name() + return True + return False + + if name_translation_key := self.__name_translation_key(): + assert self.platform + if name_translation_key in self.platform.platform_translations: + return False + + if hasattr(self, "entity_description"): + if (name := self.entity_description.name) is DEVICE_NAME: + return True + if not name: + # Backwards compatibility with setting EntityDescription.name to None + # for device name. + # Deprecated in HA Core 2023.6, remove in HA Core 2023.7 + report_implicit_device_name() + return True + return False + if not self.name: + # Backwards compatibility with setting EntityDescription.name to None + # for device name. + # Deprecated in HA Core 2023.6, remove in HA Core 2023.7 + report_implicit_device_name() + return True + return False + @property def has_entity_name(self) -> bool: """Return if the name of the entity is describing only the entity itself.""" @@ -340,26 +412,41 @@ class Entity(ABC): ) return self.platform.component_translations.get(name_translation_key) + def __name_translation_key(self) -> str | None: + """Return translation key for entity name.""" + if self.translation_key is None: + return None + assert self.platform + return ( + f"component.{self.platform.platform_name}.entity.{self.platform.domain}" + f".{self.translation_key}.name" + ) + @property def name(self) -> str | None: - """Return the name of the entity.""" + """Return the name of the entity. + + Returns None if the name is set to DEVICE_NAME. + """ if hasattr(self, "_attr_name"): - if self._attr_name is DEVICE_CLASS_NAME: + if (name := 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: + if name is DEVICE_NAME: + return None + return name + if self.has_entity_name and ( + name_translation_key := self.__name_translation_key() + ): assert self.platform - name_translation_key = ( - f"component.{self.platform.platform_name}.entity.{self.platform.domain}" - f".{self.translation_key}.name" - ) if name_translation_key in self.platform.platform_translations: - name: str = self.platform.platform_translations[name_translation_key] + name = self.platform.platform_translations[name_translation_key] return name if hasattr(self, "entity_description"): - if self.entity_description.name is DEVICE_CLASS_NAME: + if (name := self.entity_description.name) is DEVICE_CLASS_NAME: return self._device_class_name() - return self.entity_description.name + if name is DEVICE_NAME: + return None + return name return None @property @@ -637,9 +724,9 @@ class Entity(ABC): ): return self.name - if not (name := self.name): + if self.use_device_name: return device_entry.name_by_user or device_entry.name - return f"{device_entry.name_by_user or device_entry.name} {name}" + return f"{device_entry.name_by_user or device_entry.name} {self.name}" @callback def _async_write_ha_state(self) -> None: diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 25140d0516f..005582e8e73 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -125,8 +125,8 @@ class EntityPlatform: self.entity_namespace = entity_namespace self.config_entry: config_entries.ConfigEntry | None = None self.entities: dict[str, Entity] = {} - self.component_translations: dict[str, Any] = {} - self.platform_translations: dict[str, Any] = {} + self.component_translations: dict[str, str] = {} + self.platform_translations: dict[str, str] = {} self._tasks: list[asyncio.Task[None]] = [] # Stop tracking tasks after setup is completed self._setup_complete = False @@ -627,7 +627,7 @@ class EntityPlatform: else: if device and entity.has_entity_name: # type: ignore[unreachable] device_name = device.name_by_user or device.name - if not entity.name: + if entity.use_device_name: suggested_object_id = device_name else: suggested_object_id = f"{device_name} {entity.name}"