diff --git a/homeassistant/components/config/area_registry.py b/homeassistant/components/config/area_registry.py index b2a590928c1..7a26437959f 100644 --- a/homeassistant/components/config/area_registry.py +++ b/homeassistant/components/config/area_registry.py @@ -44,6 +44,7 @@ def websocket_list_areas( vol.Optional("humidity_entity_id"): vol.Any(str, None), vol.Optional("icon"): str, vol.Optional("labels"): [str], + vol.Optional("motion_entity_id"): vol.Any(str, None), vol.Required("name"): str, vol.Optional("picture"): vol.Any(str, None), vol.Optional("temperature_entity_id"): vol.Any(str, None), @@ -112,6 +113,7 @@ def websocket_delete_area( vol.Optional("humidity_entity_id"): vol.Any(str, None), vol.Optional("icon"): vol.Any(str, None), vol.Optional("labels"): [str], + vol.Optional("motion_entity_id"): vol.Any(str, None), vol.Optional("name"): str, vol.Optional("picture"): vol.Any(str, None), vol.Optional("temperature_entity_id"): vol.Any(str, None), diff --git a/homeassistant/helpers/area_registry.py b/homeassistant/helpers/area_registry.py index ba02ed51f6b..dcaf243ffb5 100644 --- a/homeassistant/helpers/area_registry.py +++ b/homeassistant/helpers/area_registry.py @@ -40,7 +40,7 @@ EVENT_AREA_REGISTRY_UPDATED: EventType[EventAreaRegistryUpdatedData] = EventType ) STORAGE_KEY = "core.area_registry" STORAGE_VERSION_MAJOR = 1 -STORAGE_VERSION_MINOR = 8 +STORAGE_VERSION_MINOR = 9 class _AreaStoreData(TypedDict): @@ -52,6 +52,7 @@ class _AreaStoreData(TypedDict): icon: str | None id: str labels: list[str] + motion_entity_id: str | None name: str picture: str | None temperature_entity_id: str | None @@ -82,6 +83,7 @@ class AreaEntry(NormalizedNameBaseRegistryEntry): icon: str | None id: str labels: set[str] = field(default_factory=set) + motion_entity_id: str | None picture: str | None temperature_entity_id: str | None _cache: dict[str, Any] = field(default_factory=dict, compare=False, init=False) @@ -98,6 +100,7 @@ class AreaEntry(NormalizedNameBaseRegistryEntry): "humidity_entity_id": self.humidity_entity_id, "icon": self.icon, "labels": list(self.labels), + "motion_entity_id": self.motion_entity_id, "name": self.name, "picture": self.picture, "temperature_entity_id": self.temperature_entity_id, @@ -157,6 +160,11 @@ class AreaRegistryStore(Store[AreasRegistryStoreData]): area["humidity_entity_id"] = None area["temperature_entity_id"] = None + if old_minor_version < 9: + # Version 1.9 adds motion_entity_id + for area_data in old_data["areas"]: + area_data["motion_entity_id"] = None + if old_major_version > 1: raise NotImplementedError return old_data # type: ignore[return-value] @@ -278,6 +286,7 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]): humidity_entity_id: str | None = None, icon: str | None = None, labels: set[str] | None = None, + motion_entity_id: str | None = None, picture: str | None = None, temperature_entity_id: str | None = None, ) -> AreaEntry: @@ -293,6 +302,9 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]): if humidity_entity_id is not None: _validate_humidity_entity(self.hass, humidity_entity_id) + if motion_entity_id is not None: + _validate_motion_entity(self.hass, motion_entity_id) + if temperature_entity_id is not None: _validate_temperature_entity(self.hass, temperature_entity_id) @@ -303,6 +315,7 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]): icon=icon, id=self._generate_id(name), labels=labels or set(), + motion_entity_id=motion_entity_id, name=name, picture=picture, temperature_entity_id=temperature_entity_id, @@ -345,6 +358,7 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]): humidity_entity_id: str | None | UndefinedType = UNDEFINED, icon: str | None | UndefinedType = UNDEFINED, labels: set[str] | UndefinedType = UNDEFINED, + motion_entity_id: str | None | UndefinedType = UNDEFINED, name: str | UndefinedType = UNDEFINED, picture: str | None | UndefinedType = UNDEFINED, temperature_entity_id: str | None | UndefinedType = UNDEFINED, @@ -357,6 +371,7 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]): humidity_entity_id=humidity_entity_id, icon=icon, labels=labels, + motion_entity_id=motion_entity_id, name=name, picture=picture, temperature_entity_id=temperature_entity_id, @@ -381,6 +396,7 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]): humidity_entity_id: str | None | UndefinedType = UNDEFINED, icon: str | None | UndefinedType = UNDEFINED, labels: set[str] | UndefinedType = UNDEFINED, + motion_entity_id: str | None | UndefinedType = UNDEFINED, name: str | UndefinedType = UNDEFINED, picture: str | None | UndefinedType = UNDEFINED, temperature_entity_id: str | None | UndefinedType = UNDEFINED, @@ -396,6 +412,7 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]): ("humidity_entity_id", humidity_entity_id), ("icon", icon), ("labels", labels), + ("motion_entity_id", motion_entity_id), ("picture", picture), ("temperature_entity_id", temperature_entity_id), ) @@ -405,6 +422,9 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]): if "humidity_entity_id" in new_values and humidity_entity_id is not None: _validate_humidity_entity(self.hass, new_values["humidity_entity_id"]) + if "motion_entity_id" in new_values and motion_entity_id is not None: + _validate_motion_entity(self.hass, new_values["motion_entity_id"]) + if "temperature_entity_id" in new_values and temperature_entity_id is not None: _validate_temperature_entity(self.hass, new_values["temperature_entity_id"]) @@ -440,6 +460,7 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]): icon=area["icon"], id=area["id"], labels=set(area["labels"]), + motion_entity_id=area["motion_entity_id"], name=area["name"], picture=area["picture"], temperature_entity_id=area["temperature_entity_id"], @@ -462,6 +483,7 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]): "icon": entry.icon, "id": entry.id, "labels": list(entry.labels), + "motion_entity_id": entry.motion_entity_id, "name": entry.name, "picture": entry.picture, "temperature_entity_id": entry.temperature_entity_id, @@ -569,3 +591,18 @@ def _validate_humidity_entity(hass: HomeAssistant, entity_id: str) -> None: or state.attributes.get(ATTR_DEVICE_CLASS) != SensorDeviceClass.HUMIDITY ): raise ValueError(f"Entity {entity_id} is not a humidity sensor") + + +def _validate_motion_entity(hass: HomeAssistant, entity_id: str) -> None: + """Validate motion entity.""" + # pylint: disable=import-outside-toplevel + from homeassistant.components.binary_sensor import BinarySensorDeviceClass + + if not (state := hass.states.get(entity_id)): + raise ValueError(f"Entity {entity_id} does not exist") + + if ( + state.domain != "binary_sensor" + or state.attributes.get(ATTR_DEVICE_CLASS) != BinarySensorDeviceClass.MOTION + ): + raise ValueError(f"Entity {entity_id} is not a motion binary_sensor") diff --git a/tests/components/config/test_area_registry.py b/tests/components/config/test_area_registry.py index 81c696bc6a7..6a02cf25e7c 100644 --- a/tests/components/config/test_area_registry.py +++ b/tests/components/config/test_area_registry.py @@ -6,6 +6,7 @@ from freezegun.api import FrozenDateTimeFactory import pytest from pytest_unordered import unordered +from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.config import area_registry from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ( @@ -50,6 +51,13 @@ async def mock_temperature_humidity_entity(hass: HomeAssistant) -> None: ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, }, ) + hass.states.async_set( + "binary_sensor.mock_motion", + "off", + { + ATTR_DEVICE_CLASS: BinarySensorDeviceClass.MOTION, + }, + ) async def test_list_areas( @@ -72,6 +80,7 @@ async def test_list_areas( humidity_entity_id="sensor.mock_humidity", icon="mdi:garage", labels={"label_1", "label_2"}, + motion_entity_id="binary_sensor.mock_motion", picture="/image/example.png", temperature_entity_id="sensor.mock_temperature", ) @@ -92,6 +101,7 @@ async def test_list_areas( "name": "mock 1", "picture": None, "temperature_entity_id": None, + "motion_entity_id": None, }, { "aliases": unordered(["alias_1", "alias_2"]), @@ -102,6 +112,7 @@ async def test_list_areas( "icon": "mdi:garage", "labels": unordered(["label_1", "label_2"]), "modified_at": created_area2.timestamp(), + "motion_entity_id": "binary_sensor.mock_motion", "name": "mock 2", "picture": "/image/example.png", "temperature_entity_id": "sensor.mock_temperature", @@ -135,6 +146,7 @@ async def test_create_area( "modified_at": utcnow().timestamp(), "temperature_entity_id": None, "humidity_entity_id": None, + "motion_entity_id": None, } assert len(area_registry.areas) == 1 @@ -149,6 +161,7 @@ async def test_create_area( "picture": "/image/example.png", "temperature_entity_id": "sensor.mock_temperature", "humidity_entity_id": "sensor.mock_humidity", + "motion_entity_id": "binary_sensor.mock_motion", "type": "config/area_registry/create", } ) @@ -168,6 +181,7 @@ async def test_create_area( "modified_at": utcnow().timestamp(), "temperature_entity_id": "sensor.mock_temperature", "humidity_entity_id": "sensor.mock_humidity", + "motion_entity_id": "binary_sensor.mock_motion", } assert len(area_registry.areas) == 2 @@ -246,6 +260,7 @@ async def test_update_area( "humidity_entity_id": "sensor.mock_humidity", "icon": "mdi:garage", "labels": ["label_1", "label_2"], + "motion_entity_id": "binary_sensor.mock_motion", "name": "mock 2", "picture": "/image/example.png", "temperature_entity_id": "sensor.mock_temperature", @@ -261,6 +276,7 @@ async def test_update_area( "humidity_entity_id": "sensor.mock_humidity", "icon": "mdi:garage", "labels": unordered(["label_1", "label_2"]), + "motion_entity_id": "binary_sensor.mock_motion", "name": "mock 2", "picture": "/image/example.png", "temperature_entity_id": "sensor.mock_temperature", @@ -281,6 +297,7 @@ async def test_update_area( "humidity_entity_id": None, "icon": None, "labels": [], + "motion_entity_id": None, "picture": None, "temperature_entity_id": None, } @@ -298,6 +315,7 @@ async def test_update_area( "picture": None, "temperature_entity_id": None, "humidity_entity_id": None, + "motion_entity_id": None, "created_at": created_at.timestamp(), "modified_at": modified_at.timestamp(), } diff --git a/tests/helpers/test_area_registry.py b/tests/helpers/test_area_registry.py index 3496c41ecf4..3fc51a206cd 100644 --- a/tests/helpers/test_area_registry.py +++ b/tests/helpers/test_area_registry.py @@ -44,6 +44,13 @@ async def mock_temperature_humidity_entity(hass: HomeAssistant) -> None: ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, }, ) + hass.states.async_set( + "binary_sensor.mock_motion", + "off", + { + ATTR_DEVICE_CLASS: "motion", + }, + ) async def test_list_areas(area_registry: ar.AreaRegistry) -> None: @@ -79,6 +86,7 @@ async def test_create_area( modified_at=utcnow(), temperature_entity_id=None, humidity_entity_id=None, + motion_entity_id=None, ) assert len(area_registry.areas) == 1 @@ -100,6 +108,7 @@ async def test_create_area( picture="/image/example.png", temperature_entity_id="sensor.mock_temperature", humidity_entity_id="sensor.mock_humidity", + motion_entity_id="binary_sensor.mock_motion", ) assert area2 == ar.AreaEntry( @@ -114,6 +123,7 @@ async def test_create_area( modified_at=utcnow(), temperature_entity_id="sensor.mock_temperature", humidity_entity_id="sensor.mock_humidity", + motion_entity_id="binary_sensor.mock_motion", ) assert len(area_registry.areas) == 2 assert area.created_at != area2.created_at @@ -222,6 +232,7 @@ async def test_update_area( picture="/image/example.png", temperature_entity_id="sensor.mock_temperature", humidity_entity_id="sensor.mock_humidity", + motion_entity_id="binary_sensor.mock_motion", ) assert updated_area != area @@ -237,6 +248,7 @@ async def test_update_area( modified_at=modified_at, temperature_entity_id="sensor.mock_temperature", humidity_entity_id="sensor.mock_humidity", + motion_entity_id="binary_sensor.mock_motion", ) assert len(area_registry.areas) == 1 @@ -341,6 +353,18 @@ async def test_update_area_with_normalized_name_already_in_use( {"humidity_entity_id": "sensor.random"}, "Entity sensor.random is not a humidity sensor", ), + ( + {"motion_entity_id": "sensor.invalid"}, + "Entity sensor.invalid does not exist", + ), + ( + {"motion_entity_id": "light.kitchen"}, + "Entity light.kitchen is not a motion binary_sensor", + ), + ( + {"motion_entity_id": "binary_sensor.random"}, + "Entity binary_sensor.random is not a motion binary_sensor", + ), ], ) async def test_update_area_entity_validation( @@ -354,6 +378,7 @@ async def test_update_area_entity_validation( area = area_registry.async_create("mock") hass.states.async_set("light.kitchen", "on", {}) hass.states.async_set("sensor.random", "3", {}) + hass.states.async_set("binary_sensor.random", "off", {}) with pytest.raises(ValueError) as e_info: area_registry.async_update(area.id, **create_kwargs) @@ -406,6 +431,7 @@ async def test_loading_area_from_storage( "modified_at": modified_at.isoformat(), "temperature_entity_id": "sensor.mock_temperature", "humidity_entity_id": "sensor.mock_humidity", + "motion_entity_id": "binary_sensor.mock_motion", } ] }, @@ -428,6 +454,7 @@ async def test_loading_area_from_storage( modified_at=modified_at, temperature_entity_id="sensor.mock_temperature", humidity_entity_id="sensor.mock_humidity", + motion_entity_id="binary_sensor.mock_motion", ) @@ -468,6 +495,7 @@ async def test_migration_from_1_1( "modified_at": "1970-01-01T00:00:00+00:00", "temperature_entity_id": None, "humidity_entity_id": None, + "motion_entity_id": None, } ] },