diff --git a/homeassistant/components/config/area_registry.py b/homeassistant/components/config/area_registry.py index bf63a516b58..d41c712dffb 100644 --- a/homeassistant/components/config/area_registry.py +++ b/homeassistant/components/config/area_registry.py @@ -35,6 +35,7 @@ def websocket_list_areas( @websocket_api.websocket_command( { vol.Required("type"): "config/area_registry/create", + vol.Optional("aliases"): list, vol.Required("name"): str, vol.Optional("picture"): vol.Any(str, None), } @@ -53,6 +54,10 @@ def websocket_create_area( data.pop("type") data.pop("id") + if "aliases" in data: + # Convert aliases to a set + data["aliases"] = set(data["aliases"]) + try: entry = registry.async_create(**data) except ValueError as err: @@ -88,6 +93,7 @@ def websocket_delete_area( @websocket_api.websocket_command( { vol.Required("type"): "config/area_registry/update", + vol.Optional("aliases"): list, vol.Required("area_id"): str, vol.Optional("name"): str, vol.Optional("picture"): vol.Any(str, None), @@ -107,6 +113,10 @@ def websocket_update_area( data.pop("type") data.pop("id") + if "aliases" in data: + # Convert aliases to a set + data["aliases"] = set(data["aliases"]) + try: entry = registry.async_update(**data) except ValueError as err: @@ -118,4 +128,9 @@ def websocket_update_area( @callback def _entry_dict(entry): """Convert entry to API format.""" - return {"area_id": entry.id, "name": entry.name, "picture": entry.picture} + return { + "aliases": entry.aliases, + "area_id": entry.id, + "name": entry.name, + "picture": entry.picture, + } diff --git a/homeassistant/helpers/area_registry.py b/homeassistant/helpers/area_registry.py index e8ba4ce362d..77c959da1a7 100644 --- a/homeassistant/helpers/area_registry.py +++ b/homeassistant/helpers/area_registry.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections import OrderedDict from collections.abc import Container, Iterable, MutableMapping -from typing import Any, Optional, cast +from typing import Any, cast import attr @@ -20,7 +20,7 @@ DATA_REGISTRY = "area_registry" EVENT_AREA_REGISTRY_UPDATED = "area_registry_updated" STORAGE_KEY = "core.area_registry" STORAGE_VERSION_MAJOR = 1 -STORAGE_VERSION_MINOR = 2 +STORAGE_VERSION_MINOR = 3 SAVE_DELAY = 10 @@ -30,8 +30,11 @@ class AreaEntry: name: str = attr.ib() normalized_name: str = attr.ib() - picture: str | None = attr.ib(default=None) + aliases: set[str] = attr.ib( + converter=attr.converters.default_if_none(factory=set) # type: ignore[misc] + ) id: str | None = attr.ib(default=None) + picture: str | None = attr.ib(default=None) def generate_id(self, existing_ids: Container[str]) -> None: """Initialize ID.""" @@ -43,14 +46,14 @@ class AreaEntry: object.__setattr__(self, "id", suggestion) -class AreaRegistryStore(Store[dict[str, list[dict[str, Optional[str]]]]]): +class AreaRegistryStore(Store[dict[str, list[dict[str, Any]]]]): """Store area registry data.""" async def _async_migrate_func( self, old_major_version: int, old_minor_version: int, - old_data: dict[str, list[dict[str, str | None]]], + old_data: dict[str, list[dict[str, Any]]], ) -> dict[str, Any]: """Migrate to the new version.""" if old_major_version < 2: @@ -60,6 +63,11 @@ class AreaRegistryStore(Store[dict[str, list[dict[str, Optional[str]]]]]): # Populate keys which were introduced before version 1.2 area.setdefault("picture", None) + if old_minor_version < 3: + # Version 1.3 adds aliases + for area in old_data["areas"]: + area["aliases"] = [] + if old_major_version > 1: raise NotImplementedError return old_data @@ -107,14 +115,22 @@ class AreaRegistry: return self.async_create(name) @callback - def async_create(self, name: str, picture: str | None = None) -> AreaEntry: + def async_create( + self, + name: str, + *, + aliases: set[str] | None = None, + picture: str | None = None, + ) -> AreaEntry: """Create a new area.""" normalized_name = normalize_area_name(name) if self.async_get_area_by_name(name): raise ValueError(f"The name {name} ({normalized_name}) is already in use") - area = AreaEntry(name=name, normalized_name=normalized_name, picture=picture) + area = AreaEntry( + aliases=aliases, name=name, normalized_name=normalized_name, picture=picture + ) area.generate_id(self.areas) assert area.id is not None self.areas[area.id] = area @@ -147,11 +163,15 @@ class AreaRegistry: def async_update( self, area_id: str, + *, + aliases: set[str] | UndefinedType = UNDEFINED, name: str | UndefinedType = UNDEFINED, picture: str | None | UndefinedType = UNDEFINED, ) -> AreaEntry: """Update name of area.""" - updated = self._async_update(area_id, name=name, picture=picture) + updated = self._async_update( + area_id, aliases=aliases, name=name, picture=picture + ) self.hass.bus.async_fire( EVENT_AREA_REGISTRY_UPDATED, {"action": "update", "area_id": area_id} ) @@ -161,16 +181,22 @@ class AreaRegistry: def _async_update( self, area_id: str, + *, + aliases: set[str] | UndefinedType = UNDEFINED, name: str | UndefinedType = UNDEFINED, picture: str | None | UndefinedType = UNDEFINED, ) -> AreaEntry: """Update name of area.""" old = self.areas[area_id] - changes = {} + new_values = {} - if picture is not UNDEFINED: - changes["picture"] = picture + for attr_name, value in ( + ("aliases", aliases), + ("picture", picture), + ): + if value is not UNDEFINED and value != getattr(old, attr_name): + new_values[attr_name] = value normalized_name = None @@ -184,13 +210,13 @@ class AreaRegistry: f"The name {name} ({normalized_name}) is already in use" ) - changes["name"] = name - changes["normalized_name"] = normalized_name + new_values["name"] = name + new_values["normalized_name"] = normalized_name - if not changes: + if not new_values: return old - new = self.areas[area_id] = attr.evolve(old, **changes) + new = self.areas[area_id] = attr.evolve(old, **new_values) if normalized_name is not None: self._normalized_name_area_idx[ normalized_name @@ -210,6 +236,7 @@ class AreaRegistry: assert area["name"] is not None and area["id"] is not None normalized_name = normalize_area_name(area["name"]) areas[area["id"]] = AreaEntry( + aliases=set(area["aliases"]), id=area["id"], name=area["name"], normalized_name=normalized_name, @@ -225,12 +252,17 @@ class AreaRegistry: self._store.async_delay_save(self._data_to_save, SAVE_DELAY) @callback - def _data_to_save(self) -> dict[str, list[dict[str, str | None]]]: + def _data_to_save(self) -> dict[str, list[dict[str, Any]]]: """Return data of area registry to store in a file.""" data = {} data["areas"] = [ - {"name": entry.name, "id": entry.id, "picture": entry.picture} + { + "aliases": list(entry.aliases), + "name": entry.name, + "id": entry.id, + "picture": entry.picture, + } for entry in self.areas.values() ] diff --git a/tests/components/config/test_area_registry.py b/tests/components/config/test_area_registry.py index 497a395ed30..de3cb78d349 100644 --- a/tests/components/config/test_area_registry.py +++ b/tests/components/config/test_area_registry.py @@ -1,9 +1,10 @@ """Test area_registry API.""" import pytest +from pytest_unordered import unordered from homeassistant.components.config import area_registry -from tests.common import mock_area_registry +from tests.common import ANY, mock_area_registry @pytest.fixture @@ -21,31 +22,68 @@ def registry(hass): async def test_list_areas(hass, client, registry): """Test list entries.""" - registry.async_create("mock 1") - registry.async_create("mock 2", "/image/example.png") + area1 = registry.async_create("mock 1") + area2 = registry.async_create( + "mock 2", aliases={"alias_1", "alias_2"}, picture="/image/example.png" + ) await client.send_json({"id": 1, "type": "config/area_registry/list"}) msg = await client.receive_json() - - assert len(msg["result"]) == len(registry.areas) - assert msg["result"][0]["name"] == "mock 1" - assert msg["result"][0]["picture"] is None - assert msg["result"][1]["name"] == "mock 2" - assert msg["result"][1]["picture"] == "/image/example.png" + assert msg["result"] == [ + { + "aliases": [], + "area_id": area1.id, + "name": "mock 1", + "picture": None, + }, + { + "aliases": unordered(["alias_1", "alias_2"]), + "area_id": area2.id, + "name": "mock 2", + "picture": "/image/example.png", + }, + ] async def test_create_area(hass, client, registry): """Test create entry.""" + # Create area with only mandatory parameters await client.send_json( {"id": 1, "name": "mock", "type": "config/area_registry/create"} ) msg = await client.receive_json() - assert "mock" in msg["result"]["name"] + assert msg["result"] == { + "aliases": [], + "area_id": ANY, + "name": "mock", + "picture": None, + } assert len(registry.areas) == 1 + # Create area with all parameters + await client.send_json( + { + "id": 2, + "aliases": ["alias_1", "alias_2"], + "name": "mock 2", + "picture": "/image/example.png", + "type": "config/area_registry/create", + } + ) + + msg = await client.receive_json() + + assert msg["result"] == { + "aliases": unordered(["alias_1", "alias_2"]), + "area_id": ANY, + "name": "mock 2", + "picture": "/image/example.png", + } + assert len(registry.areas) == 2 + async def test_create_area_with_name_already_in_use(hass, client, registry): """Test create entry that should fail.""" @@ -100,6 +138,7 @@ async def test_update_area(hass, client, registry): await client.send_json( { "id": 1, + "aliases": ["alias_1", "alias_2"], "area_id": area.id, "name": "mock 2", "picture": "/image/example.png", @@ -109,14 +148,18 @@ async def test_update_area(hass, client, registry): msg = await client.receive_json() - assert msg["result"]["area_id"] == area.id - assert msg["result"]["name"] == "mock 2" - assert msg["result"]["picture"] == "/image/example.png" + assert msg["result"] == { + "aliases": unordered(["alias_1", "alias_2"]), + "area_id": area.id, + "name": "mock 2", + "picture": "/image/example.png", + } assert len(registry.areas) == 1 await client.send_json( { "id": 2, + "aliases": ["alias_1", "alias_1"], "area_id": area.id, "picture": None, "type": "config/area_registry/update", @@ -125,9 +168,12 @@ async def test_update_area(hass, client, registry): msg = await client.receive_json() - assert msg["result"]["area_id"] == area.id - assert msg["result"]["name"] == "mock 2" - assert msg["result"]["picture"] is None + assert msg["result"] == { + "aliases": ["alias_1"], + "area_id": area.id, + "name": "mock 2", + "picture": None, + } assert len(registry.areas) == 1 diff --git a/tests/helpers/test_area_registry.py b/tests/helpers/test_area_registry.py index a076f3daa86..ea42417aef9 100644 --- a/tests/helpers/test_area_registry.py +++ b/tests/helpers/test_area_registry.py @@ -4,7 +4,7 @@ import pytest from homeassistant.core import callback from homeassistant.helpers import area_registry -from tests.common import flush_store, mock_area_registry +from tests.common import ANY, flush_store, mock_area_registry @pytest.fixture @@ -38,17 +38,39 @@ async def test_list_areas(registry): async def test_create_area(hass, registry, update_events): """Make sure that we can create an area.""" + # Create area with only mandatory parameters area = registry.async_create("mock") - assert area.id == "mock" - assert area.name == "mock" + assert area == area_registry.AreaEntry( + name="mock", normalized_name=ANY, aliases=set(), id=ANY, picture=None + ) assert len(registry.areas) == 1 await hass.async_block_till_done() assert len(update_events) == 1 - assert update_events[0]["action"] == "create" - assert update_events[0]["area_id"] == area.id + assert update_events[-1]["action"] == "create" + assert update_events[-1]["area_id"] == area.id + + # Create area with all parameters + area = registry.async_create( + "mock 2", aliases={"alias_1", "alias_2"}, picture="/image/example.png" + ) + + assert area == area_registry.AreaEntry( + name="mock 2", + normalized_name=ANY, + aliases={"alias_1", "alias_2"}, + id=ANY, + picture="/image/example.png", + ) + assert len(registry.areas) == 2 + + await hass.async_block_till_done() + + assert len(update_events) == 2 + assert update_events[-1]["action"] == "create" + assert update_events[-1]["area_id"] == area.id async def test_create_area_with_name_already_in_use(hass, registry, update_events): @@ -70,7 +92,7 @@ async def test_create_area_with_id_already_in_use(registry): """Make sure that we can't create an area with a name already in use.""" area1 = registry.async_create("mock") - updated_area1 = registry.async_update(area1.id, "New Name") + updated_area1 = registry.async_update(area1.id, name="New Name") assert updated_area1.id == area1.id area2 = registry.async_create("mock") @@ -108,10 +130,21 @@ async def test_update_area(hass, registry, update_events): """Make sure that we can read areas.""" area = registry.async_create("mock") - updated_area = registry.async_update(area.id, name="mock1") + updated_area = registry.async_update( + area.id, + aliases={"alias_1", "alias_2"}, + name="mock1", + picture="/image/example.png", + ) assert updated_area != area - assert updated_area.name == "mock1" + assert updated_area == area_registry.AreaEntry( + name="mock1", + normalized_name=ANY, + aliases={"alias_1", "alias_2"}, + id=ANY, + picture="/image/example.png", + ) assert len(registry.areas) == 1 await hass.async_block_till_done() @@ -198,7 +231,16 @@ async def test_loading_area_from_storage(hass, hass_storage): hass_storage[area_registry.STORAGE_KEY] = { "version": area_registry.STORAGE_VERSION_MAJOR, "minor_version": area_registry.STORAGE_VERSION_MINOR, - "data": {"areas": [{"id": "12345A", "name": "mock", "picture": "blah"}]}, + "data": { + "areas": [ + { + "aliases": ["alias_1", "alias_2"], + "id": "12345A", + "name": "mock", + "picture": "blah", + } + ] + }, } await area_registry.async_load(hass) @@ -228,7 +270,9 @@ async def test_migration_from_1_1(hass, hass_storage): "version": area_registry.STORAGE_VERSION_MAJOR, "minor_version": area_registry.STORAGE_VERSION_MINOR, "key": area_registry.STORAGE_KEY, - "data": {"areas": [{"id": "12345A", "name": "mock", "picture": None}]}, + "data": { + "areas": [{"aliases": [], "id": "12345A", "name": "mock", "picture": None}] + }, }