Add area motion entity ID

This commit is contained in:
Paulus Schoutsen 2025-05-13 17:27:13 +00:00
parent 26796f87cd
commit 7bac640267
4 changed files with 86 additions and 1 deletions

View File

@ -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),

View File

@ -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")

View File

@ -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(),
}

View File

@ -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,
}
]
},