diff --git a/homeassistant/components/config/device_registry.py b/homeassistant/components/config/device_registry.py index 6134f42e3c453e..425cf3ba9412e6 100644 --- a/homeassistant/components/config/device_registry.py +++ b/homeassistant/components/config/device_registry.py @@ -190,6 +190,10 @@ async def websocket_remove_config_entry_from_device( config_entry_id = msg["config_entry_id"] device_id = msg["device_id"] + # A composite device id has no single underlying device to remove; reject it. + if registry.async_is_composite_device_id(device_id): + raise HomeAssistantError("Cannot remove a composite device") + if (config_entry := hass.config_entries.async_get_entry(config_entry_id)) is None: raise HomeAssistantError("Unknown config entry") @@ -215,14 +219,8 @@ async def websocket_remove_config_entry_from_device( "Failed to remove device entry, rejected by integration" ) - # Integration might have removed the config entry already, that is fine. + # The integration might have removed the device already, that is fine. if registry.async_get(device_id): - entry = registry.async_update_device( - device_id, remove_config_entry_id=config_entry_id - ) - - entry_as_dict = entry.dict_repr if entry else None - else: - entry_as_dict = None + registry.async_remove_device(device_id) - connection.send_message(websocket_api.result_message(msg["id"], entry_as_dict)) + connection.send_message(websocket_api.result_message(msg["id"], None)) diff --git a/homeassistant/components/daikin/manifest.json b/homeassistant/components/daikin/manifest.json index 904afa47078284..c406cf0ddb008c 100644 --- a/homeassistant/components/daikin/manifest.json +++ b/homeassistant/components/daikin/manifest.json @@ -7,6 +7,6 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["pydaikin"], - "requirements": ["pydaikin==2.18.4"], + "requirements": ["pydaikin==2.18.5"], "zeroconf": ["_dkapi._tcp.local."] } diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index 6dff1f12f51215..bec555e7e6d2ac 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -12,6 +12,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.reload import async_integration_yaml_config from homeassistant.helpers.storage import STORAGE_DIR @@ -286,6 +287,15 @@ async def async_remove_config_entry_device( ): # can not remove interface device return False + ui_unique_ids = knx_module.config_store.get_entity_uids() + entity_registry = er.async_get(hass) + if any( + entity.config_entry_id == config_entry.entry_id + and entity.unique_id not in ui_unique_ids + for entity in er.async_entries_for_device(entity_registry, device_entry.id) + ): + # device still has YAML-configured KNX entities; it would be recreated after reload + return False for entity in knx_module.config_store.get_entity_entries(): if entity.device_id == device_entry.id: await knx_module.config_store.delete_entity(entity.entity_id) diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py index 68b6f55bf5eda3..5e7019e209c05c 100644 --- a/homeassistant/components/knx/const.py +++ b/homeassistant/components/knx/const.py @@ -19,6 +19,11 @@ DOMAIN: Final = "knx" KNX_MODULE_KEY: HassKey[KNXModule] = HassKey(DOMAIN) +# Prefix of device identifiers created via the `knx/create_device` websocket +# command (see websocket.py). A YAML `device.id` matching this prefix is +# assumed to reference such a device verbatim and is not slugified. +UI_DEVICE_ID_PREFIX: Final = "knx_vdev_" + # Address is used for configuration and services by the # same functions so the key has to match KNX_ADDRESS: Final = "address" diff --git a/homeassistant/components/knx/entity.py b/homeassistant/components/knx/entity.py index a0272c26688546..3cce1ea6b3ff5e 100644 --- a/homeassistant/components/knx/entity.py +++ b/homeassistant/components/knx/entity.py @@ -6,7 +6,13 @@ from xknx.devices import Device as XknxDevice from xknx.telegram.address import DeviceGroupAddress, GroupAddress -from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME, EntityCategory +from homeassistant.const import ( + CONF_DEVICE, + CONF_ENTITY_CATEGORY, + CONF_ID, + CONF_NAME, + EntityCategory, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo @@ -115,6 +121,7 @@ async def update_entity( class _KnxEntityBase(Entity): """Representation of a KNX entity.""" + _attr_has_entity_name = True _attr_should_poll = False _attr_unique_id: str @@ -195,6 +202,18 @@ def __init__( self._attr_unique_id = new_unique_id self._attr_entity_category = entity_config.get(CONF_ENTITY_CATEGORY) + if device := entity_config.get(CONF_DEVICE): + # Entities sharing the same `device` `id` are grouped into one + # device. `id` is normalized in the schema (`_device_id`), which + # also lets YAML entities join a UI-created device by referencing + # its identifier verbatim. + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device[CONF_ID])}, + manufacturer="KNX", + ) + if device_name := device.get(CONF_NAME): + self._attr_device_info["name"] = device_name + default_entity_id: str | None if (default_entity_id := entity_config.get(CONF_DEFAULT_ENTITY_ID)) is not None: self.entity_id = default_entity_id @@ -203,8 +222,6 @@ def __init__( class KnxUiEntity(_KnxEntityBase): """Representation of a KNX UI entity.""" - _attr_has_entity_name = True - def __init__( self, knx_module: KNXModule, unique_id: str, entity_config: dict[str, Any] ) -> None: diff --git a/homeassistant/components/knx/quality_scale.yaml b/homeassistant/components/knx/quality_scale.yaml index dc4be57f8b2e5d..d7cf44744f8bfe 100644 --- a/homeassistant/components/knx/quality_scale.yaml +++ b/homeassistant/components/knx/quality_scale.yaml @@ -20,10 +20,7 @@ rules: docs-triggers: done entity-event-setup: done entity-unique-id: done - has-entity-name: - status: exempt - comment: | - YAML entities don't support devices. UI entities do and use `has_entity_name`. + has-entity-name: done runtime-data: status: exempt comment: | @@ -63,10 +60,7 @@ rules: Integration has no authentication. test-coverage: done # Gold - devices: - status: exempt - comment: | - YAML entities don't support devices. UI entities support user-defined devices. + devices: done diagnostics: done discovery-update-info: status: exempt diff --git a/homeassistant/components/knx/schema.py b/homeassistant/components/knx/schema.py index 5b819e5b13cfc0..bbd624c9292bc3 100644 --- a/homeassistant/components/knx/schema.py +++ b/homeassistant/components/knx/schema.py @@ -32,10 +32,12 @@ ) from homeassistant.components.text import TextMode from homeassistant.const import ( + CONF_DEVICE, CONF_DEVICE_CLASS, CONF_ENTITY_CATEGORY, CONF_ENTITY_ID, CONF_EVENT, + CONF_ID, CONF_MODE, CONF_NAME, CONF_PAYLOAD, @@ -46,6 +48,7 @@ ) from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import ENTITY_CATEGORIES_SCHEMA +from homeassistant.util import slugify from .const import ( CONF_CONTEXT_TIMEOUT, @@ -60,6 +63,7 @@ CONF_SYNC_STATE, CONF_VALUE, KNX_ADDRESS, + UI_DEVICE_ID_PREFIX, ClimateConf, ColorTempModes, CoverConf, @@ -204,11 +208,34 @@ def platform_node(cls) -> dict[vol.Optional, vol.All]: } +def _device_id(value: str) -> str: + """Normalize a YAML device id. + + A value matching the identifier of a device created in the UI (see + `UI_DEVICE_ID_PREFIX`) is passed through verbatim, so it keeps linking to + that device. Any other value is slugified so ids that only differ in + case or whitespace resolve to the same device instead of silently + creating a separate one. + """ + value = value.strip() + if value.startswith(UI_DEVICE_ID_PREFIX): + return value + return slugify(value) + + def _entity_base_schema(platform: Platform) -> vol.Schema: """Return a base schema for KNX entities.""" return vol.Schema( { vol.Optional(CONF_NAME, default=""): cv.string, + vol.Optional(CONF_DEVICE): vol.Schema( + { + vol.Required(CONF_ID): vol.All( + cv.string, _device_id, vol.Length(min=1) + ), + vol.Optional(CONF_NAME): cv.string, + } + ), vol.Optional(CONF_DEFAULT_ENTITY_ID): vol.All( cv.entity_id, cv.entity_domain(platform) ), diff --git a/homeassistant/components/knx/storage/config_store.py b/homeassistant/components/knx/storage/config_store.py index b474989e0fd7c3..2c8a19c54b12a3 100644 --- a/homeassistant/components/knx/storage/config_store.py +++ b/homeassistant/components/knx/storage/config_store.py @@ -181,12 +181,14 @@ async def delete_entity(self, entity_id: str) -> None: entity_registry.async_remove(entity_id) await self._store.async_save(self.data) + def get_entity_uids(self) -> set[str]: + """Return unique_ids of all UI configured entities.""" + return {uid for platform in self.data["entities"].values() for uid in platform} + def get_entity_entries(self) -> list[er.RegistryEntry]: """Get entity_ids of all UI configured entities.""" entity_registry = er.async_get(self.hass) - unique_ids = { - uid for platform in self.data["entities"].values() for uid in platform - } + unique_ids = self.get_entity_uids() return [ registry_entry for registry_entry in er.async_entries_for_config_entry( diff --git a/homeassistant/components/knx/websocket.py b/homeassistant/components/knx/websocket.py index 568de4fe8220c3..1fd9aae523a0c4 100644 --- a/homeassistant/components/knx/websocket.py +++ b/homeassistant/components/knx/websocket.py @@ -36,6 +36,7 @@ SIGNAL_KNX_DATA_SECURE_ISSUE_TELEGRAM, SIGNAL_KNX_TELEGRAM, SUPPORTED_PLATFORMS_UI, + UI_DEVICE_ID_PREFIX, ) from .dpt import get_supported_dpts from .storage.config_store import ConfigStoreException @@ -699,7 +700,7 @@ def ws_create_device( msg: dict, ) -> None: """Create a new KNX device.""" - identifier = f"knx_vdev_{ulid_now()}" + identifier = f"{UI_DEVICE_ID_PREFIX}{ulid_now()}" device_registry = dr.async_get(hass) _device = device_registry.async_get_or_create( config_entry_id=knx.entry.entry_id, diff --git a/homeassistant/components/lyngdorf/manifest.json b/homeassistant/components/lyngdorf/manifest.json index 88e43b877e93a5..2e6d61ea1cb9f3 100644 --- a/homeassistant/components/lyngdorf/manifest.json +++ b/homeassistant/components/lyngdorf/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_push", "loggers": ["lyngdorf", "async_upnp_client"], "quality_scale": "silver", - "requirements": ["lyngdorf==1.4.4"], + "requirements": ["lyngdorf==1.4.8"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:2", diff --git a/homeassistant/components/tado/coordinator.py b/homeassistant/components/tado/coordinator.py index 9be21aab8867a6..57b29fa3657103 100644 --- a/homeassistant/components/tado/coordinator.py +++ b/homeassistant/components/tado/coordinator.py @@ -122,6 +122,7 @@ def _load_tado_data() -> tuple[dict, list, list]: self.data["zone"] = zones self.data["weather"] = home["weather"] self.data["geofence"] = home["geofence"] + self.data["rate_limit"] = self.get_rate_limit() refresh_token = await self.hass.async_add_executor_job( self._tado.get_refresh_token diff --git a/requirements_all.txt b/requirements_all.txt index a48b88b16ef244..cce18747365e41 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1534,7 +1534,7 @@ lw12==0.9.2 lxml==6.1.1 # homeassistant.components.lyngdorf -lyngdorf==1.4.4 +lyngdorf==1.4.8 # homeassistant.components.matrix matrix-nio==0.26.0 @@ -2116,7 +2116,7 @@ pycsspeechtts==1.0.8 pycync==0.5.0 # homeassistant.components.daikin -pydaikin==2.18.4 +pydaikin==2.18.5 # homeassistant.components.danfoss_air pydanfossair==0.1.0 diff --git a/tests/components/config/test_device_registry.py b/tests/components/config/test_device_registry.py index 5d6700abf5ca3a..2b2996b43117c3 100644 --- a/tests/components/config/test_device_registry.py +++ b/tests/components/config/test_device_registry.py @@ -604,9 +604,7 @@ async def async_remove_config_entry_device( hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry ) -> bool: if can_remove: - device_registry.async_update_device( - device_entry.id, remove_config_entry_id=config_entry.entry_id - ) + device_registry.async_remove_device(device_entry.id) return can_remove mock_integration( @@ -677,6 +675,51 @@ async def async_remove_config_entry_device( } +@pytest.mark.parametrize("load_registries", [False]) +async def test_remove_config_entry_from_composite_device( + hass: HomeAssistant, + client: MockHAClientWebSocket, + hass_storage: dict[str, Any], +) -> None: + """Test removing a config entry from a pre-migration composite device id fails.""" + entry_1 = MockConfigEntry() + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry() + entry_2.add_to_hass(hass) + + composite_id = "compositea000000000000000000000" + hass_storage[dr.STORAGE_KEY] = { + "version": 1, + "minor_version": 12, + "key": dr.STORAGE_KEY, + "data": { + "devices": [ + # Composite spanning two config entries; splitting it on load removes + # the composite device, so composite_id no longer refers to a device + _storage_device_v1_12( + composite_id, + [entry_1.entry_id, entry_2.entry_id], + entry_1.entry_id, + "a", + ), + ], + "deleted_devices": [], + }, + } + + dr.async_setup(hass) + await dr.async_load(hass) + # pylint: disable-next=home-assistant-tests-registry-fixtures + registry = dr.async_get(hass) + assert registry.async_is_composite_device_id(composite_id) is True + + response = await client.remove_device(composite_id, entry_1.entry_id) + + assert not response["success"] + assert response["error"]["code"] == "home_assistant_error" + assert response["error"]["message"] == "Cannot remove a composite device" + + async def test_list_linked_devices( hass: HomeAssistant, client: MockHAClientWebSocket, diff --git a/tests/components/duco/test_select.py b/tests/components/duco/test_select.py index e0c2abbcff3304..a20d281c57cb13 100644 --- a/tests/components/duco/test_select.py +++ b/tests/components/duco/test_select.py @@ -35,6 +35,8 @@ _SELECT_ENTITY = "select.living_ventilation_state" _VALVE_SELECT_ENTITY = "select.bedroom_valve_ventilation_state" _UNSUPPORTED_SELECT_ENTITY = "select.office_co2_ventilation_state" +# Node 50 "Kitchen RH" (a non-box satellite node) repurposed as a controllable node. +_CONTROLLABLE_SELECT_ENTITY = "select.kitchen_rh_ventilation_state" def _build_node_actions( @@ -144,12 +146,15 @@ async def test_select_creates_entities_for_controllable_valve_nodes( valve_node_type: NodeType, ) -> None: """Test select discovery includes valve nodes when they advertise control.""" + # Mutate a non-box node (node 50 "Kitchen RH", index 3); mutating the box + # node would make its via_device link resolve to itself. mock_nodes = [ + *mock_sensor_nodes[:3], replace( - mock_sensor_nodes[0], - general=replace(mock_sensor_nodes[0].general, node_type=valve_node_type), + mock_sensor_nodes[3], + general=replace(mock_sensor_nodes[3].general, node_type=valve_node_type), ), - *mock_sensor_nodes[1:], + *mock_sensor_nodes[4:], ] mock_duco_client.async_get_nodes.return_value = mock_nodes mock_duco_client.async_get_node_actions.return_value = _build_multi_node_actions( @@ -159,7 +164,7 @@ async def test_select_creates_entities_for_controllable_valve_nodes( await setup_platform_integration(hass, mock_config_entry, [Platform.SELECT]) - assert hass.states.get(_SELECT_ENTITY) is not None + assert hass.states.get(_CONTROLLABLE_SELECT_ENTITY) is not None valve_state = hass.states.get(_VALVE_SELECT_ENTITY) assert valve_state is not None assert valve_state.attributes[ATTR_OPTIONS] == [ diff --git a/tests/components/duco/test_sensor.py b/tests/components/duco/test_sensor.py index 698b1d12d507cf..633b8e0b3bc0c8 100644 --- a/tests/components/duco/test_sensor.py +++ b/tests/components/duco/test_sensor.py @@ -41,7 +41,6 @@ @pytest.mark.parametrize( "ventilation_node_type", [ - pytest.param(NodeType.BOX, id="box"), pytest.param(NodeType.VLV, id="vlv"), pytest.param(NodeType.VLVRH, id="vlvrh"), pytest.param(NodeType.VLVVOC, id="vlvvoc"), @@ -60,10 +59,57 @@ async def test_ventilation_related_sensors_created_for_supported_node_types( mock_sensor_nodes: list[Node], ventilation_node_type: NodeType, ) -> None: - """Test ventilation-related sensors are created for supported node families.""" + """Test ventilation-related sensors are created for supported non-box node families.""" + # Mutate a non-box node (node 50 "Kitchen RH", index 3); mutating the box + # node would make its via_device link resolve to itself. supported_node = replace( + mock_sensor_nodes[3], + general=replace(mock_sensor_nodes[3].general, node_type=ventilation_node_type), + ventilation=replace( + mock_sensor_nodes[3].ventilation, + flow_lvl_tgt=42, + time_state_end=1700000459, + ), + ) + mock_duco_client.async_get_nodes.return_value = [ + *mock_sensor_nodes[:3], + supported_node, + *mock_sensor_nodes[4:], + ] + + await setup_platform_integration(hass, mock_config_entry, [Platform.SENSOR]) + + state = hass.states.get("sensor.kitchen_rh_ventilation_state") + assert state is not None + assert state.state == "auto" + + state = hass.states.get("sensor.kitchen_rh_target_flow_level") + assert state is not None + assert state.state == "42" + + state = hass.states.get("sensor.kitchen_rh_state_end_time") + assert state is not None + assert state.state == "2023-11-14T22:20:59+00:00" + + assert hass.states.get("sensor.office_co2_ventilation_state") is None + assert hass.states.get("sensor.office_co2_target_flow_level") is None + assert hass.states.get("sensor.office_co2_state_end_time") is None + + +async def test_ventilation_related_sensors_created_for_box_node( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_duco_client: AsyncMock, + mock_sensor_nodes: list[Node], +) -> None: + """Test ventilation-related sensors are created for the box node. + + The box (node 1) keeps its own device; unlike the satellite nodes it sets no + via_device, so it is exercised here on the real box node rather than by mutating + a satellite into a second box that would merge with the controller device. + """ + box_node = replace( mock_sensor_nodes[0], - general=replace(mock_sensor_nodes[0].general, node_type=ventilation_node_type), ventilation=replace( mock_sensor_nodes[0].ventilation, flow_lvl_tgt=42, @@ -71,7 +117,7 @@ async def test_ventilation_related_sensors_created_for_supported_node_types( ), ) mock_duco_client.async_get_nodes.return_value = [ - supported_node, + box_node, *mock_sensor_nodes[1:], ] diff --git a/tests/components/fritz/test_coordinator.py b/tests/components/fritz/test_coordinator.py index 9a12c4f1f21ed0..39eaf06a2a5ed6 100644 --- a/tests/components/fritz/test_coordinator.py +++ b/tests/components/fritz/test_coordinator.py @@ -724,8 +724,8 @@ async def test_old_discovery_does_not_self_reference_box( assert entry.state is ConfigEntryState.LOADED - router = device_registry.async_get_device( - identifiers={(DOMAIN, MOCK_SERIAL_NUMBER)} + router = device_registry.async_get_device_by_identifier( + (DOMAIN, MOCK_SERIAL_NUMBER), entry.entry_id ) assert router is not None assert router.via_device_id is None diff --git a/tests/components/knx/test_device.py b/tests/components/knx/test_device.py index 3f41644e6a6394..227c928c7baeb5 100644 --- a/tests/components/knx/test_device.py +++ b/tests/components/knx/test_device.py @@ -1,17 +1,22 @@ """Test KNX devices.""" from typing import Any +from unittest.mock import AsyncMock, Mock, patch -from homeassistant.components.knx.const import DOMAIN +import pytest + +from homeassistant.components.knx.const import DOMAIN, KNX_ADDRESS from homeassistant.components.knx.storage.config_store import ( STORAGE_KEY as KNX_CONFIG_STORAGE_KEY, ) +from homeassistant.const import SERVICE_RELOAD, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -from .conftest import KNXTestKit +from .conftest import KNXTestKit, _patch_telegram_store +from tests.common import MockConfigEntry from tests.typing import WebSocketGenerator @@ -79,3 +84,154 @@ async def test_remove_device( ) assert not entity_registry.entities.get_entries_for_device_id(device_id) assert not hass_storage[KNX_CONFIG_STORAGE_KEY]["data"]["entities"].get("switch") + + +async def test_remove_yaml_device_blocked( + hass: HomeAssistant, + knx: KNXTestKit, + device_registry: dr.DeviceRegistry, + hass_ws_client: WebSocketGenerator, +) -> None: + """A device with YAML-configured entities can not be removed from the UI.""" + assert await async_setup_component(hass, "config", {}) + await knx.setup_integration( + { + Platform.SWITCH: { + "name": "test", + KNX_ADDRESS: "1/1/1", + "device": {"id": "living_room", "name": "Living room"}, + } + } + ) + client = await hass_ws_client(hass) + + device = device_registry.async_get_device_by_identifier( + (DOMAIN, "living_room"), knx.mock_config_entry.entry_id + ) + assert device is not None + + response = await client.remove_device(device.id, knx.mock_config_entry.entry_id) + assert not response["success"] + assert device_registry.async_get_device_by_identifier( + (DOMAIN, "living_room"), knx.mock_config_entry.entry_id + ) + + +async def test_remove_device_ignores_foreign_platform_entities( + hass: HomeAssistant, + knx: KNXTestKit, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, +) -> None: + """An entity from another integration on the device does not block removal.""" + assert await async_setup_component(hass, "config", {}) + await knx.setup_integration() + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "knx/create_device", "name": "Test Device"}) + res = await client.receive_json() + assert res["success"], res + device_id = res["result"]["id"] + + entity_registry.async_get_or_create( + "sensor", + "other_integration", + "other_unique_id", + device_id=device_id, + ) + + response = await client.remove_device(device_id, knx.mock_config_entry.entry_id) + assert response["success"] + assert not device_registry.async_get(device_id) + + +async def test_remove_device_ignores_other_config_entry_knx_entities( + hass: HomeAssistant, + knx: KNXTestKit, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, +) -> None: + """A knx-platform entity owned by a different config entry does not block removal.""" + assert await async_setup_component(hass, "config", {}) + await knx.setup_integration() + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "knx/create_device", "name": "Test Device"}) + res = await client.receive_json() + assert res["success"], res + device_id = res["result"]["id"] + + other_entry = MockConfigEntry(domain=DOMAIN) + other_entry.add_to_hass(hass) + entity_registry.async_get_or_create( + "sensor", + DOMAIN, + "other_config_entry_unique_id", + config_entry=other_entry, + device_id=device_id, + ) + + response = await client.remove_device(device_id, knx.mock_config_entry.entry_id) + assert response["success"] + assert not device_registry.async_get(device_id) + + +@pytest.mark.parametrize("entity_count", [1, 2]) +async def test_yaml_device_name_updates_on_reload( + hass: HomeAssistant, + knx: KNXTestKit, + device_registry: dr.DeviceRegistry, + entity_count: int, +) -> None: + """Renaming a YAML `device` and reloading updates the device registry. + + The current YAML `name` wins on every (re-)setup - it is not fixed to + whatever was configured when the device was first created. This holds + regardless of how many entities reference the device. + """ + + def _config(device_name: str) -> dict[str, Any]: + entities = [ + { + "name": "a", + KNX_ADDRESS: "1/1/1", + "device": {"id": "as_df", "name": device_name}, + } + ] + if entity_count > 1: + # No `name` here: this entity must not block the rename. + entities.append( + {"name": "b", KNX_ADDRESS: "1/1/2", "device": {"id": "as_df"}} + ) + return {Platform.SWITCH: entities} + + await knx.setup_integration(_config("Initial name")) + + device = device_registry.async_get_device_by_identifier( + (DOMAIN, "as_df"), knx.mock_config_entry.entry_id + ) + assert device is not None + assert device.name == "Initial name" + device_id = device.id + + with ( + patch( + "homeassistant.config.async_hass_config_yaml", + AsyncMock(return_value={DOMAIN: _config("My device")}), + ), + _patch_telegram_store(real_store=False), + patch( + "xknx.xknx.knx_interface_factory", + return_value=Mock( + start=AsyncMock(), stop=AsyncMock(), gateway_info=AsyncMock() + ), + ), + ): + await hass.services.async_call(DOMAIN, SERVICE_RELOAD, blocking=True) + + # Same device is updated in place, not duplicated. + device = device_registry.async_get(device_id) + assert device is not None + assert device.name == "My device" diff --git a/tests/components/knx/test_entity.py b/tests/components/knx/test_entity.py index ec2ace10469449..522a6a0f92770e 100644 --- a/tests/components/knx/test_entity.py +++ b/tests/components/knx/test_entity.py @@ -4,10 +4,10 @@ import pytest -from homeassistant.components.knx.const import KNX_ADDRESS +from homeassistant.components.knx.const import DOMAIN, KNX_ADDRESS from homeassistant.const import STATE_UNKNOWN, EntityCategory, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from .conftest import KNXTestKit @@ -116,3 +116,124 @@ async def test_yaml_entity_category( entity = entity_registry.async_get("switch.test") assert entity.entity_category is expected_entity_category + + +async def test_yaml_entity_device_grouping( + hass: HomeAssistant, + knx: KNXTestKit, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """Entities sharing a `device` name are grouped into a single device.""" + await knx.setup_integration( + { + Platform.SWITCH: [ + { + "default_entity_id": "switch.a", + KNX_ADDRESS: "1/1/1", + "device": {"id": "living_room", "name": "Living room"}, + }, + { + "default_entity_id": "switch.b", + KNX_ADDRESS: "1/1/2", + "device": {"id": "living_room", "name": "Living room"}, + }, + { + "default_entity_id": "switch.c", + KNX_ADDRESS: "1/1/3", + "device": {"id": "bedroom"}, # name is optional + }, + {"default_entity_id": "switch.d", KNX_ADDRESS: "1/1/4"}, + ] + } + ) + + entity_a = entity_registry.async_get("switch.a") + entity_b = entity_registry.async_get("switch.b") + entity_c = entity_registry.async_get("switch.c") + entity_d = entity_registry.async_get("switch.d") + + assert entity_a.device_id is not None + assert entity_a.device_id == entity_b.device_id + assert entity_c.device_id not in (None, entity_a.device_id) + assert entity_d.device_id is None + + device = device_registry.async_get(entity_a.device_id) + assert device.name == "Living room" + assert device.manufacturer == "KNX" + assert (DOMAIN, "living_room") in device.identifiers + + # `name` is optional; the device is still created keyed by its `id`. + bedroom = device_registry.async_get(entity_c.device_id) + assert (DOMAIN, "bedroom") in bedroom.identifiers + + +@pytest.mark.parametrize( + ("config", "expected_friendly_name"), + [ + ( + { + "name": "Ceiling light", + KNX_ADDRESS: "1/1/1", + "device": {"id": "kitchen", "name": "Kitchen"}, + }, + "Kitchen Ceiling light", + ), + ( + { + KNX_ADDRESS: "1/1/1", + "device": {"id": "kitchen", "name": "Kitchen"}, + }, + "Kitchen", # no entity name: entity is the device's main feature + ), + ( + {"name": "Ceiling light", KNX_ADDRESS: "1/1/1"}, + "Ceiling light", # no device: name is shown verbatim, as before + ), + ], +) +async def test_yaml_entity_device_naming( + hass: HomeAssistant, + knx: KNXTestKit, + config: dict[str, Any], + expected_friendly_name: str, +) -> None: + """An entity on a device uses has_entity_name naming, like a UI entity.""" + await knx.setup_integration({Platform.SWITCH: config}) + states = hass.states.async_all("switch") + assert len(states) == 1 + assert states[0].attributes["friendly_name"] == expected_friendly_name + + +@pytest.mark.parametrize( + ("device_id", "expected_identifier"), + [ + ("Living Room", "living_room"), + ("living room", "living_room"), + (" living_room ", "living_room"), + # A UI device identifier is preserved verbatim, not lower-cased. + ("knx_vdev_ABC123", "knx_vdev_ABC123"), + # Surrounding whitespace is stripped before the UI-prefix check. + (" knx_vdev_ABC123 ", "knx_vdev_ABC123"), + ], +) +async def test_yaml_device_id_normalization( + hass: HomeAssistant, + knx: KNXTestKit, + device_registry: dr.DeviceRegistry, + device_id: str, + expected_identifier: str, +) -> None: + """A YAML device `id` is slugified unless it is a UI device identifier.""" + await knx.setup_integration( + { + Platform.SWITCH: { + "name": "test", + KNX_ADDRESS: "1/1/1", + "device": {"id": device_id}, + } + } + ) + assert device_registry.async_get_device_by_identifier( + (DOMAIN, expected_identifier), knx.mock_config_entry.entry_id + ) diff --git a/tests/components/mobile_app/test_webhook.py b/tests/components/mobile_app/test_webhook.py index 5fbf501cb839c9..3c1ec10c93e104 100644 --- a/tests/components/mobile_app/test_webhook.py +++ b/tests/components/mobile_app/test_webhook.py @@ -1098,7 +1098,9 @@ async def test_webhook_handle_scan_tag( webhook_client: TestClient, ) -> None: """Test that we can scan tags.""" - device = device_registry.async_get_device(identifiers={(DOMAIN, "mock-device-id")}) + [device] = device_registry.async_get_devices( + identifiers={(DOMAIN, "mock-device-id")} + ) assert device is not None events = async_capture_events(hass, EVENT_TAG_SCANNED) diff --git a/tests/components/mqtt/test_device_trigger.py b/tests/components/mqtt/test_device_trigger.py index 2f090ceda68909..bd397c21f9e683 100644 --- a/tests/components/mqtt/test_device_trigger.py +++ b/tests/components/mqtt/test_device_trigger.py @@ -1348,7 +1348,9 @@ async def test_entity_device_info_with_via_device( async_fire_mqtt_message(hass, "homeassistant/device_automation/bla/config", data) await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={("mqtt", "helloworld")}) + device = device_registry.async_get_device_by_identifier( + ("mqtt", "helloworld"), mqtt_config_entry.entry_id + ) assert device is not None assert device.via_device_id == hub.id diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index f5ea13afda1815..44d7de23922230 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -3292,7 +3292,9 @@ async def test_discovery_with_late_via_device_discovery( # The child device links to the stub via device by via_device_id stub_id = via_device_entry.id - child_device_entry = device_registry.async_get_device({("mqtt", "0AFFD2")}) + child_device_entry = device_registry.async_get_device_by_identifier( + ("mqtt", "0AFFD2"), hass.config_entries.async_entries("mqtt")[0].entry_id + ) assert child_device_entry is not None assert child_device_entry.via_device_id == stub_id @@ -3321,7 +3323,9 @@ async def test_discovery_with_late_via_device_discovery( # The stub merges into the announced device, keeping its id, so the link # from the child device survives assert via_device_entry.id == stub_id - child_device_entry = device_registry.async_get_device({("mqtt", "0AFFD2")}) + child_device_entry = device_registry.async_get_device_by_identifier( + ("mqtt", "0AFFD2"), hass.config_entries.async_entries("mqtt")[0].entry_id + ) assert child_device_entry is not None assert child_device_entry.via_device_id == stub_id @@ -3384,7 +3388,9 @@ async def test_discovery_with_late_via_device_update( # The discovery update established the via_device_id link on the child device stub_id = via_device_entry.id - child_device_entry = device_registry.async_get_device({("mqtt", "0AFFD2")}) + child_device_entry = device_registry.async_get_device_by_identifier( + ("mqtt", "0AFFD2"), hass.config_entries.async_entries("mqtt")[0].entry_id + ) assert child_device_entry is not None assert child_device_entry.via_device_id == stub_id @@ -3411,7 +3417,9 @@ async def test_discovery_with_late_via_device_update( assert via_device_entry is not None assert via_device_entry.name == "My Switch" assert via_device_entry.id == stub_id - child_device_entry = device_registry.async_get_device({("mqtt", "0AFFD2")}) + child_device_entry = device_registry.async_get_device_by_identifier( + ("mqtt", "0AFFD2"), hass.config_entries.async_entries("mqtt")[0].entry_id + ) assert child_device_entry is not None assert child_device_entry.via_device_id == stub_id @@ -3447,8 +3455,12 @@ async def test_via_device_relinks_after_parent_removed( ) await hass.async_block_till_done() - parent = device_registry.async_get_device({("mqtt", "parent-id")}) - child = device_registry.async_get_device({("mqtt", "child-id")}) + parent = device_registry.async_get_device_by_identifier( + ("mqtt", "parent-id"), hass.config_entries.async_entries("mqtt")[0].entry_id + ) + child = device_registry.async_get_device_by_identifier( + ("mqtt", "child-id"), hass.config_entries.async_entries("mqtt")[0].entry_id + ) assert parent is not None assert child is not None assert child.via_device_id == parent.id @@ -3456,7 +3468,9 @@ async def test_via_device_relinks_after_parent_removed( # Removing the parent clears the child's via_device_id device_registry.async_remove_device(parent.id) await hass.async_block_till_done() - child = device_registry.async_get_device({("mqtt", "child-id")}) + child = device_registry.async_get_device_by_identifier( + ("mqtt", "child-id"), hass.config_entries.async_entries("mqtt")[0].entry_id + ) assert child is not None assert child.via_device_id is None @@ -3467,8 +3481,12 @@ async def test_via_device_relinks_after_parent_removed( ) await hass.async_block_till_done() - parent_stub = device_registry.async_get_device({("mqtt", "parent-id")}) - child = device_registry.async_get_device({("mqtt", "child-id")}) + parent_stub = device_registry.async_get_device_by_identifier( + ("mqtt", "parent-id"), hass.config_entries.async_entries("mqtt")[0].entry_id + ) + child = device_registry.async_get_device_by_identifier( + ("mqtt", "child-id"), hass.config_entries.async_entries("mqtt")[0].entry_id + ) assert parent_stub is not None assert child is not None assert child.via_device_id == parent_stub.id @@ -3496,7 +3514,9 @@ async def test_via_device_across_subentries( config_entry = hass.config_entries.async_entries(DOMAIN)[0] subentry_id = next(iter(config_entry.subentries)) - parent = device_registry.async_get_device({(DOMAIN, subentry_id)}) + parent = device_registry.async_get_device_by_identifier( + (DOMAIN, subentry_id), config_entry.entry_id + ) assert parent is not None assert parent.config_subentry_id == subentry_id @@ -3512,7 +3532,9 @@ async def test_via_device_across_subentries( ) await hass.async_block_till_done() - child = device_registry.async_get_device({(DOMAIN, "child-id")}) + child = device_registry.async_get_device_by_identifier( + (DOMAIN, "child-id"), config_entry.entry_id + ) assert child is not None # The parent lives in a subentry and the discovered child does not, yet the # link resolves because lookups are scoped to the config entry. diff --git a/tests/components/mqtt/test_tag.py b/tests/components/mqtt/test_tag.py index 0d6d0a4f888b6d..924bda3e909fab 100644 --- a/tests/components/mqtt/test_tag.py +++ b/tests/components/mqtt/test_tag.py @@ -554,7 +554,9 @@ async def test_entity_device_info_with_via_device( async_fire_mqtt_message(hass, "homeassistant/tag/bla/config", data) await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={("mqtt", "helloworld")}) + device = device_registry.async_get_device_by_identifier( + ("mqtt", "helloworld"), mqtt_config_entry.entry_id + ) assert device is not None assert device.via_device_id == hub.id diff --git a/tests/components/tado/snapshots/test_diagnostics.ambr b/tests/components/tado/snapshots/test_diagnostics.ambr index 1ea74be559edb6..19745cf103b69f 100644 --- a/tests/components/tado/snapshots/test_diagnostics.ambr +++ b/tests/components/tado/snapshots/test_diagnostics.ambr @@ -76,6 +76,10 @@ 'presence': 'HOME', 'presenceLocked': False, }), + 'rate_limit': dict({ + 'per-day': 1000, + 'remaining': 100, + }), 'weather': dict({ 'outsideTemperature': dict({ 'celsius': 7.46,