Fix has_entity_name not always being set in ESPHome (#97055)
This commit is contained in:
parent
bf66dc7a91
commit
095146b163
8 changed files with 120 additions and 34 deletions
|
@ -306,7 +306,6 @@ omit =
|
||||||
homeassistant/components/escea/climate.py
|
homeassistant/components/escea/climate.py
|
||||||
homeassistant/components/escea/discovery.py
|
homeassistant/components/escea/discovery.py
|
||||||
homeassistant/components/esphome/bluetooth/*
|
homeassistant/components/esphome/bluetooth/*
|
||||||
homeassistant/components/esphome/entry_data.py
|
|
||||||
homeassistant/components/esphome/manager.py
|
homeassistant/components/esphome/manager.py
|
||||||
homeassistant/components/etherscan/sensor.py
|
homeassistant/components/etherscan/sensor.py
|
||||||
homeassistant/components/eufy/*
|
homeassistant/components/eufy/*
|
||||||
|
|
|
@ -59,6 +59,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
entry_data = RuntimeEntryData(
|
entry_data = RuntimeEntryData(
|
||||||
client=cli,
|
client=cli,
|
||||||
entry_id=entry.entry_id,
|
entry_id=entry.entry_id,
|
||||||
|
title=entry.title,
|
||||||
store=domain_data.get_or_create_store(hass, entry),
|
store=domain_data.get_or_create_store(hass, entry),
|
||||||
original_options=dict(entry.options),
|
original_options=dict(entry.options),
|
||||||
)
|
)
|
||||||
|
|
|
@ -140,6 +140,7 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]):
|
||||||
"""Define a base esphome entity."""
|
"""Define a base esphome entity."""
|
||||||
|
|
||||||
_attr_should_poll = False
|
_attr_should_poll = False
|
||||||
|
_attr_has_entity_name = True
|
||||||
_static_info: _InfoT
|
_static_info: _InfoT
|
||||||
_state: _StateT
|
_state: _StateT
|
||||||
_has_state: bool
|
_has_state: bool
|
||||||
|
@ -164,7 +165,6 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]):
|
||||||
if object_id := entity_info.object_id:
|
if object_id := entity_info.object_id:
|
||||||
# Use the object_id to suggest the entity_id
|
# Use the object_id to suggest the entity_id
|
||||||
self.entity_id = f"{domain}.{device_info.name}_{object_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(
|
self._attr_device_info = DeviceInfo(
|
||||||
connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)}
|
connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)}
|
||||||
)
|
)
|
||||||
|
|
|
@ -86,6 +86,7 @@ class RuntimeEntryData:
|
||||||
"""Store runtime data for esphome config entries."""
|
"""Store runtime data for esphome config entries."""
|
||||||
|
|
||||||
entry_id: str
|
entry_id: str
|
||||||
|
title: str
|
||||||
client: APIClient
|
client: APIClient
|
||||||
store: ESPHomeStorage
|
store: ESPHomeStorage
|
||||||
state: dict[type[EntityState], dict[int, EntityState]] = field(default_factory=dict)
|
state: dict[type[EntityState], dict[int, EntityState]] = field(default_factory=dict)
|
||||||
|
@ -127,14 +128,16 @@ class RuntimeEntryData:
|
||||||
@property
|
@property
|
||||||
def name(self) -> str:
|
def name(self) -> str:
|
||||||
"""Return the name of the device."""
|
"""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
|
@property
|
||||||
def friendly_name(self) -> str:
|
def friendly_name(self) -> str:
|
||||||
"""Return the friendly name of the device."""
|
"""Return the friendly name of the device."""
|
||||||
if self.device_info and self.device_info.friendly_name:
|
device_info = self.device_info
|
||||||
return self.device_info.friendly_name
|
return (device_info and device_info.friendly_name) or self.name.title().replace(
|
||||||
return self.name
|
"_", " "
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def signal_device_updated(self) -> str:
|
def signal_device_updated(self) -> str:
|
||||||
|
@ -303,6 +306,7 @@ class RuntimeEntryData:
|
||||||
current_state_by_type = self.state[state_type]
|
current_state_by_type = self.state[state_type]
|
||||||
current_state = current_state_by_type.get(key, _SENTINEL)
|
current_state = current_state_by_type.get(key, _SENTINEL)
|
||||||
subscription_key = (state_type, key)
|
subscription_key = (state_type, key)
|
||||||
|
debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG)
|
||||||
if (
|
if (
|
||||||
current_state == state
|
current_state == state
|
||||||
and subscription_key not in stale_state
|
and subscription_key not in stale_state
|
||||||
|
@ -314,6 +318,7 @@ class RuntimeEntryData:
|
||||||
and (cast(SensorInfo, entity_info)).force_update
|
and (cast(SensorInfo, entity_info)).force_update
|
||||||
)
|
)
|
||||||
):
|
):
|
||||||
|
if debug_enabled:
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
"%s: ignoring duplicate update with key %s: %s",
|
"%s: ignoring duplicate update with key %s: %s",
|
||||||
self.name,
|
self.name,
|
||||||
|
@ -321,6 +326,7 @@ class RuntimeEntryData:
|
||||||
state,
|
state,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
if debug_enabled:
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
"%s: dispatching update with key %s: %s",
|
"%s: dispatching update with key %s: %s",
|
||||||
self.name,
|
self.name,
|
||||||
|
@ -367,8 +373,8 @@ 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."""
|
||||||
if self.device_info is None:
|
if TYPE_CHECKING:
|
||||||
raise ValueError("device_info is not set yet")
|
assert self.device_info is not None
|
||||||
store_data: StoreData = {
|
store_data: StoreData = {
|
||||||
"device_info": self.device_info.to_dict(),
|
"device_info": self.device_info.to_dict(),
|
||||||
"services": [],
|
"services": [],
|
||||||
|
@ -377,9 +383,10 @@ class RuntimeEntryData:
|
||||||
for info_type, infos in self.info.items():
|
for info_type, infos in self.info.items():
|
||||||
comp_type = INFO_TO_COMPONENT_TYPE[info_type]
|
comp_type = INFO_TO_COMPONENT_TYPE[info_type]
|
||||||
store_data[comp_type] = [info.to_dict() for info in infos.values()] # type: ignore[literal-required]
|
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:
|
if store_data == self._storage_contents:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, NamedTuple
|
from typing import TYPE_CHECKING, Any, NamedTuple
|
||||||
|
|
||||||
from aioesphomeapi import (
|
from aioesphomeapi import (
|
||||||
APIClient,
|
APIClient,
|
||||||
|
@ -395,9 +395,7 @@ class ESPHomeManager:
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
self.device_id = _async_setup_device_registry(
|
self.device_id = _async_setup_device_registry(hass, entry, entry_data)
|
||||||
hass, entry, entry_data.device_info
|
|
||||||
)
|
|
||||||
entry_data.async_update_device_state(hass)
|
entry_data.async_update_device_state(hass)
|
||||||
|
|
||||||
entity_infos, services = await cli.list_entities_services()
|
entity_infos, services = await cli.list_entities_services()
|
||||||
|
@ -515,9 +513,12 @@ class ESPHomeManager:
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def _async_setup_device_registry(
|
def _async_setup_device_registry(
|
||||||
hass: HomeAssistant, entry: ConfigEntry, device_info: EsphomeDeviceInfo
|
hass: HomeAssistant, entry: ConfigEntry, entry_data: RuntimeEntryData
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Set up device registry feature for a particular config entry."""
|
"""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
|
sw_version = device_info.esphome_version
|
||||||
if device_info.compilation_time:
|
if device_info.compilation_time:
|
||||||
sw_version += f" ({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,
|
config_entry_id=entry.entry_id,
|
||||||
configuration_url=configuration_url,
|
configuration_url=configuration_url,
|
||||||
connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)},
|
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,
|
manufacturer=manufacturer,
|
||||||
model=model,
|
model=model,
|
||||||
sw_version=sw_version,
|
sw_version=sw_version,
|
||||||
|
|
|
@ -211,13 +211,13 @@ async def _mock_generic_device_entry(
|
||||||
|
|
||||||
mock_device = MockESPHomeDevice(entry)
|
mock_device = MockESPHomeDevice(entry)
|
||||||
|
|
||||||
device_info = DeviceInfo(
|
default_device_info = {
|
||||||
name="test",
|
"name": "test",
|
||||||
friendly_name="Test",
|
"friendly_name": "Test",
|
||||||
mac_address="11:22:33:44:55:aa",
|
"esphome_version": "1.0.0",
|
||||||
esphome_version="1.0.0",
|
"mac_address": "11:22:33:44:55:aa",
|
||||||
**mock_device_info,
|
}
|
||||||
)
|
device_info = DeviceInfo(**(default_device_info | mock_device_info))
|
||||||
|
|
||||||
async def _subscribe_states(callback: Callable[[EntityState], None]) -> None:
|
async def _subscribe_states(callback: Callable[[EntityState], None]) -> None:
|
||||||
"""Subscribe to state."""
|
"""Subscribe to state."""
|
||||||
|
|
|
@ -184,3 +184,38 @@ async def test_deep_sleep_device(
|
||||||
state = hass.states.get("binary_sensor.test_mybinary_sensor")
|
state = hass.states.get("binary_sensor.test_mybinary_sensor")
|
||||||
assert state is not None
|
assert state is not None
|
||||||
assert state.state == STATE_ON
|
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
|
||||||
|
|
|
@ -1,30 +1,45 @@
|
||||||
"""Test ESPHome sensors."""
|
"""Test ESPHome sensors."""
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
import logging
|
||||||
import math
|
import math
|
||||||
|
|
||||||
from aioesphomeapi import (
|
from aioesphomeapi import (
|
||||||
APIClient,
|
APIClient,
|
||||||
EntityCategory as ESPHomeEntityCategory,
|
EntityCategory as ESPHomeEntityCategory,
|
||||||
|
EntityInfo,
|
||||||
|
EntityState,
|
||||||
LastResetType,
|
LastResetType,
|
||||||
SensorInfo,
|
SensorInfo,
|
||||||
SensorState,
|
SensorState,
|
||||||
SensorStateClass as ESPHomeSensorStateClass,
|
SensorStateClass as ESPHomeSensorStateClass,
|
||||||
TextSensorInfo,
|
TextSensorInfo,
|
||||||
TextSensorState,
|
TextSensorState,
|
||||||
|
UserService,
|
||||||
)
|
)
|
||||||
|
|
||||||
from homeassistant.components.sensor import ATTR_STATE_CLASS, SensorStateClass
|
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.core import HomeAssistant
|
||||||
from homeassistant.helpers import entity_registry as er
|
from homeassistant.helpers import entity_registry as er
|
||||||
from homeassistant.helpers.entity import EntityCategory
|
from homeassistant.helpers.entity import EntityCategory
|
||||||
|
|
||||||
|
from .conftest import MockESPHomeDevice
|
||||||
|
|
||||||
|
|
||||||
async def test_generic_numeric_sensor(
|
async def test_generic_numeric_sensor(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
mock_client: APIClient,
|
mock_client: APIClient,
|
||||||
mock_generic_device_entry,
|
mock_esphome_device: Callable[
|
||||||
|
[APIClient, list[EntityInfo], list[UserService], list[EntityState]],
|
||||||
|
Awaitable[MockESPHomeDevice],
|
||||||
|
],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test a generic sensor entity."""
|
"""Test a generic sensor entity."""
|
||||||
|
logging.getLogger("homeassistant.components.esphome").setLevel(logging.DEBUG)
|
||||||
entity_info = [
|
entity_info = [
|
||||||
SensorInfo(
|
SensorInfo(
|
||||||
object_id="mysensor",
|
object_id="mysensor",
|
||||||
|
@ -35,7 +50,7 @@ async def test_generic_numeric_sensor(
|
||||||
]
|
]
|
||||||
states = [SensorState(key=1, state=50)]
|
states = [SensorState(key=1, state=50)]
|
||||||
user_service = []
|
user_service = []
|
||||||
await mock_generic_device_entry(
|
mock_device = await mock_esphome_device(
|
||||||
mock_client=mock_client,
|
mock_client=mock_client,
|
||||||
entity_info=entity_info,
|
entity_info=entity_info,
|
||||||
user_service=user_service,
|
user_service=user_service,
|
||||||
|
@ -45,6 +60,34 @@ async def test_generic_numeric_sensor(
|
||||||
assert state is not None
|
assert state is not None
|
||||||
assert state.state == "50"
|
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(
|
async def test_generic_numeric_sensor_with_entity_category_and_icon(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue