Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 7 additions & 9 deletions homeassistant/components/config/device_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand All @@ -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))
2 changes: 1 addition & 1 deletion homeassistant/components/daikin/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."]
}
10 changes: 10 additions & 0 deletions homeassistant/components/knx/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions homeassistant/components/knx/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
23 changes: 20 additions & 3 deletions homeassistant/components/knx/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down
10 changes: 2 additions & 8 deletions homeassistant/components/knx/quality_scale.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions homeassistant/components/knx/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -60,6 +63,7 @@
CONF_SYNC_STATE,
CONF_VALUE,
KNX_ADDRESS,
UI_DEVICE_ID_PREFIX,
ClimateConf,
ColorTempModes,
CoverConf,
Expand Down Expand Up @@ -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)
),
Expand Down
8 changes: 5 additions & 3 deletions homeassistant/components/knx/storage/config_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
3 changes: 2 additions & 1 deletion homeassistant/components/knx/websocket.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/lyngdorf/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions homeassistant/components/tado/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions requirements_all.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

49 changes: 46 additions & 3 deletions tests/components/config/test_device_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down
13 changes: 9 additions & 4 deletions tests/components/duco/test_select.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand All @@ -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] == [
Expand Down
Loading
Loading