Fix has_entity_name not always being set in ESPHome (#97055)

This commit is contained in:
J. Nick Koston 2023-07-23 03:45:48 -05:00 committed by GitHub
parent bf66dc7a91
commit 095146b163
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 120 additions and 34 deletions

View File

@ -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/*

View File

@ -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),
)

View File

@ -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)}
)

View File

@ -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

View File

@ -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,

View File

@ -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."""

View File

@ -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

View File

@ -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,