Add category registry (#110897)

* Add category registry

* Add entity registry support

* Update homeassistant/components/config/entity_registry.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Use ulid instead

* Add tests for adding same name in different scopes

* Handle keyerror on update

* Lookup tweak

* Omit categories from entity registry snapshots

* Use base registry

* Update snapshots

* Update snapshots

---------

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
Franck Nijhof 2024-03-15 13:25:16 +01:00 committed by GitHub
parent 436c83e8a7
commit 0e2775667d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 2232 additions and 13 deletions

View File

@ -62,6 +62,7 @@ from .const import (
from .exceptions import HomeAssistantError
from .helpers import (
area_registry,
category_registry,
config_validation as cv,
device_registry,
entity,
@ -342,6 +343,7 @@ async def async_load_base_functionality(hass: core.HomeAssistant) -> None:
template.async_setup(hass)
await asyncio.gather(
create_eager_task(area_registry.async_load(hass)),
create_eager_task(category_registry.async_load(hass)),
create_eager_task(device_registry.async_load(hass)),
create_eager_task(entity_registry.async_load(hass)),
create_eager_task(floor_registry.async_load(hass)),

View File

@ -14,6 +14,7 @@ from . import (
auth,
auth_provider_homeassistant,
automation,
category_registry,
config_entries,
core,
device_registry,
@ -30,6 +31,7 @@ SECTIONS = (
auth,
auth_provider_homeassistant,
automation,
category_registry,
config_entries,
core,
device_registry,

View File

@ -0,0 +1,134 @@
"""Websocket API to interact with the category registry."""
from typing import Any
import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.components.websocket_api.connection import ActiveConnection
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import category_registry as cr, config_validation as cv
@callback
def async_setup(hass: HomeAssistant) -> bool:
"""Register the category registry WS commands."""
websocket_api.async_register_command(hass, websocket_list_categories)
websocket_api.async_register_command(hass, websocket_create_category)
websocket_api.async_register_command(hass, websocket_delete_category)
websocket_api.async_register_command(hass, websocket_update_category)
return True
@websocket_api.websocket_command(
{
vol.Required("type"): "config/category_registry/list",
vol.Required("scope"): str,
}
)
@callback
def websocket_list_categories(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
"""Handle list categories command."""
category_registry = cr.async_get(hass)
connection.send_result(
msg["id"],
[
_entry_dict(entry)
for entry in category_registry.async_list_categories(scope=msg["scope"])
],
)
@websocket_api.websocket_command(
{
vol.Required("type"): "config/category_registry/create",
vol.Required("scope"): str,
vol.Required("name"): str,
vol.Optional("icon"): vol.Any(cv.icon, None),
}
)
@websocket_api.require_admin
@callback
def websocket_create_category(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
"""Create category command."""
category_registry = cr.async_get(hass)
data = dict(msg)
data.pop("type")
data.pop("id")
try:
entry = category_registry.async_create(**data)
except ValueError as err:
connection.send_error(msg["id"], "invalid_info", str(err))
else:
connection.send_result(msg["id"], _entry_dict(entry))
@websocket_api.websocket_command(
{
vol.Required("type"): "config/category_registry/delete",
vol.Required("scope"): str,
vol.Required("category_id"): str,
}
)
@websocket_api.require_admin
@callback
def websocket_delete_category(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
"""Delete category command."""
category_registry = cr.async_get(hass)
try:
category_registry.async_delete(
scope=msg["scope"], category_id=msg["category_id"]
)
except KeyError:
connection.send_error(msg["id"], "invalid_info", "Category ID doesn't exist")
else:
connection.send_result(msg["id"])
@websocket_api.websocket_command(
{
vol.Required("type"): "config/category_registry/update",
vol.Required("scope"): str,
vol.Required("category_id"): str,
vol.Optional("name"): str,
vol.Optional("icon"): vol.Any(cv.icon, None),
}
)
@websocket_api.require_admin
@callback
def websocket_update_category(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
"""Handle update category websocket command."""
category_registry = cr.async_get(hass)
data = dict(msg)
data.pop("type")
data.pop("id")
try:
entry = category_registry.async_update(**data)
except ValueError as err:
connection.send_error(msg["id"], "invalid_info", str(err))
except KeyError:
connection.send_error(msg["id"], "invalid_info", "Category ID doesn't exist")
else:
connection.send_result(msg["id"], _entry_dict(entry))
@callback
def _entry_dict(entry: cr.CategoryEntry) -> dict[str, Any]:
"""Convert entry to API format."""
return {
"category_id": entry.category_id,
"icon": entry.icon,
"name": entry.name,
}

View File

@ -153,6 +153,16 @@ def websocket_get_entities(
# If passed in, we update value. Passing None will remove old value.
vol.Optional("aliases"): list,
vol.Optional("area_id"): vol.Any(str, None),
# Categories is a mapping of key/value (scope/category_id) pairs.
# If passed in, we update/adjust only the provided scope(s).
# Other category scopes in the entity, are left as is.
#
# Categorized items such as entities
# can only be in 1 category ID per scope at a time.
# Therefore, passing in a category ID will either add or move
# the entity to that specific category. Passing in None will
# remove the entity from the category.
vol.Optional("categories"): cv.schema_with_slug_keys(vol.Any(str, None)),
vol.Optional("device_class"): vol.Any(str, None),
vol.Optional("icon"): vol.Any(str, None),
vol.Optional("name"): vol.Any(str, None),
@ -227,6 +237,18 @@ def websocket_update_entity(
)
return
# Update the categories if provided
if "categories" in msg:
categories = entity_entry.categories.copy()
for scope, category_id in msg["categories"].items():
if scope in categories and category_id is None:
# Remove the category from the scope as it was unset
del categories[scope]
elif category_id is not None:
# Add or update the category for the given scope
categories[scope] = category_id
changes["categories"] = categories
try:
if changes:
entity_entry = registry.async_update_entity(entity_id, **changes)

View File

@ -0,0 +1,208 @@
"""Provide a way to categorize things within a defined scope."""
from __future__ import annotations
from collections.abc import Iterable
import dataclasses
from dataclasses import dataclass, field
from typing import Literal, TypedDict, cast
from homeassistant.core import HomeAssistant, callback
from homeassistant.util.ulid import ulid_now
from .registry import BaseRegistry
from .typing import UNDEFINED, EventType, UndefinedType
DATA_REGISTRY = "category_registry"
EVENT_CATEGORY_REGISTRY_UPDATED = "category_registry_updated"
STORAGE_KEY = "core.category_registry"
STORAGE_VERSION_MAJOR = 1
class EventCategoryRegistryUpdatedData(TypedDict):
"""Event data for when the category registry is updated."""
action: Literal["create", "remove", "update"]
scope: str
category_id: str
EventCategoryRegistryUpdated = EventType[EventCategoryRegistryUpdatedData]
@dataclass(slots=True, kw_only=True, frozen=True)
class CategoryEntry:
"""Category registry entry."""
category_id: str = field(default_factory=ulid_now)
icon: str | None = None
name: str
class CategoryRegistry(BaseRegistry):
"""Class to hold a registry of categories by scope."""
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the category registry."""
self.hass = hass
self.categories: dict[str, dict[str, CategoryEntry]] = {}
self._store = hass.helpers.storage.Store(
STORAGE_VERSION_MAJOR,
STORAGE_KEY,
atomic_writes=True,
)
@callback
def async_get_category(
self, *, scope: str, category_id: str
) -> CategoryEntry | None:
"""Get category by ID."""
if scope not in self.categories:
return None
return self.categories[scope].get(category_id)
@callback
def async_list_categories(self, *, scope: str) -> Iterable[CategoryEntry]:
"""Get all categories."""
if scope not in self.categories:
return []
return self.categories[scope].values()
@callback
def async_create(
self,
*,
name: str,
scope: str,
icon: str | None = None,
) -> CategoryEntry:
"""Create a new category."""
self._async_ensure_name_is_available(scope, name)
category = CategoryEntry(
icon=icon,
name=name,
)
if scope not in self.categories:
self.categories[scope] = {}
self.categories[scope][category.category_id] = category
self.async_schedule_save()
self.hass.bus.async_fire(
EVENT_CATEGORY_REGISTRY_UPDATED,
EventCategoryRegistryUpdatedData(
action="create", scope=scope, category_id=category.category_id
),
)
return category
@callback
def async_delete(self, *, scope: str, category_id: str) -> None:
"""Delete category."""
del self.categories[scope][category_id]
self.hass.bus.async_fire(
EVENT_CATEGORY_REGISTRY_UPDATED,
EventCategoryRegistryUpdatedData(
action="remove",
scope=scope,
category_id=category_id,
),
)
self.async_schedule_save()
@callback
def async_update(
self,
*,
scope: str,
category_id: str,
icon: str | None | UndefinedType = UNDEFINED,
name: str | UndefinedType = UNDEFINED,
) -> CategoryEntry:
"""Update name or icon of the category."""
old = self.categories[scope][category_id]
changes = {}
if icon is not UNDEFINED and icon != old.icon:
changes["icon"] = icon
if name is not UNDEFINED and name != old.name:
changes["name"] = name
self._async_ensure_name_is_available(scope, name, category_id)
if not changes:
return old
new = self.categories[scope][category_id] = dataclasses.replace(old, **changes) # type: ignore[arg-type]
self.async_schedule_save()
self.hass.bus.async_fire(
EVENT_CATEGORY_REGISTRY_UPDATED,
EventCategoryRegistryUpdatedData(
action="update", scope=scope, category_id=category_id
),
)
return new
async def async_load(self) -> None:
"""Load the category registry."""
data = await self._store.async_load()
category_entries: dict[str, dict[str, CategoryEntry]] = {}
if data is not None:
for scope, categories in data["categories"].items():
category_entries[scope] = {
category["category_id"]: CategoryEntry(
category_id=category["category_id"],
icon=category["icon"],
name=category["name"],
)
for category in categories
}
self.categories = category_entries
@callback
def _data_to_save(self) -> dict[str, dict[str, list[dict[str, str | None]]]]:
"""Return data of category registry to store in a file."""
return {
"categories": {
scope: [
{
"category_id": entry.category_id,
"icon": entry.icon,
"name": entry.name,
}
for entry in entries.values()
]
for scope, entries in self.categories.items()
}
}
@callback
def _async_ensure_name_is_available(
self, scope: str, name: str, category_id: str | None = None
) -> None:
"""Ensure name is available within the scope."""
if scope not in self.categories:
return
for category in self.categories[scope].values():
if (
category.name.casefold() == name.casefold()
and category.category_id != category_id
):
raise ValueError(f"The name '{name}' is already in use")
@callback
def async_get(hass: HomeAssistant) -> CategoryRegistry:
"""Get category registry."""
return cast(CategoryRegistry, hass.data[DATA_REGISTRY])
async def async_load(hass: HomeAssistant) -> None:
"""Load category registry."""
assert DATA_REGISTRY not in hass.data
hass.data[DATA_REGISTRY] = CategoryRegistry(hass)
await hass.data[DATA_REGISTRY].async_load()

View File

@ -67,7 +67,7 @@ EVENT_ENTITY_REGISTRY_UPDATED = "entity_registry_updated"
_LOGGER = logging.getLogger(__name__)
STORAGE_VERSION_MAJOR = 1
STORAGE_VERSION_MINOR = 13
STORAGE_VERSION_MINOR = 14
STORAGE_KEY = "core.entity_registry"
CLEANUP_INTERVAL = 3600 * 24
@ -164,6 +164,7 @@ class RegistryEntry:
previous_unique_id: str | None = attr.ib(default=None)
aliases: set[str] = attr.ib(factory=set)
area_id: str | None = attr.ib(default=None)
categories: dict[str, str] = attr.ib(factory=dict)
capabilities: Mapping[str, Any] | None = attr.ib(default=None)
config_entry_id: str | None = attr.ib(default=None)
device_class: str | None = attr.ib(default=None)
@ -262,6 +263,7 @@ class RegistryEntry:
# it every time
return {
"area_id": self.area_id,
"categories": self.categories,
"config_entry_id": self.config_entry_id,
"device_id": self.device_id,
"disabled_by": self.disabled_by,
@ -319,6 +321,7 @@ class RegistryEntry:
{
"aliases": list(self.aliases),
"area_id": self.area_id,
"categories": self.categories,
"capabilities": self.capabilities,
"config_entry_id": self.config_entry_id,
"device_class": self.device_class,
@ -498,6 +501,11 @@ class EntityRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]):
for entity in data["entities"]:
entity["labels"] = []
if old_major_version == 1 and old_minor_version < 14:
# Version 1.14 adds categories
for entity in data["entities"]:
entity["categories"] = {}
if old_major_version > 1:
raise NotImplementedError
return data
@ -952,6 +960,7 @@ class EntityRegistry(BaseRegistry):
*,
aliases: set[str] | UndefinedType = UNDEFINED,
area_id: str | None | UndefinedType = UNDEFINED,
categories: dict[str, str] | UndefinedType = UNDEFINED,
capabilities: Mapping[str, Any] | None | UndefinedType = UNDEFINED,
config_entry_id: str | None | UndefinedType = UNDEFINED,
device_class: str | None | UndefinedType = UNDEFINED,
@ -1003,6 +1012,7 @@ class EntityRegistry(BaseRegistry):
for attr_name, value in (
("aliases", aliases),
("area_id", area_id),
("categories", categories),
("capabilities", capabilities),
("config_entry_id", config_entry_id),
("device_class", device_class),
@ -1081,6 +1091,7 @@ class EntityRegistry(BaseRegistry):
*,
aliases: set[str] | UndefinedType = UNDEFINED,
area_id: str | None | UndefinedType = UNDEFINED,
categories: dict[str, str] | UndefinedType = UNDEFINED,
capabilities: Mapping[str, Any] | None | UndefinedType = UNDEFINED,
config_entry_id: str | None | UndefinedType = UNDEFINED,
device_class: str | None | UndefinedType = UNDEFINED,
@ -1106,6 +1117,7 @@ class EntityRegistry(BaseRegistry):
entity_id,
aliases=aliases,
area_id=area_id,
categories=categories,
capabilities=capabilities,
config_entry_id=config_entry_id,
device_class=device_class,
@ -1196,6 +1208,7 @@ class EntityRegistry(BaseRegistry):
entities[entity["entity_id"]] = RegistryEntry(
aliases=set(entity["aliases"]),
area_id=entity["area_id"],
categories=entity["categories"],
capabilities=entity["capabilities"],
config_entry_id=entity["config_entry_id"],
device_class=entity["device_class"],
@ -1255,6 +1268,17 @@ class EntityRegistry(BaseRegistry):
],
}
@callback
def async_clear_category_id(self, scope: str, category_id: str) -> None:
"""Clear category id from registry entries."""
for entity_id, entry in self.entities.items():
if (
existing_category_id := entry.categories.get(scope)
) and category_id == existing_category_id:
categories = entry.categories.copy()
del categories[scope]
self.async_update_entity(entity_id, categories=categories)
@callback
def async_clear_label_id(self, label_id: str) -> None:
"""Clear label from registry entries."""
@ -1344,6 +1368,21 @@ def async_entries_for_label(
return [entry for entry in registry.entities.values() if label_id in entry.labels]
@callback
def async_entries_for_category(
registry: EntityRegistry, scope: str, category_id: str
) -> list[RegistryEntry]:
"""Return entries that match a category in a scope."""
return [
entry
for entry in registry.entities.values()
if (
(existing_category_id := entry.categories.get(scope))
and category_id == existing_category_id
)
]
@callback
def async_entries_for_config_entry(
registry: EntityRegistry, config_entry_id: str
@ -1386,13 +1425,13 @@ def async_config_entry_disabled_by_changed(
def _async_setup_cleanup(hass: HomeAssistant, registry: EntityRegistry) -> None:
"""Clean up device registry when entities removed."""
# pylint: disable-next=import-outside-toplevel
from . import event, label_registry as lr
from . import category_registry as cr, event, label_registry as lr
@callback
def _label_removed_from_registry_filter(
event: lr.EventLabelRegistryUpdated,
def _removed_from_registry_filter(
event: lr.EventLabelRegistryUpdated | cr.EventCategoryRegistryUpdated,
) -> bool:
"""Filter all except for the remove action from label registry events."""
"""Filter all except for the remove action from registry events."""
return event.data["action"] == "remove"
@callback
@ -1402,10 +1441,23 @@ def _async_setup_cleanup(hass: HomeAssistant, registry: EntityRegistry) -> None:
hass.bus.async_listen(
event_type=lr.EVENT_LABEL_REGISTRY_UPDATED,
event_filter=_label_removed_from_registry_filter,
event_filter=_removed_from_registry_filter,
listener=_handle_label_registry_update,
)
@callback
def _handle_category_registry_update(
event: cr.EventCategoryRegistryUpdated,
) -> None:
"""Update entity that have a category that has been removed."""
registry.async_clear_category_id(event.data["scope"], event.data["category_id"])
hass.bus.async_listen(
event_type=cr.EVENT_CATEGORY_REGISTRY_UPDATED,
event_filter=_removed_from_registry_filter,
listener=_handle_category_registry_update,
)
@callback
def cleanup(_: datetime) -> None:
"""Clean up entity registry."""

View File

@ -688,6 +688,7 @@ ignore = [
[tool.ruff.lint.flake8-import-conventions.extend-aliases]
voluptuous = "vol"
"homeassistant.helpers.area_registry" = "ar"
"homeassistant.helpers.category_registry" = "cr"
"homeassistant.helpers.config_validation" = "cv"
"homeassistant.helpers.device_registry" = "dr"
"homeassistant.helpers.entity_registry" = "er"

View File

@ -59,6 +59,7 @@ from homeassistant.core import (
)
from homeassistant.helpers import (
area_registry as ar,
category_registry as cr,
device_registry as dr,
entity,
entity_platform,
@ -334,6 +335,7 @@ async def async_test_home_assistant(
"homeassistant.helpers.restore_state.start.async_at_start",
):
await ar.async_load(hass)
await cr.async_load(hass)
await dr.async_load(hass)
await er.async_load(hass)
await fr.async_load(hass)

View File

@ -0,0 +1,380 @@
"""Test category registry API."""
import pytest
from homeassistant.components.config import category_registry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import category_registry as cr
from tests.common import ANY
from tests.typing import MockHAClientWebSocket, WebSocketGenerator
@pytest.fixture(name="client")
async def client_fixture(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> MockHAClientWebSocket:
"""Fixture that can interact with the config manager API."""
category_registry.async_setup(hass)
return await hass_ws_client(hass)
async def test_list_categories(
client: MockHAClientWebSocket,
category_registry: cr.CategoryRegistry,
) -> None:
"""Test list entries."""
category1 = category_registry.async_create(
scope="automation",
name="Energy saving",
icon="mdi:leaf",
)
category2 = category_registry.async_create(
scope="automation",
name="Something else",
icon="mdi:home",
)
category3 = category_registry.async_create(
scope="zone",
name="Grocery stores",
icon="mdi:store",
)
assert len(category_registry.categories) == 2
assert len(category_registry.categories["automation"]) == 2
assert len(category_registry.categories["zone"]) == 1
await client.send_json_auto_id(
{"type": "config/category_registry/list", "scope": "automation"}
)
msg = await client.receive_json()
assert len(msg["result"]) == 2
assert msg["result"][0] == {
"category_id": category1.category_id,
"name": "Energy saving",
"icon": "mdi:leaf",
}
assert msg["result"][1] == {
"category_id": category2.category_id,
"name": "Something else",
"icon": "mdi:home",
}
await client.send_json_auto_id(
{"type": "config/category_registry/list", "scope": "zone"}
)
msg = await client.receive_json()
assert len(msg["result"]) == 1
assert msg["result"][0] == {
"category_id": category3.category_id,
"name": "Grocery stores",
"icon": "mdi:store",
}
async def test_create_category(
client: MockHAClientWebSocket,
category_registry: cr.CategoryRegistry,
) -> None:
"""Test create entry."""
await client.send_json_auto_id(
{
"type": "config/category_registry/create",
"scope": "automation",
"name": "Energy saving",
"icon": "mdi:leaf",
}
)
msg = await client.receive_json()
assert len(category_registry.categories) == 1
assert len(category_registry.categories["automation"]) == 1
assert msg["result"] == {
"icon": "mdi:leaf",
"category_id": ANY,
"name": "Energy saving",
}
await client.send_json_auto_id(
{
"scope": "automation",
"name": "Something else",
"type": "config/category_registry/create",
}
)
msg = await client.receive_json()
assert len(category_registry.categories) == 1
assert len(category_registry.categories["automation"]) == 2
assert msg["result"] == {
"icon": None,
"category_id": ANY,
"name": "Something else",
}
# Test adding the same one again in a different scope
await client.send_json_auto_id(
{
"type": "config/category_registry/create",
"scope": "script",
"name": "Energy saving",
"icon": "mdi:leaf",
}
)
msg = await client.receive_json()
assert len(category_registry.categories) == 2
assert len(category_registry.categories["automation"]) == 2
assert len(category_registry.categories["script"]) == 1
assert msg["result"] == {
"icon": "mdi:leaf",
"category_id": ANY,
"name": "Energy saving",
}
async def test_create_category_with_name_already_in_use(
client: MockHAClientWebSocket,
category_registry: cr.CategoryRegistry,
) -> None:
"""Test create entry that should fail."""
category_registry.async_create(
scope="automation",
name="Energy saving",
)
assert len(category_registry.categories) == 1
assert len(category_registry.categories["automation"]) == 1
await client.send_json_auto_id(
{
"scope": "automation",
"name": "ENERGY SAVING",
"type": "config/category_registry/create",
}
)
msg = await client.receive_json()
assert not msg["success"]
assert msg["error"]["code"] == "invalid_info"
assert msg["error"]["message"] == "The name 'ENERGY SAVING' is already in use"
assert len(category_registry.categories) == 1
assert len(category_registry.categories["automation"]) == 1
async def test_delete_category(
client: MockHAClientWebSocket,
category_registry: cr.CategoryRegistry,
) -> None:
"""Test delete entry."""
category = category_registry.async_create(
scope="automation",
name="Energy saving",
icon="mdi:leaf",
)
assert len(category_registry.categories) == 1
assert len(category_registry.categories["automation"]) == 1
await client.send_json_auto_id(
{
"scope": "automation",
"category_id": category.category_id,
"type": "config/category_registry/delete",
}
)
msg = await client.receive_json()
assert msg["success"]
assert len(category_registry.categories) == 1
assert not category_registry.categories["automation"]
async def test_delete_non_existing_category(
client: MockHAClientWebSocket,
category_registry: cr.CategoryRegistry,
) -> None:
"""Test delete entry that should fail."""
category = category_registry.async_create(
scope="automation",
name="Energy saving",
icon="mdi:leaf",
)
assert len(category_registry.categories) == 1
assert len(category_registry.categories["automation"]) == 1
await client.send_json_auto_id(
{
"scope": "automation",
"category_id": "idkfa",
"type": "config/category_registry/delete",
}
)
msg = await client.receive_json()
assert not msg["success"]
assert msg["error"]["code"] == "invalid_info"
assert msg["error"]["message"] == "Category ID doesn't exist"
assert len(category_registry.categories) == 1
assert len(category_registry.categories["automation"]) == 1
await client.send_json_auto_id(
{
"scope": "bullshizzle",
"category_id": category.category_id,
"type": "config/category_registry/delete",
}
)
msg = await client.receive_json()
assert not msg["success"]
assert msg["error"]["code"] == "invalid_info"
assert msg["error"]["message"] == "Category ID doesn't exist"
assert len(category_registry.categories) == 1
assert len(category_registry.categories["automation"]) == 1
async def test_update_category(
client: MockHAClientWebSocket,
category_registry: cr.CategoryRegistry,
) -> None:
"""Test update entry."""
category = category_registry.async_create(
scope="automation",
name="Energy saving",
)
assert len(category_registry.categories) == 1
assert len(category_registry.categories["automation"]) == 1
await client.send_json_auto_id(
{
"scope": "automation",
"category_id": category.category_id,
"name": "ENERGY SAVING",
"icon": "mdi:left",
"type": "config/category_registry/update",
}
)
msg = await client.receive_json()
assert len(category_registry.categories) == 1
assert len(category_registry.categories["automation"]) == 1
assert msg["result"] == {
"icon": "mdi:left",
"category_id": category.category_id,
"name": "ENERGY SAVING",
}
await client.send_json_auto_id(
{
"scope": "automation",
"category_id": category.category_id,
"name": "Energy saving",
"icon": None,
"type": "config/category_registry/update",
}
)
msg = await client.receive_json()
assert len(category_registry.categories) == 1
assert len(category_registry.categories["automation"]) == 1
assert msg["result"] == {
"icon": None,
"category_id": category.category_id,
"name": "Energy saving",
}
async def test_update_with_name_already_in_use(
client: MockHAClientWebSocket,
category_registry: cr.CategoryRegistry,
) -> None:
"""Test update entry."""
category_registry.async_create(
scope="automation",
name="Energy saving",
)
category = category_registry.async_create(
scope="automation",
name="Something else",
)
assert len(category_registry.categories) == 1
assert len(category_registry.categories["automation"]) == 2
await client.send_json_auto_id(
{
"scope": "automation",
"category_id": category.category_id,
"name": "ENERGY SAVING",
"type": "config/category_registry/update",
}
)
msg = await client.receive_json()
assert not msg["success"]
assert msg["error"]["code"] == "invalid_info"
assert msg["error"]["message"] == "The name 'ENERGY SAVING' is already in use"
assert len(category_registry.categories) == 1
assert len(category_registry.categories["automation"]) == 2
async def test_update_non_existing_category(
client: MockHAClientWebSocket,
category_registry: cr.CategoryRegistry,
) -> None:
"""Test update entry that should fail."""
category = category_registry.async_create(
scope="automation",
name="Energy saving",
icon="mdi:leaf",
)
assert len(category_registry.categories) == 1
assert len(category_registry.categories["automation"]) == 1
await client.send_json_auto_id(
{
"scope": "automation",
"category_id": "idkfa",
"name": "New category name",
"type": "config/category_registry/update",
}
)
msg = await client.receive_json()
assert not msg["success"]
assert msg["error"]["code"] == "invalid_info"
assert msg["error"]["message"] == "Category ID doesn't exist"
assert len(category_registry.categories) == 1
assert len(category_registry.categories["automation"]) == 1
await client.send_json_auto_id(
{
"scope": "bullshizzle",
"category_id": category.category_id,
"name": "New category name",
"type": "config/category_registry/update",
}
)
msg = await client.receive_json()
assert not msg["success"]
assert msg["error"]["code"] == "invalid_info"
assert msg["error"]["message"] == "Category ID doesn't exist"
assert len(category_registry.categories) == 1
assert len(category_registry.categories["automation"]) == 1

View File

@ -61,6 +61,7 @@ async def test_list_entities(
assert msg["result"] == [
{
"area_id": None,
"categories": {},
"config_entry_id": None,
"device_id": None,
"disabled_by": None,
@ -80,6 +81,7 @@ async def test_list_entities(
},
{
"area_id": None,
"categories": {},
"config_entry_id": None,
"device_id": None,
"disabled_by": None,
@ -126,6 +128,7 @@ async def test_list_entities(
assert msg["result"] == [
{
"area_id": None,
"categories": {},
"config_entry_id": None,
"device_id": None,
"disabled_by": None,
@ -349,6 +352,7 @@ async def test_get_entity(hass: HomeAssistant, client: MockHAClientWebSocket) ->
"aliases": [],
"area_id": None,
"capabilities": None,
"categories": {},
"config_entry_id": None,
"device_class": None,
"device_id": None,
@ -382,6 +386,7 @@ async def test_get_entity(hass: HomeAssistant, client: MockHAClientWebSocket) ->
"aliases": [],
"area_id": None,
"capabilities": None,
"categories": {},
"config_entry_id": None,
"device_class": None,
"device_id": None,
@ -440,6 +445,7 @@ async def test_get_entities(hass: HomeAssistant, client: MockHAClientWebSocket)
"aliases": [],
"area_id": None,
"capabilities": None,
"categories": {},
"config_entry_id": None,
"device_class": None,
"device_id": None,
@ -464,6 +470,7 @@ async def test_get_entities(hass: HomeAssistant, client: MockHAClientWebSocket)
"aliases": [],
"area_id": None,
"capabilities": None,
"categories": {},
"config_entry_id": None,
"device_class": None,
"device_id": None,
@ -514,13 +521,14 @@ async def test_update_entity(
assert state.name == "before update"
assert state.attributes[ATTR_ICON] == "icon:before update"
# UPDATE AREA, DEVICE_CLASS, HIDDEN_BY, ICON AND NAME
# Update area, categories, device_class, hidden_by, icon, labels & name
await client.send_json_auto_id(
{
"type": "config/entity_registry/update",
"entity_id": "test_domain.world",
"aliases": ["alias_1", "alias_2"],
"area_id": "mock-area-id",
"categories": {"scope1": "id", "scope2": "id"},
"device_class": "custom_device_class",
"hidden_by": "user", # We exchange strings over the WS API, not enums
"icon": "icon:after update",
@ -535,6 +543,7 @@ async def test_update_entity(
"aliases": unordered(["alias_1", "alias_2"]),
"area_id": "mock-area-id",
"capabilities": None,
"categories": {"scope1": "id", "scope2": "id"},
"config_entry_id": None,
"device_class": "custom_device_class",
"device_id": None,
@ -561,7 +570,7 @@ async def test_update_entity(
assert state.name == "after update"
assert state.attributes[ATTR_ICON] == "icon:after update"
# UPDATE HIDDEN_BY TO ILLEGAL VALUE
# Update hidden_by to illegal value
await client.send_json_auto_id(
{
"type": "config/entity_registry/update",
@ -575,7 +584,7 @@ async def test_update_entity(
assert registry.entities["test_domain.world"].hidden_by is RegistryEntryHider.USER
# UPDATE DISABLED_BY TO USER
# Update disabled_by to user
await client.send_json_auto_id(
{
"type": "config/entity_registry/update",
@ -592,7 +601,7 @@ async def test_update_entity(
registry.entities["test_domain.world"].disabled_by is RegistryEntryDisabler.USER
)
# UPDATE DISABLED_BY TO NONE
# Update disabled_by to None
await client.send_json_auto_id(
{
"type": "config/entity_registry/update",
@ -608,6 +617,7 @@ async def test_update_entity(
"aliases": unordered(["alias_1", "alias_2"]),
"area_id": "mock-area-id",
"capabilities": None,
"categories": {"scope1": "id", "scope2": "id"},
"config_entry_id": None,
"device_class": "custom_device_class",
"device_id": None,
@ -631,7 +641,7 @@ async def test_update_entity(
"require_restart": True,
}
# UPDATE ENTITY OPTION
# Update entity option
await client.send_json_auto_id(
{
"type": "config/entity_registry/update",
@ -648,6 +658,127 @@ async def test_update_entity(
"aliases": unordered(["alias_1", "alias_2"]),
"area_id": "mock-area-id",
"capabilities": None,
"categories": {"scope1": "id", "scope2": "id"},
"config_entry_id": None,
"device_class": "custom_device_class",
"device_id": None,
"disabled_by": None,
"entity_category": None,
"entity_id": "test_domain.world",
"has_entity_name": False,
"hidden_by": "user", # We exchange strings over the WS API, not enums
"icon": "icon:after update",
"id": ANY,
"labels": [],
"name": "after update",
"options": {"sensor": {"unit_of_measurement": "beard_second"}},
"original_device_class": None,
"original_icon": None,
"original_name": None,
"platform": "test_platform",
"translation_key": None,
"unique_id": "1234",
},
}
# Add a category to the entity
await client.send_json_auto_id(
{
"type": "config/entity_registry/update",
"entity_id": "test_domain.world",
"categories": {"scope3": "id"},
}
)
msg = await client.receive_json()
assert msg["success"]
assert msg["result"] == {
"entity_entry": {
"aliases": unordered(["alias_1", "alias_2"]),
"area_id": "mock-area-id",
"capabilities": None,
"categories": {"scope1": "id", "scope2": "id", "scope3": "id"},
"config_entry_id": None,
"device_class": "custom_device_class",
"device_id": None,
"disabled_by": None,
"entity_category": None,
"entity_id": "test_domain.world",
"has_entity_name": False,
"hidden_by": "user", # We exchange strings over the WS API, not enums
"icon": "icon:after update",
"id": ANY,
"labels": [],
"name": "after update",
"options": {"sensor": {"unit_of_measurement": "beard_second"}},
"original_device_class": None,
"original_icon": None,
"original_name": None,
"platform": "test_platform",
"translation_key": None,
"unique_id": "1234",
},
}
# Move the entity to a different category
await client.send_json_auto_id(
{
"type": "config/entity_registry/update",
"entity_id": "test_domain.world",
"categories": {"scope3": "other_id"},
}
)
msg = await client.receive_json()
assert msg["success"]
assert msg["result"] == {
"entity_entry": {
"aliases": unordered(["alias_1", "alias_2"]),
"area_id": "mock-area-id",
"capabilities": None,
"categories": {"scope1": "id", "scope2": "id", "scope3": "other_id"},
"config_entry_id": None,
"device_class": "custom_device_class",
"device_id": None,
"disabled_by": None,
"entity_category": None,
"entity_id": "test_domain.world",
"has_entity_name": False,
"hidden_by": "user", # We exchange strings over the WS API, not enums
"icon": "icon:after update",
"id": ANY,
"labels": [],
"name": "after update",
"options": {"sensor": {"unit_of_measurement": "beard_second"}},
"original_device_class": None,
"original_icon": None,
"original_name": None,
"platform": "test_platform",
"translation_key": None,
"unique_id": "1234",
},
}
# Move the entity to a different category
await client.send_json_auto_id(
{
"type": "config/entity_registry/update",
"entity_id": "test_domain.world",
"categories": {"scope2": None},
}
)
msg = await client.receive_json()
assert msg["success"]
assert msg["result"] == {
"entity_entry": {
"aliases": unordered(["alias_1", "alias_2"]),
"area_id": "mock-area-id",
"capabilities": None,
"categories": {"scope1": "id", "scope3": "other_id"},
"config_entry_id": None,
"device_class": "custom_device_class",
"device_id": None,
@ -702,6 +833,7 @@ async def test_update_entity_require_restart(
"aliases": [],
"area_id": None,
"capabilities": None,
"categories": {},
"config_entry_id": config_entry.entry_id,
"device_class": None,
"device_id": None,
@ -815,6 +947,7 @@ async def test_update_entity_no_changes(
"aliases": [],
"area_id": None,
"capabilities": None,
"categories": {},
"config_entry_id": None,
"device_class": None,
"device_id": None,
@ -904,6 +1037,7 @@ async def test_update_entity_id(
"aliases": [],
"area_id": None,
"capabilities": None,
"categories": {},
"config_entry_id": None,
"device_class": None,
"device_id": None,

View File

@ -61,6 +61,8 @@
'capabilities': dict({
'state_class': 'measurement',
}),
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': None,
@ -111,6 +113,8 @@
'capabilities': dict({
'state_class': 'total_increasing',
}),
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': None,
@ -159,6 +163,8 @@
]),
'area_id': None,
'capabilities': None,
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': None,
@ -208,6 +214,8 @@
'capabilities': dict({
'state_class': 'total_increasing',
}),
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': None,
@ -258,6 +266,8 @@
'capabilities': dict({
'state_class': 'measurement',
}),
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': None,
@ -308,6 +318,8 @@
'capabilities': dict({
'state_class': 'total_increasing',
}),
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': None,
@ -356,6 +368,8 @@
]),
'area_id': None,
'capabilities': None,
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': None,
@ -405,6 +419,8 @@
'capabilities': dict({
'state_class': 'total_increasing',
}),
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': None,
@ -455,6 +471,8 @@
'capabilities': dict({
'state_class': 'measurement',
}),
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': None,
@ -505,6 +523,8 @@
'capabilities': dict({
'state_class': 'total_increasing',
}),
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': None,
@ -553,6 +573,8 @@
]),
'area_id': None,
'capabilities': None,
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': None,
@ -602,6 +624,8 @@
'capabilities': dict({
'state_class': 'total_increasing',
}),
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': None,
@ -652,6 +676,8 @@
'capabilities': dict({
'state_class': 'measurement',
}),
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': None,
@ -702,6 +728,8 @@
'capabilities': dict({
'state_class': 'total_increasing',
}),
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': None,
@ -750,6 +778,8 @@
]),
'area_id': None,
'capabilities': None,
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': None,
@ -799,6 +829,8 @@
'capabilities': dict({
'state_class': 'total_increasing',
}),
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': None,
@ -849,6 +881,8 @@
'capabilities': dict({
'state_class': 'measurement',
}),
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': None,
@ -899,6 +933,8 @@
'capabilities': dict({
'state_class': 'total_increasing',
}),
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': None,
@ -947,6 +983,8 @@
]),
'area_id': None,
'capabilities': None,
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': None,
@ -996,6 +1034,8 @@
'capabilities': dict({
'state_class': 'total_increasing',
}),
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': None,
@ -1046,6 +1086,8 @@
'capabilities': dict({
'state_class': 'measurement',
}),
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': None,
@ -1096,6 +1138,8 @@
'capabilities': dict({
'state_class': 'total_increasing',
}),
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': None,
@ -1144,6 +1188,8 @@
]),
'area_id': None,
'capabilities': None,
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': None,
@ -1193,6 +1239,8 @@
'capabilities': dict({
'state_class': 'total_increasing',
}),
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': None,
@ -1243,6 +1291,8 @@
'capabilities': dict({
'state_class': 'measurement',
}),
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': None,
@ -1293,6 +1343,8 @@
'capabilities': dict({
'state_class': 'total_increasing',
}),
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': None,
@ -1341,6 +1393,8 @@
]),
'area_id': None,
'capabilities': None,
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': None,
@ -1390,6 +1444,8 @@
'capabilities': dict({
'state_class': 'total_increasing',
}),
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': None,
@ -1440,6 +1496,8 @@
'capabilities': dict({
'state_class': 'measurement',
}),
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': None,
@ -1490,6 +1548,8 @@
'capabilities': dict({
'state_class': 'total_increasing',
}),
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': None,
@ -1538,6 +1598,8 @@
]),
'area_id': None,
'capabilities': None,
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': None,
@ -1587,6 +1649,8 @@
'capabilities': dict({
'state_class': 'total_increasing',
}),
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': None,
@ -1637,6 +1701,8 @@
'capabilities': dict({
'state_class': 'total_increasing',
}),
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': None,
@ -1687,6 +1753,8 @@
'capabilities': dict({
'state_class': 'total_increasing',
}),
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': None,
@ -1737,6 +1805,8 @@
'capabilities': dict({
'state_class': 'measurement',
}),
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': None,
@ -1787,6 +1857,8 @@
'capabilities': dict({
'state_class': 'measurement',
}),
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': 'integration',
@ -1821,6 +1893,8 @@
'capabilities': dict({
'state_class': 'measurement',
}),
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': 'integration',
@ -1862,6 +1936,8 @@
'check-wiring',
]),
}),
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': 'integration',
@ -1894,6 +1970,8 @@
]),
'area_id': None,
'capabilities': None,
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': 'integration',
@ -1928,6 +2006,8 @@
'capabilities': dict({
'state_class': 'total_increasing',
}),
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': 'integration',
@ -1965,6 +2045,8 @@
'capabilities': dict({
'state_class': 'total_increasing',
}),
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': 'integration',
@ -2002,6 +2084,8 @@
'capabilities': dict({
'state_class': 'measurement',
}),
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': 'integration',
@ -2039,6 +2123,8 @@
'capabilities': dict({
'state_class': 'measurement',
}),
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': 'integration',
@ -2073,6 +2159,8 @@
'capabilities': dict({
'state_class': 'measurement',
}),
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': 'integration',
@ -2114,6 +2202,8 @@
'check-wiring',
]),
}),
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': 'integration',
@ -2146,6 +2236,8 @@
]),
'area_id': None,
'capabilities': None,
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': 'integration',
@ -2180,6 +2272,8 @@
'capabilities': dict({
'state_class': 'total_increasing',
}),
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': 'integration',
@ -2217,6 +2311,8 @@
'capabilities': dict({
'state_class': 'total_increasing',
}),
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': 'integration',
@ -2254,6 +2350,8 @@
'capabilities': dict({
'state_class': 'measurement',
}),
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': 'integration',
@ -2291,6 +2389,8 @@
'capabilities': dict({
'state_class': 'measurement',
}),
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': 'integration',
@ -2325,6 +2425,8 @@
'capabilities': dict({
'state_class': 'measurement',
}),
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': 'integration',
@ -2366,6 +2468,8 @@
'check-wiring',
]),
}),
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': 'integration',
@ -2398,6 +2502,8 @@
]),
'area_id': None,
'capabilities': None,
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': 'integration',
@ -2432,6 +2538,8 @@
'capabilities': dict({
'state_class': 'total_increasing',
}),
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': 'integration',
@ -2469,6 +2577,8 @@
'capabilities': dict({
'state_class': 'total_increasing',
}),
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': 'integration',
@ -2506,6 +2616,8 @@
'capabilities': dict({
'state_class': 'measurement',
}),
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': 'integration',
@ -2543,6 +2655,8 @@
'capabilities': dict({
'state_class': 'measurement',
}),
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': 'integration',
@ -2577,6 +2691,8 @@
'capabilities': dict({
'state_class': 'measurement',
}),
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': 'integration',
@ -2618,6 +2734,8 @@
'check-wiring',
]),
}),
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': 'integration',
@ -2650,6 +2768,8 @@
]),
'area_id': None,
'capabilities': None,
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': 'integration',
@ -2688,6 +2808,8 @@
'check-wiring',
]),
}),
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': 'integration',
@ -2720,6 +2842,8 @@
]),
'area_id': None,
'capabilities': None,
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': 'integration',
@ -2758,6 +2882,8 @@
'check-wiring',
]),
}),
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': 'integration',
@ -2790,6 +2916,8 @@
]),
'area_id': None,
'capabilities': None,
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': 'integration',
@ -2828,6 +2956,8 @@
'check-wiring',
]),
}),
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': 'integration',
@ -2860,6 +2990,8 @@
]),
'area_id': None,
'capabilities': None,
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': 'integration',
@ -2898,6 +3030,8 @@
'check-wiring',
]),
}),
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': 'integration',
@ -2930,6 +3064,8 @@
]),
'area_id': None,
'capabilities': None,
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': 'integration',
@ -2996,6 +3132,8 @@
'capabilities': dict({
'state_class': 'measurement',
}),
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': None,
@ -3038,6 +3176,8 @@
]),
'area_id': None,
'capabilities': None,
'categories': dict({
}),
'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72',
'device_class': None,
'disabled_by': 'integration',

File diff suppressed because it is too large Load Diff

View File

@ -53,6 +53,7 @@ from homeassistant.const import HASSIO_USER_NAME
from homeassistant.core import CoreState, HassJob, HomeAssistant
from homeassistant.helpers import (
area_registry as ar,
category_registry as cr,
config_entry_oauth2_flow,
device_registry as dr,
entity_registry as er,
@ -1618,6 +1619,12 @@ def mock_bluetooth(
"""Mock out bluetooth from starting."""
@pytest.fixture
def category_registry(hass: HomeAssistant) -> cr.CategoryRegistry:
"""Return the category registry from the current hass instance."""
return cr.async_get(hass)
@pytest.fixture
def area_registry(hass: HomeAssistant) -> ar.AreaRegistry:
"""Return the area registry from the current hass instance."""

View File

@ -0,0 +1,395 @@
"""Tests for the category registry."""
import re
from typing import Any
import pytest
from homeassistant.core import HomeAssistant
from homeassistant.helpers import category_registry as cr
from tests.common import async_capture_events, flush_store
async def test_list_categories_for_scope(
category_registry: cr.CategoryRegistry,
) -> None:
"""Make sure that we can read categories for scope."""
categories = category_registry.async_list_categories(scope="automation")
assert len(list(categories)) == len(
category_registry.categories.get("automation", {})
)
async def test_create_category(
hass: HomeAssistant, category_registry: cr.CategoryRegistry
) -> None:
"""Make sure that we can create new categories."""
update_events = async_capture_events(hass, cr.EVENT_CATEGORY_REGISTRY_UPDATED)
category = category_registry.async_create(
scope="automation",
name="Energy saving",
icon="mdi:leaf",
)
assert category.category_id
assert category.name == "Energy saving"
assert category.icon == "mdi:leaf"
assert len(category_registry.categories) == 1
assert len(category_registry.categories["automation"]) == 1
await hass.async_block_till_done()
assert len(update_events) == 1
assert update_events[0].data == {
"action": "create",
"scope": "automation",
"category_id": category.category_id,
}
async def test_create_category_with_name_already_in_use(
hass: HomeAssistant, category_registry: cr.CategoryRegistry
) -> None:
"""Make sure that we can't create a category with the same name within a scope."""
update_events = async_capture_events(hass, cr.EVENT_CATEGORY_REGISTRY_UPDATED)
category_registry.async_create(
scope="automation",
name="Energy saving",
icon="mdi:leaf",
)
with pytest.raises(
ValueError,
match=re.escape("The name 'ENERGY SAVING' is already in use"),
):
category_registry.async_create(
scope="automation",
name="ENERGY SAVING",
icon="mdi:leaf",
)
await hass.async_block_till_done()
assert len(category_registry.categories["automation"]) == 1
assert len(update_events) == 1
async def test_create_category_with_duplicate_name_in_other_scopes(
hass: HomeAssistant, category_registry: cr.CategoryRegistry
) -> None:
"""Make we can create the same category in multiple scopes."""
update_events = async_capture_events(hass, cr.EVENT_CATEGORY_REGISTRY_UPDATED)
category_registry.async_create(
scope="automation",
name="Energy saving",
icon="mdi:leaf",
)
category_registry.async_create(
scope="script",
name="Energy saving",
icon="mdi:leaf",
)
await hass.async_block_till_done()
assert len(category_registry.categories["script"]) == 1
assert len(category_registry.categories["automation"]) == 1
assert len(update_events) == 2
async def test_delete_category(
hass: HomeAssistant, category_registry: cr.CategoryRegistry
) -> None:
"""Make sure that we can delete a category."""
update_events = async_capture_events(hass, cr.EVENT_CATEGORY_REGISTRY_UPDATED)
category = category_registry.async_create(
scope="automation",
name="Energy saving",
icon="mdi:leaf",
)
assert len(category_registry.categories["automation"]) == 1
category_registry.async_delete(scope="automation", category_id=category.category_id)
assert not category_registry.categories["automation"]
await hass.async_block_till_done()
assert len(update_events) == 2
assert update_events[0].data == {
"action": "create",
"scope": "automation",
"category_id": category.category_id,
}
assert update_events[1].data == {
"action": "remove",
"scope": "automation",
"category_id": category.category_id,
}
async def test_delete_non_existing_category(
category_registry: cr.CategoryRegistry,
) -> None:
"""Make sure that we can't delete a category that doesn't exist."""
category = category_registry.async_create(
scope="automation",
name="Energy saving",
icon="mdi:leaf",
)
with pytest.raises(KeyError):
category_registry.async_delete(scope="automation", category_id="")
with pytest.raises(KeyError):
category_registry.async_delete(scope="", category_id=category.category_id)
assert len(category_registry.categories["automation"]) == 1
async def test_update_category(
hass: HomeAssistant, category_registry: cr.CategoryRegistry
) -> None:
"""Make sure that we can update categories."""
update_events = async_capture_events(hass, cr.EVENT_CATEGORY_REGISTRY_UPDATED)
category = category_registry.async_create(
scope="automation",
name="Energy saving",
)
assert len(category_registry.categories["automation"]) == 1
assert category.category_id
assert category.name == "Energy saving"
assert category.icon is None
updated_category = category_registry.async_update(
scope="automation",
category_id=category.category_id,
name="ENERGY SAVING",
icon="mdi:leaf",
)
assert updated_category != category
assert updated_category.category_id == category.category_id
assert updated_category.name == "ENERGY SAVING"
assert updated_category.icon == "mdi:leaf"
assert len(category_registry.categories["automation"]) == 1
await hass.async_block_till_done()
assert len(update_events) == 2
assert update_events[0].data == {
"action": "create",
"scope": "automation",
"category_id": category.category_id,
}
assert update_events[1].data == {
"action": "update",
"scope": "automation",
"category_id": category.category_id,
}
async def test_update_category_with_same_data(
hass: HomeAssistant, category_registry: cr.CategoryRegistry
) -> None:
"""Make sure that we can reapply the same data to a category and it won't update."""
update_events = async_capture_events(hass, cr.EVENT_CATEGORY_REGISTRY_UPDATED)
category = category_registry.async_create(
scope="automation",
name="Energy saving",
icon="mdi:leaf",
)
updated_category = category_registry.async_update(
scope="automation",
category_id=category.category_id,
name="Energy saving",
icon="mdi:leaf",
)
assert category == updated_category
await hass.async_block_till_done()
# No update event
assert len(update_events) == 1
assert update_events[0].data == {
"action": "create",
"scope": "automation",
"category_id": category.category_id,
}
async def test_update_category_with_same_name_change_case(
category_registry: cr.CategoryRegistry,
) -> None:
"""Make sure that we can reapply the same name with a different case to a category."""
category = category_registry.async_create(
scope="automation",
name="Energy saving",
icon="mdi:leaf",
)
updated_category = category_registry.async_update(
scope="automation",
category_id=category.category_id,
name="ENERGY SAVING",
)
assert updated_category.category_id == category.category_id
assert updated_category.name == "ENERGY SAVING"
assert updated_category.icon == "mdi:leaf"
assert len(category_registry.categories["automation"]) == 1
async def test_update_category_with_name_already_in_use(
category_registry: cr.CategoryRegistry,
) -> None:
"""Make sure that we can't update a category with a name already in use."""
category1 = category_registry.async_create(
scope="automation",
name="Energy saving",
icon="mdi:leaf",
)
category2 = category_registry.async_create(
scope="automation",
name="Something else",
icon="mdi:leaf",
)
with pytest.raises(
ValueError,
match=re.escape("The name 'ENERGY SAVING' is already in use"),
):
category_registry.async_update(
scope="automation",
category_id=category2.category_id,
name="ENERGY SAVING",
)
assert category1.name == "Energy saving"
assert category2.name == "Something else"
assert len(category_registry.categories["automation"]) == 2
async def test_load_categories(
hass: HomeAssistant, category_registry: cr.CategoryRegistry
) -> None:
"""Make sure that we can load/save data correctly."""
category1 = category_registry.async_create(
scope="automation",
name="Energy saving",
icon="mdi:leaf",
)
category2 = category_registry.async_create(
scope="automation",
name="Something else",
icon="mdi:leaf",
)
category3 = category_registry.async_create(
scope="zone",
name="Grocery stores",
icon="mdi:store",
)
assert len(category_registry.categories) == 2
assert len(category_registry.categories["automation"]) == 2
assert len(category_registry.categories["zone"]) == 1
registry2 = cr.CategoryRegistry(hass)
await flush_store(category_registry._store)
await registry2.async_load()
assert len(registry2.categories) == 2
assert len(registry2.categories["automation"]) == 2
assert len(registry2.categories["zone"]) == 1
assert list(category_registry.categories) == list(registry2.categories)
assert list(category_registry.categories["automation"]) == list(
registry2.categories["automation"]
)
assert list(category_registry.categories["zone"]) == list(
registry2.categories["zone"]
)
category1_registry2 = registry2.async_get_category(
scope="automation", category_id=category1.category_id
)
assert category1_registry2.category_id == category1.category_id
assert category1_registry2.name == category1.name
assert category1_registry2.icon == category1.icon
category2_registry2 = registry2.async_get_category(
scope="automation", category_id=category2.category_id
)
assert category2_registry2.category_id == category2.category_id
assert category2_registry2.name == category2.name
assert category2_registry2.icon == category2.icon
category3_registry2 = registry2.async_get_category(
scope="zone", category_id=category3.category_id
)
assert category3_registry2.category_id == category3.category_id
assert category3_registry2.name == category3.name
assert category3_registry2.icon == category3.icon
@pytest.mark.parametrize("load_registries", [False])
async def test_loading_categories_from_storage(
hass: HomeAssistant, hass_storage: Any
) -> None:
"""Test loading stored categories on start."""
hass_storage[cr.STORAGE_KEY] = {
"version": cr.STORAGE_VERSION_MAJOR,
"data": {
"categories": {
"automation": [
{
"category_id": "uuid1",
"name": "Energy saving",
"icon": "mdi:leaf",
},
{
"category_id": "uuid2",
"name": "Something else",
"icon": None,
},
],
"zone": [
{
"category_id": "uuid3",
"name": "Grocery stores",
"icon": "mdi:store",
},
],
}
},
}
await cr.async_load(hass)
category_registry = cr.async_get(hass)
assert len(category_registry.categories) == 2
assert len(category_registry.categories["automation"]) == 2
assert len(category_registry.categories["zone"]) == 1
category1 = category_registry.async_get_category(
scope="automation", category_id="uuid1"
)
assert category1.category_id == "uuid1"
assert category1.name == "Energy saving"
assert category1.icon == "mdi:leaf"
category2 = category_registry.async_get_category(
scope="automation", category_id="uuid2"
)
assert category2.category_id == "uuid2"
assert category2.name == "Something else"
assert category2.icon is None
category3 = category_registry.async_get_category(scope="zone", category_id="uuid3")
assert category3.category_id == "uuid3"
assert category3.name == "Grocery stores"
assert category3.icon == "mdi:store"

View File

@ -280,7 +280,9 @@ async def test_loading_saving_data(
orig_entry2.entity_id, "light", {"minimum_brightness": 20}
)
entity_registry.async_update_entity(
orig_entry2.entity_id, labels={"label1", "label2"}
orig_entry2.entity_id,
categories={"scope", "id"},
labels={"label1", "label2"},
)
orig_entry2 = entity_registry.async_get(orig_entry2.entity_id)
orig_entry3 = entity_registry.async_get_or_create("light", "hue", "ABCD")
@ -310,6 +312,7 @@ async def test_loading_saving_data(
assert orig_entry4 == new_entry4
assert new_entry2.area_id == "mock-area-id"
assert new_entry2.categories == {"scope", "id"}
assert new_entry2.capabilities == {"max": 100}
assert new_entry2.config_entry_id == mock_config.entry_id
assert new_entry2.device_class == "user-class"
@ -1847,3 +1850,76 @@ async def test_entries_for_label(entity_registry: er.EntityRegistry) -> None:
assert not er.async_entries_for_label(entity_registry, "unknown")
assert not er.async_entries_for_label(entity_registry, "")
async def test_removing_categories(entity_registry: er.EntityRegistry) -> None:
"""Make sure we can clear categories."""
entry = entity_registry.async_get_or_create(
domain="light",
platform="hue",
unique_id="5678",
)
entry = entity_registry.async_update_entity(
entry.entity_id, categories={"scope1": "id", "scope2": "id"}
)
entity_registry.async_clear_category_id("scope1", "id")
entry_cleared_scope1 = entity_registry.async_get(entry.entity_id)
entity_registry.async_clear_category_id("scope2", "id")
entry_cleared_scope2 = entity_registry.async_get(entry.entity_id)
assert entry_cleared_scope1
assert entry_cleared_scope2
assert entry != entry_cleared_scope1
assert entry != entry_cleared_scope2
assert entry_cleared_scope1 != entry_cleared_scope2
assert entry.categories == {"scope1": "id", "scope2": "id"}
assert entry_cleared_scope1.categories == {"scope2": "id"}
assert not entry_cleared_scope2.categories
async def test_entries_for_category(entity_registry: er.EntityRegistry) -> None:
"""Test getting entity entries by category."""
entity_registry.async_get_or_create(
domain="light",
platform="hue",
unique_id="000",
)
entry = entity_registry.async_get_or_create(
domain="light",
platform="hue",
unique_id="123",
)
category_1 = entity_registry.async_update_entity(
entry.entity_id, categories={"scope1": "id"}
)
entry = entity_registry.async_get_or_create(
domain="light",
platform="hue",
unique_id="456",
)
category_2 = entity_registry.async_update_entity(
entry.entity_id, categories={"scope2": "id"}
)
entry = entity_registry.async_get_or_create(
domain="light",
platform="hue",
unique_id="789",
)
category_1_and_2 = entity_registry.async_update_entity(
entry.entity_id, categories={"scope1": "id", "scope2": "id"}
)
entries = er.async_entries_for_category(entity_registry, "scope1", "id")
assert len(entries) == 2
assert entries == [category_1, category_1_and_2]
entries = er.async_entries_for_category(entity_registry, "scope2", "id")
assert len(entries) == 2
assert entries == [category_2, category_1_and_2]
assert not er.async_entries_for_category(entity_registry, "unknown", "id")
assert not er.async_entries_for_category(entity_registry, "", "id")
assert not er.async_entries_for_category(entity_registry, "scope1", "unknown")
assert not er.async_entries_for_category(entity_registry, "scope1", "")

View File

@ -166,7 +166,7 @@ class HomeAssistantSnapshotSerializer(AmberDataSerializer):
cls, data: er.RegistryEntry
) -> SerializableData:
"""Prepare a Home Assistant entity registry entry for serialization."""
return EntityRegistryEntrySnapshot(
serialized = EntityRegistryEntrySnapshot(
attrs.asdict(data)
| {
"config_entry_id": ANY,
@ -175,6 +175,8 @@ class HomeAssistantSnapshotSerializer(AmberDataSerializer):
"options": {k: dict(v) for k, v in data.options.items()},
}
)
serialized.pop("categories")
return serialized
@classmethod
def _serializable_flow_result(cls, data: FlowResult) -> SerializableData: