From 41458a645df18f46d70e834141b81958bf21ed34 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Tue, 28 Jul 2026 17:36:49 +0200 Subject: [PATCH 01/14] Bump zwave-js-server-python to 0.73.0 (#177465) --- homeassistant/components/zwave_js/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 9b2529ca58f0f..0720ff978154a 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -9,7 +9,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["zwave_js_server"], - "requirements": ["zwave-js-server-python==0.72.0"], + "requirements": ["zwave-js-server-python==0.73.0"], "usb": [ { "known_devices": ["Aeotec Z-Stick Gen5+", "Z-WaveMe UZB"], diff --git a/requirements_all.txt b/requirements_all.txt index c1ceadbb33729..45859e837d5a2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3491,7 +3491,7 @@ zinvolt==1.0.0 zm-py==0.5.4 # homeassistant.components.zwave_js -zwave-js-server-python==0.72.0 +zwave-js-server-python==0.73.0 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 From b52e8646a819c47e8327a5b68d764b025d191d94 Mon Sep 17 00:00:00 2001 From: AlCalzone Date: Tue, 28 Jul 2026 17:45:26 +0200 Subject: [PATCH 02/14] Forward Z-Wave `interview progress` event (#174434) Co-authored-by: Martin Hjelmare --- homeassistant/components/zwave_js/api.py | 42 +++++++++++++++++ tests/components/zwave_js/test_api.py | 57 ++++++++++++++++++++++++ 2 files changed, 99 insertions(+) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 81fa9e7fa35e2..b7c5e2e92c797 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -777,6 +777,7 @@ def forward_node_added( node.on("interview started", forward_event), node.on("interview completed", forward_event), node.on("interview stage completed", forward_stage), + node.on("interview progress", forward_progress), node.on("interview failed", forward_event), ] unsubs.extend(interview_unsubs) @@ -813,6 +814,19 @@ def forward_stage(event: dict) -> None: ) ) + @callback + def forward_progress(event: dict) -> None: + connection.send_message( + websocket_api.event_message( + msg[ID], + { + "event": event["event"], + "stage": event["stage"], + "progress": event["progress"], + }, + ) + ) + @callback def node_found(event: dict) -> None: node = event["node"] @@ -1544,6 +1558,19 @@ def forward_stage(event: dict) -> None: ) ) + @callback + def forward_progress(event: dict) -> None: + connection.send_message( + websocket_api.event_message( + msg[ID], + { + "event": event["event"], + "stage": event["stage"], + "progress": event["progress"], + }, + ) + ) + @callback def node_found(event: dict) -> None: node = event["node"] @@ -1563,6 +1590,7 @@ def node_added(event: dict) -> None: node.on("interview started", forward_event), node.on("interview completed", forward_event), node.on("interview stage completed", forward_stage), + node.on("interview progress", forward_progress), node.on("interview failed", forward_event), ] unsubs.extend(interview_unsubs) @@ -1863,11 +1891,25 @@ def forward_stage(event: dict) -> None: ) ) + @callback + def forward_progress(event: dict) -> None: + connection.send_message( + websocket_api.event_message( + msg[ID], + { + "event": event["event"], + "stage": event["stage"], + "progress": event["progress"], + }, + ) + ) + connection.subscriptions[msg["id"]] = async_cleanup msg[DATA_UNSUBSCRIBE] = unsubs = [ node.on("interview started", forward_event), node.on("interview completed", forward_event), node.on("interview stage completed", forward_stage), + node.on("interview progress", forward_progress), node.on("interview failed", forward_event), ] diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index 7269e24503726..916f5e0d48718 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -743,6 +743,25 @@ async def test_add_node( assert msg["event"]["event"] == "interview stage completed" assert msg["event"]["stage"] == "NodeInfo" + event = Event( + type="interview progress", + data={ + "source": "node", + "event": "interview progress", + "nodeId": 67, + "stage": "CommandClasses", + "progress": 42.5, + "endpoint": 0, + "commandClass": 112, + }, + ) + client.driver.receive_event(event) + + msg = await ws_client.receive_json() + assert msg["event"]["event"] == "interview progress" + assert msg["event"]["stage"] == "CommandClasses" + assert msg["event"]["progress"] == 42.5 + event = Event( type="interview completed", data={"source": "node", "event": "interview completed", "nodeId": 67}, @@ -2160,6 +2179,25 @@ async def test_replace_failed_node( assert msg["event"]["event"] == "interview stage completed" assert msg["event"]["stage"] == "NodeInfo" + event = Event( + type="interview progress", + data={ + "source": "node", + "event": "interview progress", + "nodeId": 67, + "stage": "CommandClasses", + "progress": 42.5, + "endpoint": 0, + "commandClass": 112, + }, + ) + client.driver.receive_event(event) + + msg = await ws_client.receive_json() + assert msg["event"]["event"] == "interview progress" + assert msg["event"]["stage"] == "CommandClasses" + assert msg["event"]["progress"] == 42.5 + event = Event( type="interview completed", data={"source": "node", "event": "interview completed", "nodeId": 67}, @@ -2764,6 +2802,25 @@ async def test_refresh_node_info( assert msg["event"]["event"] == "interview stage completed" assert msg["event"]["stage"] == "NodeInfo" + event = Event( + type="interview progress", + data={ + "source": "node", + "event": "interview progress", + "nodeId": 52, + "stage": "CommandClasses", + "progress": 42.5, + "endpoint": 0, + "commandClass": 112, + }, + ) + client.driver.receive_event(event) + + msg = await ws_client.receive_json() + assert msg["event"]["event"] == "interview progress" + assert msg["event"]["stage"] == "CommandClasses" + assert msg["event"]["progress"] == 42.5 + event = Event( type="interview completed", data={"source": "node", "event": "interview completed", "nodeId": 52}, From c561563cf78c9b3fedd65925ace0bff663cf2a29 Mon Sep 17 00:00:00 2001 From: Daniel Albers Date: Tue, 28 Jul 2026 18:14:47 +0200 Subject: [PATCH 03/14] usb: Avoid importing aioesphomeapi in serial proxy stub (#174181) --- .../components/usb/serial_proxy_stub.py | 113 ++++++++++++++++-- 1 file changed, 102 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/usb/serial_proxy_stub.py b/homeassistant/components/usb/serial_proxy_stub.py index 95a085f48cc14..9409ff1850939 100644 --- a/homeassistant/components/usb/serial_proxy_stub.py +++ b/homeassistant/components/usb/serial_proxy_stub.py @@ -1,29 +1,120 @@ """ESPHome serial proxy URI handler stub for serialx.""" -from collections.abc import Callable -from typing import override +from collections.abc import Buffer, Callable +from typing import Unpack, override -from serialx import register_uri_handler -from serialx.platforms.serial_esphome import ESPHomeSerial, ESPHomeSerialTransport +from serialx import BaseSerial, BaseSerialTransport, ModemPins, register_uri_handler +from serialx.common import ConnectKwargs from homeassistant.core import Event, callback from homeassistant.exceptions import ConfigEntryNotReady +_NOT_READY = "ESPHome has not loaded yet" -class HassESPHomeSerialStub(ESPHomeSerial): - """ESPHomeSerial that throws `ConfigEntryNotReady` until ESPHome itself loads.""" + +class HassESPHomeSerialStub(BaseSerial): + """Serial stub that raises `ConfigEntryNotReady` until ESPHome itself loads.""" @override - async def _async_open(self) -> None: + def _open(self) -> None: """Open a connection.""" - raise ConfigEntryNotReady("ESPHome has not loaded yet") + raise ConfigEntryNotReady(_NOT_READY) + + @override + def _configure_port(self) -> None: + """Configure the serial port settings.""" + + @override + def _close(self) -> None: + """Close the serial port.""" + @property + @override + def is_open(self) -> bool: + """Return whether the serial port is open.""" + return False + + @override + def _get_modem_pins(self) -> ModemPins: + """Get modem control bits.""" + return ModemPins() + + @override + def _set_modem_pins(self, modem_pins: ModemPins) -> None: + """Set modem control bits.""" -class HassESPHomeSerialStubTransport(ESPHomeSerialTransport): - """Transport variant that constructs `HassESPHomeSerialStub`.""" + @override + def _readinto(self, b: Buffer, *, timeout: float | None) -> int: + """Read bytes from the serial port into a buffer.""" + return 0 + + @override + def _write(self, data: Buffer, *, timeout: float | None) -> int: + """Write bytes to the serial port.""" + return 0 + + @override + def _flush(self) -> None: + """Flush write buffers.""" + + @override + def _reset_read_buffer(self) -> None: + """Reset the read buffer.""" + + @override + def _reset_write_buffer(self) -> None: + """Reset the write buffer.""" + + @override + def num_unread_bytes(self) -> int: + """Return the number of bytes waiting to be read.""" + return 0 + + @override + def num_unwritten_bytes(self) -> int: + """Return the number of bytes waiting to be written.""" + return 0 + + +class HassESPHomeSerialStubTransport(BaseSerialTransport): + """Transport stub that raises `ConfigEntryNotReady` until ESPHome itself loads.""" transport_name = "esphome-hass" - _serial_cls = HassESPHomeSerialStub + + @override + async def _connect( + self, *, path: str | None = None, **kwargs: Unpack[ConnectKwargs] + ) -> None: + """Connect to the serial port.""" + raise ConfigEntryNotReady(_NOT_READY) + + @override + def close(self) -> None: + """Close the transport.""" + if self._closing: + return + self._closing = True + self._mark_user_closed() + # Resolve the closed waiter so `connect()`'s failure path doesn't hang. + self._call_protocol_connection_lost(None) + + @override + def abort(self) -> None: + """Abort the transport immediately.""" + self.close() + + @override + async def _flush(self) -> None: + """Flush write buffers.""" + + @override + async def _get_modem_pins(self) -> ModemPins: + """Get modem control bits.""" + return ModemPins() + + @override + async def _set_modem_pins(self, modem_pins: ModemPins) -> None: + """Set modem control bits.""" def register_serialx_transport() -> Callable[[Event], None]: From 6c938c6c799a9077a5b80deaebb277f019accc21 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Jul 2026 18:18:05 +0200 Subject: [PATCH 04/14] Adapt zwave_me to new device registry API (#176960) --- homeassistant/components/zwave_me/controller.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zwave_me/controller.py b/homeassistant/components/zwave_me/controller.py index 9e68b16883772..e9beebabaa0df 100644 --- a/homeassistant/components/zwave_me/controller.py +++ b/homeassistant/components/zwave_me/controller.py @@ -70,8 +70,9 @@ def on_device_destroy(self, device_id: str) -> None: def remove_stale_devices(self, registry: dr.DeviceRegistry): """Remove old-format devices in the registry.""" for device_id in self.device_ids: - device = registry.async_get_device( - identifiers={(DOMAIN, f"{self.config.unique_id}-{device_id}")} + device = registry.async_get_device_by_identifier( + (DOMAIN, f"{self.config.unique_id}-{device_id}"), + self.config.entry_id, ) if device is not None: registry.async_remove_device(device.id) From 6e459d8c914e930f58046fac77161465bd3a3b81 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:18:33 +0200 Subject: [PATCH 05/14] Return None from async_is_composite_device_id for unknown ids (#177473) --- homeassistant/components/template/entity.py | 4 +++- homeassistant/components/template/repairs.py | 7 ++++--- homeassistant/helpers/device_registry.py | 13 ++++++++----- tests/helpers/test_device_registry.py | 2 +- 4 files changed, 16 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/template/entity.py b/homeassistant/components/template/entity.py index 6bf941d3c0b08..e2c2709b84eef 100644 --- a/homeassistant/components/template/entity.py +++ b/homeassistant/components/template/entity.py @@ -90,7 +90,9 @@ def __init__( device_registry = dr.async_get(hass) if ( device_id := config.get(CONF_DEVICE_ID) - ) is not None and not device_registry.async_is_composite_device_id(device_id): + ) is not None and device_registry.async_is_composite_device_id( + device_id + ) is False: self.device_entry = device_registry.async_get(device_id) @property diff --git a/homeassistant/components/template/repairs.py b/homeassistant/components/template/repairs.py index 276ea269f7d49..3a95eb11a349f 100644 --- a/homeassistant/components/template/repairs.py +++ b/homeassistant/components/template/repairs.py @@ -41,8 +41,9 @@ async def async_step_select_device( errors: dict[str, str] = {} if user_input is not None: device_id = user_input.get(CONF_DEVICE_ID) - if device_id is None or not device_registry.async_is_composite_device_id( - device_id + if ( + device_id is None + or device_registry.async_is_composite_device_id(device_id) is False ): options = {**entry.options} if device_id: @@ -52,7 +53,7 @@ async def async_step_select_device( self.hass.config_entries.async_update_entry(entry, options=options) await self.hass.config_entries.async_reload(entry.entry_id) return self.async_create_entry(data={}) - # The suggested composite device id was submitted unchanged + # A composite or unknown device id was submitted errors[CONF_DEVICE_ID] = "invalid_device" return self.async_show_form( diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index f0ebb3cd1e759..d40ba43b8e16c 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -1659,15 +1659,18 @@ def async_get_devices_for_composite_device_id( return self.devices.get_devices_for_composite_device_id(composite_device_id) @callback - def async_is_composite_device_id(self, device_id: str) -> bool: + def async_is_composite_device_id(self, device_id: str) -> bool | None: """Return True if device_id is a pre-migration composite device id. A composite device was split into one device per config entry; the - composite device id no longer refers to a registered device. + composite device id no longer refers to a registered device. Returns + False for a registered device id, and None for an unknown id. """ - return device_id not in self.devices and bool( - self.devices.get_devices_for_composite_device_id(device_id) - ) + if device_id in self.devices: + return False + if self.devices.get_devices_for_composite_device_id(device_id): + return True + return None @callback def _resolve_via_device_id( diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index 95048ed8cfea5..c7e50d2c59df9 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -3095,7 +3095,7 @@ async def test_async_is_composite_device_id( assert device_registry.async_is_composite_device_id(old_id) is True assert device_registry.async_is_composite_device_id(device_1.id) is False assert device_registry.async_is_composite_device_id(device_2.id) is False - assert device_registry.async_is_composite_device_id("unknown_id") is False + assert device_registry.async_is_composite_device_id("unknown_id") is None @pytest.mark.parametrize("load_registries", [False]) From 1feb091f0748e598e0be9d6d2f4e60f594613d4a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Jul 2026 18:19:04 +0200 Subject: [PATCH 06/14] Remove dead device cleanup code in acmeda (#177469) --- homeassistant/components/acmeda/const.py | 1 - homeassistant/components/acmeda/entity.py | 26 ++--------------------- homeassistant/components/acmeda/hub.py | 11 +--------- 3 files changed, 3 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/acmeda/const.py b/homeassistant/components/acmeda/const.py index c65efcc02f6fa..379e8ab05ae8a 100644 --- a/homeassistant/components/acmeda/const.py +++ b/homeassistant/components/acmeda/const.py @@ -6,4 +6,3 @@ DOMAIN = "acmeda" ACMEDA_HUB_UPDATE = "acmeda_hub_update_{}" -ACMEDA_ENTITY_REMOVE = "acmeda_entity_remove_{}" diff --git a/homeassistant/components/acmeda/entity.py b/homeassistant/components/acmeda/entity.py index f10e3efca321d..e76221881e46c 100644 --- a/homeassistant/components/acmeda/entity.py +++ b/homeassistant/components/acmeda/entity.py @@ -5,10 +5,9 @@ import aiopulse from homeassistant.core import callback -from homeassistant.helpers import device_registry as dr, entity, entity_registry as er -from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers import device_registry as dr, entity -from .const import ACMEDA_ENTITY_REMOVE, DOMAIN, LOGGER +from .const import DOMAIN, LOGGER class AcmedaEntity(entity.Entity): @@ -21,32 +20,11 @@ def __init__(self, roller: aiopulse.Roller) -> None: """Initialize the roller.""" self.roller = roller - async def async_remove_and_unregister(self) -> None: - """Unregister from registries and call entity remove function.""" - LOGGER.error("Removing %s %s", self.__class__.__name__, self.unique_id) - - ent_registry = er.async_get(self.hass) - if self.entity_id in ent_registry.entities: - ent_registry.async_remove(self.entity_id) - - if self.device_entry: - dr.async_get(self.hass).async_remove_device(self.device_entry.id) - - await self.async_remove(force_remove=True) - @override async def async_added_to_hass(self) -> None: """Entity has been added to hass.""" self.roller.callback_subscribe(self.notify_update) - self.async_on_remove( - async_dispatcher_connect( - self.hass, - ACMEDA_ENTITY_REMOVE.format(self.roller.id), - self.async_remove_and_unregister, - ) - ) - @override async def async_will_remove_from_hass(self) -> None: """Entity being removed from hass.""" diff --git a/homeassistant/components/acmeda/hub.py b/homeassistant/components/acmeda/hub.py index 267ff1d981cfb..c5ccbd833e24d 100644 --- a/homeassistant/components/acmeda/hub.py +++ b/homeassistant/components/acmeda/hub.py @@ -9,7 +9,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_send -from .const import ACMEDA_ENTITY_REMOVE, ACMEDA_HUB_UPDATE, LOGGER +from .const import ACMEDA_HUB_UPDATE, LOGGER from .helpers import update_devices @@ -23,7 +23,6 @@ def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: self.config_entry = config_entry self.hass = hass self.tasks: list[asyncio.Task[None]] = [] - self.current_rollers: dict[int, aiopulse.Roller] = {} self.cleanup_callbacks: list[Callable[[], None]] = [] @property @@ -79,11 +78,3 @@ async def async_notify_update(self, update_type: aiopulse.UpdateType) -> None: async_dispatcher_send( self.hass, ACMEDA_HUB_UPDATE.format(self.config_entry.entry_id) ) - - for unique_id in list(self.current_rollers): - if unique_id not in self.api.rollers: - LOGGER.debug("Notifying remove of %s", unique_id) - self.current_rollers.pop(unique_id) - async_dispatcher_send( - self.hass, ACMEDA_ENTITY_REMOVE.format(unique_id) - ) From dd6aec687463ea4e00f8c047c980777c0b4bf944 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Jul 2026 18:27:12 +0200 Subject: [PATCH 07/14] Adapt esphome to new device registry API (#176942) --- homeassistant/components/esphome/entity.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/esphome/entity.py b/homeassistant/components/esphome/entity.py index 2d5ae2161d139..9aa75736d63d1 100644 --- a/homeassistant/components/esphome/entity.py +++ b/homeassistant/components/esphome/entity.py @@ -121,13 +121,15 @@ def async_static_info_updated( # Update device assignment in registry if info.device_id: # Entity now belongs to a sub device - new_device = dev_reg.async_get_device( - identifiers={(DOMAIN, f"{device_info.mac_address}_{info.device_id}")} + new_device = dev_reg.async_get_device_by_identifier( + (DOMAIN, f"{device_info.mac_address}_{info.device_id}"), + entry_data.entry_id, ) else: # Entity now belongs to the main device - new_device = dev_reg.async_get_device( - connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)} + new_device = dev_reg.async_get_device_by_connection( + (dr.CONNECTION_NETWORK_MAC, device_info.mac_address), + entry_data.entry_id, ) if new_device: From e5f26210686b9e33a9c4ab0c18d999845c089884 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Jul 2026 18:32:49 +0200 Subject: [PATCH 08/14] Adapt tasmota to new device registry API (#176953) --- homeassistant/components/tasmota/__init__.py | 6 +++--- homeassistant/components/tasmota/device_trigger.py | 4 ++-- homeassistant/components/tasmota/discovery.py | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/tasmota/__init__.py b/homeassistant/components/tasmota/__init__.py index f44d996c282a7..501b9844b003a 100644 --- a/homeassistant/components/tasmota/__init__.py +++ b/homeassistant/components/tasmota/__init__.py @@ -114,11 +114,11 @@ async def _remove_device( device_registry: DeviceRegistry, ) -> None: """Remove a discovered Tasmota device.""" - device = device_registry.async_get_device( - connections={(CONNECTION_NETWORK_MAC, mac)} + device = device_registry.async_get_device_by_connection( + (CONNECTION_NETWORK_MAC, mac), config_entry.entry_id ) - if device is None or config_entry.entry_id not in device.config_entries: + if device is None: return _LOGGER.debug("Removing tasmota from device %s", mac) diff --git a/homeassistant/components/tasmota/device_trigger.py b/homeassistant/components/tasmota/device_trigger.py index 4f132fb0b8a11..f6378bbb3c2e0 100644 --- a/homeassistant/components/tasmota/device_trigger.py +++ b/homeassistant/components/tasmota/device_trigger.py @@ -221,8 +221,8 @@ async def discovery_update(trigger_config: TasmotaTriggerConfig) -> None: ) device_registry = dr.async_get(hass) - device = device_registry.async_get_device( - connections={(CONNECTION_NETWORK_MAC, tasmota_trigger.cfg.mac)}, + device = device_registry.async_get_device_by_connection( + (CONNECTION_NETWORK_MAC, tasmota_trigger.cfg.mac), config_entry.entry_id ) if device is None: diff --git a/homeassistant/components/tasmota/discovery.py b/homeassistant/components/tasmota/discovery.py index de64f6725d87a..42549e4e03f32 100644 --- a/homeassistant/components/tasmota/discovery.py +++ b/homeassistant/components/tasmota/discovery.py @@ -301,8 +301,8 @@ async def async_sensors_discovered( device_registry = dr.async_get(hass) entity_registry = er.async_get(hass) - device = device_registry.async_get_device( - connections={(dr.CONNECTION_NETWORK_MAC, mac)} + device = device_registry.async_get_device_by_connection( + (dr.CONNECTION_NETWORK_MAC, mac), config_entry.entry_id ) if device is None: From 9303a3752dfa5896c7672f8076016302b9d9828d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Jul 2026 19:46:18 +0200 Subject: [PATCH 09/14] Fix devolo_home_control device delete race (#177479) --- .../components/devolo_home_control/entity.py | 18 +++++++++-- .../devolo_home_control/test_sensor.py | 32 ++++++++++++++++++- 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/devolo_home_control/entity.py b/homeassistant/components/devolo_home_control/entity.py index a47eb2c03ee98..d0fbdb0112a87 100644 --- a/homeassistant/components/devolo_home_control/entity.py +++ b/homeassistant/components/devolo_home_control/entity.py @@ -1,7 +1,7 @@ """Base class for a device entity integrated in devolo Home Control.""" import logging -from typing import override +from typing import TYPE_CHECKING, override from urllib.parse import urlparse from devolo_home_control_api.devices.zwave import Zwave @@ -103,8 +103,20 @@ def _generic_message(self, message: tuple) -> None: ].name, ) self._attr_available = state - elif message[1] == "del" and self.device_entry: - dr.async_get(self.hass).async_remove_device(self.device_entry.id) + elif message[1] == "del": + # A "del" is dispatched to every entity of the device. Look the device + # up freshly instead of using the cached (possibly stale) device_entry: + # the first entity to run removes it and the rest find it already gone. + device_registry = dr.async_get(self.hass) + platform = self.platform + if TYPE_CHECKING: + # Devolo entities always belong to a config entry + assert platform.config_entry is not None + if device := device_registry.async_get_device_by_identifier( + (DOMAIN, self._device_instance.uid), + platform.config_entry.entry_id, + ): + device_registry.async_remove_device(device.id) else: _LOGGER.debug("No valid message received: %s", message) diff --git a/tests/components/devolo_home_control/test_sensor.py b/tests/components/devolo_home_control/test_sensor.py index f5aa213f7e6dc..390f760f2b4ab 100644 --- a/tests/components/devolo_home_control/test_sensor.py +++ b/tests/components/devolo_home_control/test_sensor.py @@ -4,10 +4,11 @@ from syrupy.assertion import SnapshotAssertion +from homeassistant.components.devolo_home_control.const import DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import STATE_UNAVAILABLE 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 . import configure_integration from .mocks import ( @@ -181,3 +182,32 @@ async def test_remove_from_hass(hass: HomeAssistant) -> None: assert len(hass.states.async_all()) == 0 assert test_gateway.publisher.unregister.call_count == 1 + + +async def test_deleted_device_removed_once( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test a device with several entities is removed once when it is deleted. + + A "del" message is dispatched to every entity of the device, so removing the + device must be idempotent; the entities whose turn comes after the device is + already gone must not act on a stale reference. + """ + entry = configure_integration(hass) + test_gateway = HomeControlMockConsumption() + with patch( + "homeassistant.components.devolo_home_control.HomeControl", + side_effect=[test_gateway, HomeControlMock()], + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + # The consumption device has several entities (power, energy), all subscribed + # to the same device uid + assert device_registry.async_get_device(identifiers={(DOMAIN, "Test")}) is not None + + # Emulate websocket message: device was deleted + test_gateway.publisher.dispatch("Test", ("Test", "del")) + await hass.async_block_till_done() + + assert device_registry.async_get_device(identifiers={(DOMAIN, "Test")}) is None From 57041e71d8f6b9739de517156f3823322c05b059 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Jul 2026 19:51:01 +0200 Subject: [PATCH 10/14] Make entity_platform check entity can attach to device (#177459) --- homeassistant/helpers/entity_platform.py | 24 ++++++- tests/helpers/test_entity_platform.py | 86 +++++++++++++++++++++++- 2 files changed, 107 insertions(+), 3 deletions(-) diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index bad3be8b03deb..3b15d34b02e91 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -40,7 +40,7 @@ from . import device_registry as dr, entity_registry as er, service, translation from .entity_registry import EntityRegistry, RegistryEntryDisabler, RegistryEntryHider from .event import async_call_later -from .frame import report_usage +from .frame import ReportBehavior, report_usage from .issue_registry import IssueSeverity, async_create_issue from .typing import UNDEFINED, ConfigType, DiscoveryInfoType, VolDictType, VolSchemaType @@ -833,6 +833,21 @@ def _entity_id_already_exists(self, entity_id: str) -> tuple[bool, bool]: already_exists = True return (already_exists, restored) + def _check_device_attach(self, entity: Entity, reason: str) -> None: + """Check the entity does not attach a device and report it if it does. + + A device can only be attached to an entity which has a unique ID and + belongs to a config entry. + """ + if entity.device_info is None and entity.device_entry is None: + return + report_usage( + f"attempts to attach a device to an entity {reason}", + core_behavior=ReportBehavior.LOG, + breaks_in_ha_version="2027.8.0", + integration_domain=self.platform_name, + ) + async def _async_add_entity( # noqa: C901 self, entity: Entity, @@ -955,7 +970,9 @@ async def _async_add_entity( # noqa: C901 else: device = entity.device_entry else: + self._check_device_attach(entity, "without a config entry") device = None + entity.device_entry = None suggested_object_id, object_id_base = _async_derive_object_ids(entity, self) @@ -1025,7 +1042,10 @@ async def _async_add_entity( # noqa: C901 entity.registry_entry = entry entity.entity_id = entry.entity_id - else: # entity.unique_id is None # noqa: PLR5501 + else: # entity.unique_id is None + self._check_device_attach(entity, "without a unique ID") + entity.device_entry = None + # We won't generate an entity ID if the platform has already set one # We will however make sure that platform cannot pick a registered ID if entity.entity_id is None or entity_registry.async_is_registered( diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index 83ca42a5e9595..acdb456872591 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -1,7 +1,7 @@ """Tests for the EntityPlatform helper.""" import asyncio -from collections.abc import Iterable +from collections.abc import Callable, Iterable from datetime import timedelta import logging import types @@ -2824,6 +2824,90 @@ async def async_setup_entry( assert len(hass.states.async_all()) == number_of_entities +def _mock_entity_implementing_device_info( + device: dr.DeviceEntry, unique_id: str | None +) -> MockEntity: + """Return an entity attaching a device by implementing device_info.""" + return MockEntity( + unique_id=unique_id, device_info=DeviceInfo(identifiers={("test", "dev1")}) + ) + + +def _mock_entity_setting_attr_device_info( + device: dr.DeviceEntry, unique_id: str | None +) -> MockEntity: + """Return an entity attaching a device by setting _attr_device_info.""" + ent = MockEntity(unique_id=unique_id) + ent._attr_device_info = DeviceInfo(identifiers={("test", "dev1")}) + return ent + + +def _mock_entity_setting_device_entry( + device: dr.DeviceEntry, unique_id: str | None +) -> MockEntity: + """Return an entity attaching a device by setting device_entry.""" + ent = MockEntity(unique_id=unique_id) + ent.device_entry = device + return ent + + +@pytest.mark.parametrize( + "make_entity", + [ + pytest.param(_mock_entity_implementing_device_info, id="device_info"), + pytest.param(_mock_entity_setting_attr_device_info, id="attr_device_info"), + pytest.param(_mock_entity_setting_device_entry, id="device_entry"), + ], +) +@pytest.mark.parametrize( + ("unique_id", "reason"), + [ + ("qwer", "without a config entry"), + (None, "without a unique ID"), + ], +) +async def test_reject_device_attach_reports_usage( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + caplog: pytest.LogCaptureFixture, + make_entity: Callable[[dr.DeviceEntry, str | None], MockEntity], + unique_id: str | None, + reason: str, +) -> None: + """Test attaching a device is rejected and reports usage. + + A device can only be attached to an entity which has a unique ID and belongs + to a config entry. The platform here has no config entry, so an entity that + attaches a device is rejected: the attempt is reported and device_entry is + cleared. + """ + config_entry = MockConfigEntry() + config_entry.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, identifiers={("test", "dev1")} + ) + + ent = make_entity(device, unique_id) + platform = MockEntityPlatform(hass) + + mock_integration = Mock(is_built_in=True, domain="test_platform") + with ( + caplog.at_level(logging.WARNING), + patch( + "homeassistant.helpers.frame.async_get_issue_integration", + return_value=mock_integration, + ), + ): + await platform.async_add_entities([ent]) + + assert ( + f"Detected that integration 'test_platform' " + f"attempts to attach a device to an entity {reason}. " + f"This will stop working in Home Assistant 2027.8.0" + ) in caplog.text + assert ent.device_entry is None + + async def test_add_entity_unknown_subentry( hass: HomeAssistant, entity_registry: er.EntityRegistry, From e5c2f27155571f7313a32a772c58c31d3847a110 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 28 Jul 2026 20:09:03 +0200 Subject: [PATCH 11/14] Forward from_recorder in recorder EventTypeManager.get (#175508) --- .../recorder/table_managers/event_types.py | 2 +- .../table_managers/test_event_types.py | 39 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 tests/components/recorder/table_managers/test_event_types.py diff --git a/homeassistant/components/recorder/table_managers/event_types.py b/homeassistant/components/recorder/table_managers/event_types.py index c180fa6f0cc62..27f0f555faf70 100644 --- a/homeassistant/components/recorder/table_managers/event_types.py +++ b/homeassistant/components/recorder/table_managers/event_types.py @@ -56,7 +56,7 @@ def get( This call is not thread-safe and must be called from the recorder thread. """ - return self.get_many((event_type,), session)[event_type] + return self.get_many((event_type,), session, from_recorder)[event_type] def get_many( self, diff --git a/tests/components/recorder/table_managers/test_event_types.py b/tests/components/recorder/table_managers/test_event_types.py new file mode 100644 index 0000000000000..687d8057d4526 --- /dev/null +++ b/tests/components/recorder/table_managers/test_event_types.py @@ -0,0 +1,39 @@ +"""The tests for the recorder event types manager.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.components.recorder.util import session_scope +from homeassistant.core import HomeAssistant + +from tests.typing import RecorderInstanceGenerator + + +@pytest.mark.parametrize( + ("from_recorder", "expected_task_queued"), + [ + pytest.param(True, False, id="from_recorder"), + pytest.param(False, True, id="not_from_recorder"), + ], +) +async def test_get_non_existent_event_type( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + from_recorder: bool, + expected_task_queued: bool, +) -> None: + """Test getting a non-existent event type only queues a refresh when needed.""" + instance = await async_setup_recorder_instance(hass) + manager = instance.event_type_manager + + with ( + session_scope(session=instance.get_session()) as session, + patch.object(instance, "queue_task") as mock_queue_task, + ): + assert manager.get("unknown_event_type", session, from_recorder) is None + + assert mock_queue_task.called == expected_task_queued + assert ("unknown_event_type" in manager._non_existent_event_types) is ( + not expected_task_queued + ) From 07a2383bdc4f1a71b0b695e24cbc0c1da823f99b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Jul 2026 20:47:51 +0200 Subject: [PATCH 12/14] Adapt zwave_js to new device registry API (#176959) --- homeassistant/components/zwave_js/__init__.py | 53 +++++++++++++------ homeassistant/components/zwave_js/api.py | 6 ++- homeassistant/components/zwave_js/helpers.py | 13 +++++ .../components/zwave_js/triggers/event.py | 6 ++- .../zwave_js/triggers/value_updated.py | 11 +++- 5 files changed, 69 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index fe9e3a72acd90..c19060a9435ed 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -401,8 +401,10 @@ async def handle_logging_changed(_: Event | None = None) -> None: if ( (own_node := driver.controller.own_node) and ( - controller_device_entry := device_registry.async_get_device( - identifiers={get_device_id(driver, own_node)} + controller_device_entry := ( + device_registry.async_get_device_by_identifier( + get_device_id(driver, own_node), self.config_entry.entry_id + ) ) ) and (model := controller_device_entry.model) @@ -440,7 +442,9 @@ async def handle_logging_changed(_: Event | None = None) -> None: self.dev_reg, self.config_entry.entry_id ) known_devices = [ - self.dev_reg.async_get_device(identifiers={get_device_id(driver, node)}) + self.dev_reg.async_get_device_by_identifier( + get_device_id(driver, node), self.config_entry.entry_id + ) for node in controller.nodes.values() ] provisioned_devices = [ @@ -561,7 +565,9 @@ def async_on_node_removed(self, event: dict) -> None: reason: RemoveNodeReason = event["reason"] # grab device in device registry attached to this node dev_id = get_device_id(self.driver_events.driver, node) - device = self.dev_reg.async_get_device(identifiers={dev_id}) + device = self.dev_reg.async_get_device_by_identifier( + dev_id, self.config_entry.entry_id + ) # We assert because we know the device exists assert device if reason in (RemoveNodeReason.REPLACED, RemoveNodeReason.PROXY_REPLACED): @@ -610,7 +616,9 @@ def async_on_identify(self, event: dict) -> None: # Get node device node: ZwaveNode = event["node"] dev_id = get_device_id(self.driver_events.driver, node) - device = self.dev_reg.async_get_device(identifiers={dev_id}) + device = self.dev_reg.async_get_device_by_identifier( + dev_id, self.config_entry.entry_id + ) assert device device_name = device.name_by_user or device.name or f"Node {node.node_id}" # In case the user has multiple networks, we should give them more information @@ -658,7 +666,14 @@ async def async_check_pre_provisioned_device(self, node: ZwaveNode) -> None: if device_id_ext: new_identifiers.add(device_id_ext) - if self.dev_reg.async_get_device(identifiers=new_identifiers): + if self.dev_reg.async_get_device_by_identifier( + device_id, self.config_entry.entry_id + ) or ( + device_id_ext + and self.dev_reg.async_get_device_by_identifier( + device_id_ext, self.config_entry.entry_id + ) + ): # If a device entry is registered with the node ID based identifiers, # just remove the device entry with the DSK identifier. self.dev_reg.async_update_device( @@ -678,7 +693,9 @@ async def async_register_node_in_dev_reg(self, node: ZwaveNode) -> dr.DeviceEntr driver = self.driver_events.driver device_id = get_device_id(driver, node) device_id_ext = get_device_id_ext(driver, node) - node_id_device = self.dev_reg.async_get_device(identifiers={device_id}) + node_id_device = self.dev_reg.async_get_device_by_identifier( + device_id, self.config_entry.entry_id + ) via_identifier = None controller = driver.controller # Get the controller node device ID if this node is not the controller @@ -709,8 +726,8 @@ async def async_register_node_in_dev_reg(self, node: ZwaveNode) -> dr.DeviceEntr # based identifier, add the node ID based identifier to the orphaned # device. if ( - hardware_device := self.dev_reg.async_get_device( - identifiers={device_id_ext} + hardware_device := self.dev_reg.async_get_device_by_identifier( + device_id_ext, self.config_entry.entry_id ) ) and len(hardware_device.identifiers) == 1: new_identifiers = hardware_device.identifiers.copy() @@ -914,7 +931,11 @@ def async_on_value_added( ) if ( not value.node.ready - or not (device := self.dev_reg.async_get_device(identifiers={device_id})) + or not ( + device := self.dev_reg.async_get_device_by_identifier( + device_id, self.config_entry.entry_id + ) + ) or value.value_id in self.controller_events.discovered_value_ids[device.id] ): return @@ -929,8 +950,8 @@ def async_on_value_added( def async_on_value_notification(self, notification: ValueNotification) -> None: """Relay stateless value notification events from Z-Wave nodes to hass.""" driver = self.controller_events.driver_events.driver - device = self.dev_reg.async_get_device( - identifiers={get_device_id(driver, notification.node)} + device = self.dev_reg.async_get_device_by_identifier( + get_device_id(driver, notification.node), self.config_entry.entry_id ) # We assert because we know the device exists assert device @@ -973,8 +994,8 @@ def async_on_notification(self, event: dict[str, Any]) -> None: | PowerLevelNotification | MultilevelSwitchNotification ) = event["notification"] - device = self.dev_reg.async_get_device( - identifiers={get_device_id(driver, notification.node)} + device = self.dev_reg.async_get_device_by_identifier( + get_device_id(driver, notification.node), self.config_entry.entry_id ) # We assert because we know the device exists assert device @@ -1056,8 +1077,8 @@ def async_on_value_updated_fire_event( driver = self.controller_events.driver_events.driver disc_info = value_updates_disc_info[value.value_id] - device = self.dev_reg.async_get_device( - identifiers={get_device_id(driver, value.node)} + device = self.dev_reg.async_get_device_by_identifier( + get_device_id(driver, value.node), self.config_entry.entry_id ) # We assert because we know the device exists assert device diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index b7c5e2e92c797..81411a51831d6 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -90,6 +90,7 @@ from .helpers import ( CannotConnect, async_enable_statistics, + async_get_config_entry_from_node, async_get_node_from_device_id, async_get_provisioning_entry_from_device_id, async_get_version_info, @@ -2780,7 +2781,10 @@ def _convert_node_to_device_id(node: Node) -> str: """Convert a node to a device id.""" driver = node.client.driver assert driver - device = dev_reg.async_get_device(identifiers={get_device_id(driver, node)}) + entry = async_get_config_entry_from_node(hass, node) + device = dev_reg.async_get_device_by_identifier( + get_device_id(driver, node), entry.entry_id + ) assert device return device.id diff --git a/homeassistant/components/zwave_js/helpers.py b/homeassistant/components/zwave_js/helpers.py index da38d7a4681a2..f0b5db4fe9a0c 100644 --- a/homeassistant/components/zwave_js/helpers.py +++ b/homeassistant/components/zwave_js/helpers.py @@ -326,6 +326,19 @@ def async_get_node_from_device_id( return driver.controller.nodes[node_id] +@callback +def async_get_config_entry_from_node( + hass: HomeAssistant, node: ZwaveNode +) -> ZwaveJSConfigEntry: + """Get the config entry from a Z-Wave JS node.""" + return next( + entry + for entry in hass.config_entries.async_entries(DOMAIN) + if entry.state is ConfigEntryState.LOADED + and entry.runtime_data.client is node.client + ) + + async def async_get_provisioning_entry_from_device_id( hass: HomeAssistant, device_id: str ) -> ProvisioningEntry | None: diff --git a/homeassistant/components/zwave_js/triggers/event.py b/homeassistant/components/zwave_js/triggers/event.py index 5b03462d5c869..1e29e8dd338b8 100644 --- a/homeassistant/components/zwave_js/triggers/event.py +++ b/homeassistant/components/zwave_js/triggers/event.py @@ -37,6 +37,7 @@ DOMAIN, ) from ..helpers import ( + async_get_config_entry_from_node, async_get_nodes_from_targets, get_device_id, get_home_and_node_id_from_device_entry, @@ -279,8 +280,11 @@ def _create_zwave_listeners(self) -> None: driver = node.client.driver assert driver is not None # The node comes from the driver. drivers.add(driver) + node_entry = async_get_config_entry_from_node(self._hass, node) device_identifier = get_device_id(driver, node) - device = dev_reg.async_get_device(identifiers={device_identifier}) + device = dev_reg.async_get_device_by_identifier( + device_identifier, node_entry.entry_id + ) assert device # We need to store the device for the callback self._unsubs.append( diff --git a/homeassistant/components/zwave_js/triggers/value_updated.py b/homeassistant/components/zwave_js/triggers/value_updated.py index 39bae05158f4b..742ab9907a5a2 100644 --- a/homeassistant/components/zwave_js/triggers/value_updated.py +++ b/homeassistant/components/zwave_js/triggers/value_updated.py @@ -39,7 +39,11 @@ DOMAIN, EVENT_VALUE_UPDATED, ) -from ..helpers import async_get_nodes_from_targets, get_device_id +from ..helpers import ( + async_get_config_entry_from_node, + async_get_nodes_from_targets, + get_device_id, +) from .trigger_helpers import async_bypass_dynamic_config_validation # Relative platform type should be @@ -172,8 +176,11 @@ def _create_zwave_listeners() -> None: driver = node.client.driver assert driver is not None # The node comes from the driver. drivers.add(driver) + entry = async_get_config_entry_from_node(hass, node) device_identifier = get_device_id(driver, node) - device = dev_reg.async_get_device(identifiers={device_identifier}) + device = dev_reg.async_get_device_by_identifier( + device_identifier, entry.entry_id + ) assert device value_id = get_value_id_str( node, command_class, property_, endpoint, property_key From 110106bf2cc16168ef9830196a9c3be32a0e0a3c Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Tue, 28 Jul 2026 22:45:02 +0200 Subject: [PATCH 13/14] Add setting to customise entity ID generation (#177140) --- .../components/config/entity_registry.py | 48 ++ homeassistant/helpers/entity_registry.py | 96 +++- tests/common.py | 1 + .../components/config/test_entity_registry.py | 106 +++++ tests/helpers/test_entity_registry.py | 428 ++++++++++++++++++ 5 files changed, 670 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/config/entity_registry.py b/homeassistant/components/config/entity_registry.py index aa0e0df35bf9e..bd2cef2b04b1a 100644 --- a/homeassistant/components/config/entity_registry.py +++ b/homeassistant/components/config/entity_registry.py @@ -30,6 +30,8 @@ def async_setup(hass: HomeAssistant) -> bool: websocket_api.async_register_command(hass, websocket_list_entities) websocket_api.async_register_command(hass, websocket_remove_entity) websocket_api.async_register_command(hass, websocket_update_entity) + websocket_api.async_register_command(hass, websocket_get_settings) + websocket_api.async_register_command(hass, websocket_update_settings) return True @@ -362,3 +364,49 @@ def websocket_get_automatic_entity_ids( connection.send_message( websocket_api.result_message(msg["id"], automatic_entity_ids) ) + + +@websocket_api.websocket_command( + {vol.Required("type"): "config/entity_registry/settings/get"} +) +@callback +def websocket_get_settings( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Handle get entity registry settings command.""" + registry = er.async_get(hass) + connection.send_result( + msg["id"], {"entity_id_parts": registry.settings.entity_id_parts} + ) + + +@require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "config/entity_registry/settings/update", + vol.Optional("entity_id_parts"): vol.Any( + None, + vol.All( + [vol.Coerce(er.EntityNamePart)], + vol.Unique(), + vol.Contains(er.EntityNamePart.ENTITY), + vol.Contains(er.EntityNamePart.DEVICE), + ), + ), + } +) +@callback +def websocket_update_settings( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Handle update entity registry settings command.""" + registry = er.async_get(hass) + changes: dict[str, Any] = {} + if "entity_id_parts" in msg: + changes["entity_id_parts"] = msg["entity_id_parts"] + settings = registry.async_update_settings(**changes) + connection.send_result(msg["id"], {"entity_id_parts": settings.entity_id_parts}) diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index b6ec39fca505f..a48a1102c3542 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -9,7 +9,9 @@ """ from collections import defaultdict -from collections.abc import Callable, Hashable, KeysView, Mapping +from collections.abc import Callable, Hashable, KeysView, Mapping, Sequence +import dataclasses +from dataclasses import dataclass from datetime import datetime, timedelta from enum import Enum, StrEnum import logging @@ -45,7 +47,7 @@ from homeassistant.util.json import format_unserializable_data from homeassistant.util.read_only_dict import ReadOnlyDict -from . import area_registry as ar, device_registry as dr, storage +from . import area_registry as ar, device_registry as dr, floor_registry as fr, storage from .device_registry import ( EVENT_DEVICE_REGISTRY_UPDATED, EventDeviceRegistryUpdatedData, @@ -72,7 +74,7 @@ _LOGGER = logging.getLogger(__name__) STORAGE_VERSION_MAJOR = 1 -STORAGE_VERSION_MINOR = 22 +STORAGE_VERSION_MINOR = 23 STORAGE_KEY = "core.entity_registry" CLEANUP_INTERVAL = 3600 * 24 @@ -135,6 +137,22 @@ class RegistryEntryHider(StrEnum): USER = "user" +class EntityNamePart(StrEnum): + """Parts a generated full entity name can be composed of.""" + + AREA = "area" + DEVICE = "device" + ENTITY = "entity" + FLOOR = "floor" + + +@dataclass(frozen=True, kw_only=True, slots=True) +class EntityRegistrySettings: + """Global entity registry settings.""" + + entity_id_parts: tuple[EntityNamePart, ...] | None = None + + class _EventEntityRegistryUpdatedData_CreateRemove(TypedDict): """EventEntityRegistryUpdated data for action type 'create' and 'remove'.""" @@ -475,7 +493,7 @@ def async_get_unprefixed_name(hass: HomeAssistant, entry: RegistryEntry) -> str: def _async_get_full_entity_name( hass: HomeAssistant, *, - area_id: str | None | UndefinedType = UNDEFINED, + area_id: str | None, device_id: str | None, fallback: str, has_entity_name: bool, @@ -483,6 +501,7 @@ def _async_get_full_entity_name( original_name: str | None, original_name_unprefixed: str | None | UndefinedType = UNDEFINED, overridden_name: str | None = None, + parts: Sequence[EntityNamePart], unprefix_name: bool = False, use_legacy_naming: bool = False, ) -> str: @@ -506,12 +525,20 @@ def _async_get_full_entity_name( area_id = device.area_id area_name: str | None = None + floor_name: str | None = None if ( - area_id is not UNDEFINED + (EntityNamePart.AREA in parts or EntityNamePart.FLOOR in parts) and area_id is not None and (area := ar.async_get(hass).async_get_area(area_id)) is not None ): area_name = area.name + if ( + EntityNamePart.FLOOR in parts + and area.floor_id is not None + and (floor := fr.async_get(hass).async_get_floor(area.floor_id)) + is not None + ): + floor_name = floor.name entity_name = name if entity_name is None: @@ -532,8 +559,14 @@ def _async_get_full_entity_name( if unprefixed_name is not None: entity_name = unprefixed_name + part_names = { + EntityNamePart.AREA: area_name, + EntityNamePart.DEVICE: device_name, + EntityNamePart.ENTITY: entity_name, + EntityNamePart.FLOOR: floor_name, + } full_name = " ".join( - part for part in (area_name, device_name, entity_name) if part + part_name for part in parts if (part_name := part_names[part]) ) else: @@ -559,12 +592,14 @@ def async_get_full_entity_name( return _async_get_full_entity_name( hass, + area_id=entry.area_id, device_id=entry.device_id, fallback="", has_entity_name=entry.has_entity_name, name=entry.name, original_name=original_name, original_name_unprefixed=original_name_unprefixed, + parts=(EntityNamePart.DEVICE, EntityNamePart.ENTITY), use_legacy_naming=True, ) @@ -739,7 +774,7 @@ def as_storage_fragment(self) -> json_fragment: ) -class EntityRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]): +class EntityRegistryStore(storage.Store[dict[str, Any]]): """Store entity registry data.""" @override @@ -747,7 +782,7 @@ async def _async_migrate_func( # noqa: C901 self, old_major_version: int, old_minor_version: int, - old_data: dict[str, list[dict[str, Any]]], + old_data: dict[str, Any], ) -> dict: """Migrate to the new version.""" data = old_data @@ -915,6 +950,10 @@ async def _async_migrate_func( # noqa: C901 for entity in data["deleted_entities"]: entity["aliases_v2"] = [None, *entity["aliases"]] + if old_minor_version < 23: + # Version 1.23 adds settings + data["settings"] = {"entity_id_parts": None} + if old_major_version > 1: raise NotImplementedError return data @@ -1163,6 +1202,7 @@ class EntityRegistry(BaseRegistry): deleted_entities: dict[tuple[str, str, str], DeletedRegistryEntry] entities: EntityRegistryItems + settings: EntityRegistrySettings _entities_data: dict[str, RegistryEntry] def __init__(self, hass: HomeAssistant) -> None: @@ -1322,6 +1362,9 @@ def _async_generate_entity_id( Entity ID conflicts are checked against registered and currently existing entities, as well as provided `reserved_entity_ids`. """ + parts = self.settings.entity_id_parts + if parts is None: + parts = (EntityNamePart.AREA, EntityNamePart.DEVICE, EntityNamePart.ENTITY) object_id = _async_get_full_entity_name( self.hass, area_id=area_id, @@ -1331,6 +1374,7 @@ def _async_generate_entity_id( name=name, original_name=object_id_base, overridden_name=suggested_object_id, + parts=parts, unprefix_name=True, ) return self.async_get_available_entity_id( @@ -2076,6 +2120,32 @@ def async_update_entity_options( new_options[domain] = options return self._async_update_entity(entity_id, options=new_options) + @callback + def async_update_settings( + self, + *, + entity_id_parts: list[EntityNamePart] | None | UndefinedType = UNDEFINED, + ) -> EntityRegistrySettings: + """Update entity registry settings.""" + self.hass.verify_event_loop_thread("entity_registry.async_update_settings") + + old = self.settings + new = old + if entity_id_parts is not UNDEFINED: + new = dataclasses.replace( + new, + entity_id_parts=None + if entity_id_parts is None + else tuple(entity_id_parts), + ) + + if new == old: + return old + + self.settings = new + self.async_schedule_save() + return new + @override async def _async_load(self) -> None: """Load the entity registry.""" @@ -2092,6 +2162,7 @@ async def _async_load(self) -> None: data = await self._store.async_load() entities = EntityRegistryItems(self.hass) deleted_entities: dict[tuple[str, str, str], DeletedRegistryEntry] = {} + settings = EntityRegistrySettings() # Move entities to the correct device when a pre-migration composite device was # split into one device per config entry. This can be removed 12 months after @@ -2276,8 +2347,14 @@ def get_optional_enum[_EnumT: StrEnum]( unique_id=entity["unique_id"], ) - self.deleted_entities = deleted_entities + if (parts_data := data["settings"]["entity_id_parts"]) is not None: + settings = EntityRegistrySettings( + entity_id_parts=tuple(EntityNamePart(part) for part in parts_data) + ) + self.entities = entities + self.deleted_entities = deleted_entities + self.settings = settings self._entities_data = entities.data # Persist entities moved off a split pre-migration composite device @@ -2297,6 +2374,7 @@ def _data_to_save(self) -> dict[str, Any]: entry.as_storage_fragment for entry in list(self.deleted_entities.values()) ], + "settings": {"entity_id_parts": self.settings.entity_id_parts}, } @callback diff --git a/tests/common.py b/tests/common.py index 60000058bff41..3422d1b4b866f 100644 --- a/tests/common.py +++ b/tests/common.py @@ -679,6 +679,7 @@ def mock_registry( mock_entries = {} registry.deleted_entities = {} registry.entities = er.EntityRegistryItems(hass) + registry.settings = er.EntityRegistrySettings() registry._entities_data = registry.entities.data for key, entry in mock_entries.items(): registry.entities[key] = entry diff --git a/tests/components/config/test_entity_registry.py b/tests/components/config/test_entity_registry.py index 1363776789179..72582c07048ba 100644 --- a/tests/components/config/test_entity_registry.py +++ b/tests/components/config/test_entity_registry.py @@ -24,6 +24,7 @@ MockConfigEntry, MockEntity, MockEntityPlatform, + MockUser, RegistryEntryWithDefaults, mock_registry, ) @@ -1576,3 +1577,108 @@ async def test_get_automatic_entity_ids( # no test_domain.unknown in registry "test_domain.unknown": None, } + + +async def test_get_settings(client: MockHAClientWebSocket) -> None: + """Test get settings.""" + await client.send_json_auto_id({"type": "config/entity_registry/settings/get"}) + msg = await client.receive_json() + + assert msg["success"] + assert msg["result"] == {"entity_id_parts": None} + + +async def test_update_settings( + client: MockHAClientWebSocket, + entity_registry: er.EntityRegistry, +) -> None: + """Test update settings.""" + await client.send_json_auto_id( + { + "type": "config/entity_registry/settings/update", + "entity_id_parts": ["floor", "area", "device", "entity"], + } + ) + msg = await client.receive_json() + + assert msg["success"] + assert msg["result"] == {"entity_id_parts": ["floor", "area", "device", "entity"]} + assert entity_registry.settings.entity_id_parts == ( + er.EntityNamePart.FLOOR, + er.EntityNamePart.AREA, + er.EntityNamePart.DEVICE, + er.EntityNamePart.ENTITY, + ) + + await client.send_json_auto_id({"type": "config/entity_registry/settings/get"}) + msg = await client.receive_json() + + assert msg["success"] + assert msg["result"] == {"entity_id_parts": ["floor", "area", "device", "entity"]} + + # Clear the override + await client.send_json_auto_id( + {"type": "config/entity_registry/settings/update", "entity_id_parts": None} + ) + msg = await client.receive_json() + + assert msg["success"] + assert msg["result"] == {"entity_id_parts": None} + assert entity_registry.settings.entity_id_parts is None + + +@pytest.mark.parametrize( + "entity_id_parts", + [ + pytest.param(["entity", "device", "bad_part"], id="unknown_part"), + pytest.param(["entity", "device", "entity"], id="duplicate"), + pytest.param(["entity"], id="missing_device"), + pytest.param(["device"], id="missing_entity"), + pytest.param([], id="empty"), + ], +) +async def test_update_settings_invalid( + client: MockHAClientWebSocket, + entity_registry: er.EntityRegistry, + entity_id_parts: list[str], +) -> None: + """Test update settings with an invalid parts list.""" + await client.send_json_auto_id( + { + "type": "config/entity_registry/settings/update", + "entity_id_parts": entity_id_parts, + } + ) + msg = await client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "invalid_format" + assert entity_registry.settings.entity_id_parts is None + + +async def test_update_settings_requires_admin( + client: MockHAClientWebSocket, + entity_registry: er.EntityRegistry, + hass_admin_user: MockUser, +) -> None: + """Test update settings fails for non admin.""" + hass_admin_user.groups = [] + + await client.send_json_auto_id( + { + "type": "config/entity_registry/settings/update", + "entity_id_parts": ["device", "entity"], + } + ) + msg = await client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "unauthorized" + assert entity_registry.settings.entity_id_parts is None + + # Reading settings is not restricted + await client.send_json_auto_id({"type": "config/entity_registry/settings/get"}) + msg = await client.receive_json() + + assert msg["success"] + assert msg["result"] == {"entity_id_parts": None} diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index dba56dc61feda..63e569aa4643d 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -23,6 +23,7 @@ area_registry as ar, device_registry as dr, entity_registry as er, + floor_registry as fr, ) from homeassistant.helpers.event import async_track_entity_registry_updated_event from homeassistant.helpers.typing import UNDEFINED @@ -467,6 +468,10 @@ async def test_loading_saving_data( assert len(entity_registry.entities) == 2 assert len(entity_registry.deleted_entities) == 2 + entity_registry.async_update_settings( + entity_id_parts=[er.EntityNamePart.ENTITY, er.EntityNamePart.DEVICE] + ) + # Now load written data in new registry registry2 = er.EntityRegistry(hass) await flush_store(entity_registry._store) @@ -475,6 +480,11 @@ async def test_loading_saving_data( # Ensure same order assert list(entity_registry.entities) == list(registry2.entities) assert list(entity_registry.deleted_entities) == list(registry2.deleted_entities) + assert registry2.settings == entity_registry.settings + assert registry2.settings.entity_id_parts == ( + er.EntityNamePart.ENTITY, + er.EntityNamePart.DEVICE, + ) new_entry1 = entity_registry.async_get_or_create("light", "hue", "1234") new_entry2 = entity_registry.async_get_or_create("light", "hue", "5678") new_entry3 = entity_registry.async_get_or_create("light", "hue", "ABCD") @@ -779,6 +789,419 @@ def test_generate_entity_id( assert new_entity_id == expected_entity_id +@pytest.mark.parametrize( + ( + "entity_id_parts", + "device_name", + "device_area_name", + "floor_name", + "has_entity_name", + "object_id_base", + "suggested_object_id", + "user_name", + "expected_initial_entity_id", + "expected_entity_id", + ), + [ + pytest.param( + [ + er.EntityNamePart.FLOOR, + er.EntityNamePart.AREA, + er.EntityNamePart.DEVICE, + er.EntityNamePart.ENTITY, + ], + "Lamp", + "Kitchen", + "First Floor", + True, + "Temperature", + None, + None, + "sensor.first_floor_kitchen_lamp_temperature", + "sensor.first_floor_kitchen_lamp_temperature", + id="all_parts", + ), + pytest.param( + [ + er.EntityNamePart.FLOOR, + er.EntityNamePart.AREA, + er.EntityNamePart.DEVICE, + er.EntityNamePart.ENTITY, + ], + "Lamp", + "Kitchen", + None, + True, + "Temperature", + None, + None, + "sensor.kitchen_lamp_temperature", + "sensor.kitchen_lamp_temperature", + id="area_without_floor", + ), + pytest.param( + [er.EntityNamePart.DEVICE, er.EntityNamePart.ENTITY], + "Lamp", + "Kitchen", + "First Floor", + True, + "Temperature", + None, + None, + "sensor.lamp_temperature", + "sensor.lamp_temperature", + id="area_not_included", + ), + pytest.param( + [ + er.EntityNamePart.FLOOR, + er.EntityNamePart.DEVICE, + er.EntityNamePart.ENTITY, + ], + "Lamp", + "Kitchen", + "First Floor", + True, + "Temperature", + None, + None, + "sensor.first_floor_lamp_temperature", + "sensor.first_floor_lamp_temperature", + id="floor_without_area", + ), + pytest.param( + [er.EntityNamePart.ENTITY, er.EntityNamePart.DEVICE], + "Lamp", + "Kitchen", + None, + True, + "Temperature", + None, + None, + "sensor.temperature_lamp", + "sensor.temperature_lamp", + id="reordered", + ), + pytest.param( + [er.EntityNamePart.DEVICE, er.EntityNamePart.ENTITY], + "Lamp", + None, + None, + False, + "Lamp Temperature", + None, + None, + "sensor.lamp_temperature", + "sensor.lamp_temperature", + id="legacy_name_stripped", + ), + pytest.param( + [er.EntityNamePart.ENTITY, er.EntityNamePart.DEVICE], + "Lamp", + None, + None, + False, + "Lamp Temperature", + None, + None, + "sensor.temperature_lamp", + "sensor.temperature_lamp", + id="legacy_name_reordered", + ), + pytest.param( + [er.EntityNamePart.DEVICE, er.EntityNamePart.ENTITY], + None, + None, + None, + True, + "My Sensor", + None, + None, + "sensor.my_sensor", + "sensor.my_sensor", + id="no_device", + ), + pytest.param( + [er.EntityNamePart.DEVICE, er.EntityNamePart.ENTITY], + "Lamp", + None, + None, + True, + "Temperature", + "custom_id", + None, + "sensor.custom_id", + "sensor.custom_id", + id="suggested_object_id_respected", + ), + pytest.param( + [er.EntityNamePart.ENTITY, er.EntityNamePart.DEVICE], + "Lamp", + None, + None, + True, + "Temperature", + None, + "Lamp Humidity", + "sensor.temperature_lamp", + "sensor.humidity_lamp", + id="user_name_unprefixed", + ), + pytest.param( + [er.EntityNamePart.ENTITY, er.EntityNamePart.DEVICE], + "Lamp", + None, + None, + True, + "Temperature", + "custom_id", + "Lamp Humidity", + "sensor.custom_id", + "sensor.humidity_lamp", + id="user_name_over_suggested_object_id", + ), + pytest.param( + [er.EntityNamePart.DEVICE, er.EntityNamePart.ENTITY], + None, + None, + None, + True, + None, + None, + None, + "sensor.test_1234", + "sensor.test_1234", + id="all_parts_empty", + ), + ], +) +def test_generate_entity_id_parts( + hass: HomeAssistant, + area_registry: ar.AreaRegistry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + floor_registry: fr.FloorRegistry, + entity_id_parts: list[er.EntityNamePart], + device_name: str | None, + device_area_name: str | None, + floor_name: str | None, + has_entity_name: bool, + object_id_base: str | None, + suggested_object_id: str | None, + user_name: str | None, + expected_initial_entity_id: str, + expected_entity_id: str, +) -> None: + """Test generating and regenerating entity IDs with configured parts.""" + config_entry = MockConfigEntry(domain="sensor") + config_entry.add_to_hass(hass) + + entity_registry.async_update_settings(entity_id_parts=entity_id_parts) + + device_id: str | None = None + if device_name is not None: + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + name=device_name, + ) + device_id = device_entry.id + if device_area_name is not None: + device_area = area_registry.async_create(device_area_name) + if floor_name is not None: + floor = floor_registry.async_create(floor_name) + area_registry.async_update(device_area.id, floor_id=floor.floor_id) + device_registry.async_update_device(device_id, area_id=device_area.id) + + entry = entity_registry.async_get_or_create( + "sensor", + "test", + "1234", + config_entry=config_entry, + device_id=device_id, + has_entity_name=has_entity_name, + object_id_base=object_id_base, + original_name=object_id_base, + suggested_object_id=suggested_object_id, + ) + assert entry.entity_id == expected_initial_entity_id + + if user_name is not None: + entry = entity_registry.async_update_entity(entry.entity_id, name=user_name) + + new_entity_id = entity_registry.async_regenerate_entity_id(entry) + assert new_entity_id == expected_entity_id + + +def test_generate_entity_id_parts_entity_area( + hass: HomeAssistant, + area_registry: ar.AreaRegistry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + floor_registry: fr.FloorRegistry, +) -> None: + """Test the entity area override drives the floor part.""" + config_entry = MockConfigEntry(domain="sensor") + config_entry.add_to_hass(hass) + + entity_registry.async_update_settings( + entity_id_parts=[ + er.EntityNamePart.FLOOR, + er.EntityNamePart.AREA, + er.EntityNamePart.DEVICE, + er.EntityNamePart.ENTITY, + ] + ) + + first_floor = floor_registry.async_create("First Floor") + second_floor = floor_registry.async_create("Second Floor") + kitchen = area_registry.async_create("Kitchen") + area_registry.async_update(kitchen.id, floor_id=first_floor.floor_id) + garage = area_registry.async_create("Garage") + area_registry.async_update(garage.id, floor_id=second_floor.floor_id) + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + name="Lamp", + ) + device_registry.async_update_device(device_entry.id, area_id=kitchen.id) + + entry = entity_registry.async_get_or_create( + "sensor", + "test", + "1234", + config_entry=config_entry, + device_id=device_entry.id, + has_entity_name=True, + object_id_base="Temperature", + original_name="Temperature", + ) + assert entry.entity_id == "sensor.first_floor_kitchen_lamp_temperature" + + entry = entity_registry.async_update_entity(entry.entity_id, area_id=garage.id) + + new_entity_id = entity_registry.async_regenerate_entity_id(entry) + assert new_entity_id == "sensor.second_floor_garage_lamp_temperature" + + +def test_regenerate_entity_id_after_settings_change( + hass: HomeAssistant, + area_registry: ar.AreaRegistry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test regenerating an entity ID after the parts setting changed.""" + config_entry = MockConfigEntry(domain="sensor") + config_entry.add_to_hass(hass) + + kitchen = area_registry.async_create("Kitchen") + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + name="Lamp", + ) + device_registry.async_update_device(device_entry.id, area_id=kitchen.id) + + entry = entity_registry.async_get_or_create( + "sensor", + "test", + "1234", + config_entry=config_entry, + device_id=device_entry.id, + has_entity_name=True, + object_id_base="Temperature", + original_name="Temperature", + ) + assert entry.entity_id == "sensor.kitchen_lamp_temperature" + + entity_registry.async_update_settings( + entity_id_parts=[er.EntityNamePart.DEVICE, er.EntityNamePart.ENTITY] + ) + + new_entity_id = entity_registry.async_regenerate_entity_id(entry) + assert new_entity_id == "sensor.lamp_temperature" + + +def test_entity_id_parts_do_not_affect_full_entity_name( + hass: HomeAssistant, + area_registry: ar.AreaRegistry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + floor_registry: fr.FloorRegistry, +) -> None: + """Test the entity ID parts setting does not affect the full entity name.""" + config_entry = MockConfigEntry(domain="sensor") + config_entry.add_to_hass(hass) + + entity_registry.async_update_settings( + entity_id_parts=[ + er.EntityNamePart.FLOOR, + er.EntityNamePart.AREA, + er.EntityNamePart.ENTITY, + er.EntityNamePart.DEVICE, + ] + ) + + floor = floor_registry.async_create("First Floor") + kitchen = area_registry.async_create("Kitchen") + area_registry.async_update(kitchen.id, floor_id=floor.floor_id) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + name="Lamp", + ) + device_registry.async_update_device(device_entry.id, area_id=kitchen.id) + + entry = entity_registry.async_get_or_create( + "sensor", + "test", + "1234", + config_entry=config_entry, + device_id=device_entry.id, + has_entity_name=True, + object_id_base="Temperature", + original_name="Temperature", + ) + assert entry.entity_id == "sensor.first_floor_kitchen_temperature_lamp" + + assert er.async_get_full_entity_name(hass, entry) == "Lamp Temperature" + + +def test_update_settings(entity_registry: er.EntityRegistry) -> None: + """Test updating entity registry settings.""" + assert entity_registry.settings == er.EntityRegistrySettings(entity_id_parts=None) + + with patch.object(entity_registry, "async_schedule_save") as mock_schedule_save: + settings = entity_registry.async_update_settings( + entity_id_parts=[er.EntityNamePart.DEVICE, er.EntityNamePart.ENTITY] + ) + assert settings.entity_id_parts == ( + er.EntityNamePart.DEVICE, + er.EntityNamePart.ENTITY, + ) + assert entity_registry.settings == settings + assert len(mock_schedule_save.mock_calls) == 1 + + # Unchanged settings don't trigger a save + with patch.object(entity_registry, "async_schedule_save") as mock_schedule_save: + settings = entity_registry.async_update_settings( + entity_id_parts=[er.EntityNamePart.DEVICE, er.EntityNamePart.ENTITY] + ) + entity_registry.async_update_settings() + assert settings.entity_id_parts == ( + er.EntityNamePart.DEVICE, + er.EntityNamePart.ENTITY, + ) + assert len(mock_schedule_save.mock_calls) == 0 + + with patch.object(entity_registry, "async_schedule_save") as mock_schedule_save: + settings = entity_registry.async_update_settings(entity_id_parts=None) + assert settings.entity_id_parts is None + assert entity_registry.settings == settings + assert len(mock_schedule_save.mock_calls) == 1 + + def test_is_registered(entity_registry: er.EntityRegistry) -> None: """Test that is_registered works.""" entry = entity_registry.async_get_or_create("light", "hue", "1234") @@ -995,6 +1418,7 @@ async def test_load_bad_data( "unique_id": ["also", "not", "valid"], # Should not load }, ], + "settings": {"entity_id_parts": None}, }, } @@ -1422,6 +1846,7 @@ async def test_migration_1_1(hass: HomeAssistant, hass_storage: dict[str, Any]) } ], "deleted_entities": [], + "settings": {"entity_id_parts": None}, }, } @@ -1649,6 +2074,7 @@ async def test_migration_1_11( "unique_id": "very_very_unique", } ], + "settings": {"entity_id_parts": None}, }, } @@ -1819,6 +2245,7 @@ async def test_migration_1_18( "unique_id": "very_very_unique", } ], + "settings": {"entity_id_parts": None}, }, } @@ -2021,6 +2448,7 @@ async def test_migration_1_21( }, ], "deleted_entities": [], + "settings": {"entity_id_parts": None}, }, } From 273577b399e5934b5e215ba2a98057e866ebde93 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Tue, 28 Jul 2026 23:06:27 +0200 Subject: [PATCH 14/14] Remove Matter doorbell device type for "switch" event entity (#177493) --- homeassistant/components/matter/event.py | 5 +- .../matter/snapshots/test_event.ambr | 65 ------------------- 2 files changed, 1 insertion(+), 69 deletions(-) diff --git a/homeassistant/components/matter/event.py b/homeassistant/components/matter/event.py index 66286b40be789..ad4d9aa4d6ce7 100644 --- a/homeassistant/components/matter/event.py +++ b/homeassistant/components/matter/event.py @@ -148,10 +148,7 @@ def _on_matter_node_event( clusters.Switch.Attributes.CurrentPosition, clusters.Switch.Attributes.FeatureMap, ), - device_type=( - device_types.Doorbell, - device_types.GenericSwitch, - ), + device_type=(device_types.GenericSwitch,), optional_attributes=( clusters.Switch.Attributes.NumberOfPositions, clusters.FixedLabel.Attributes.LabelList, diff --git a/tests/components/matter/snapshots/test_event.ambr b/tests/components/matter/snapshots/test_event.ambr index fa19351158aa7..0ec4e27fff8a3 100644 --- a/tests/components/matter/snapshots/test_event.ambr +++ b/tests/components/matter/snapshots/test_event.ambr @@ -1779,71 +1779,6 @@ 'state': 'unknown', }) # --- -# name: test_events[mock_doorbell][event.mock_doorbell_button-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': dict({ - : list([ - 'initial_press', - 'short_release', - 'long_press', - 'long_release', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'event', - 'entity_category': None, - 'entity_id': 'event.mock_doorbell_button', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Button', - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Button', - 'platform': 'matter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'button', - 'unique_id': '00000000000004D2-000000000000003F-MatterNodeDevice-1-GenericSwitch-59-1', - 'unit_of_measurement': None, - }) -# --- -# name: test_events[mock_doorbell][event.mock_doorbell_button-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'button', - : None, - : list([ - 'initial_press', - 'short_release', - 'long_press', - 'long_release', - ]), - : 'Mock Doorbell Button', - }), - 'context': , - 'entity_id': 'event.mock_doorbell_button', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_events[mock_generic_switch][event.mock_generic_switch_button-entry] EntityRegistryEntrySnapshot({ 'aliases': list([