Skip to content

Commit df8ef15

Browse files
piitayaCopilot
andauthored
Add reorder floors and areas websocket command (home-assistant#156802)
Co-authored-by: Copilot <[email protected]>
1 parent 249c153 commit df8ef15

File tree

6 files changed

+285
-5
lines changed

6 files changed

+285
-5
lines changed

homeassistant/components/config/area_registry.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ def async_setup(hass: HomeAssistant) -> bool:
1818
websocket_api.async_register_command(hass, websocket_create_area)
1919
websocket_api.async_register_command(hass, websocket_delete_area)
2020
websocket_api.async_register_command(hass, websocket_update_area)
21+
websocket_api.async_register_command(hass, websocket_reorder_areas)
2122
return True
2223

2324

@@ -145,3 +146,27 @@ def websocket_update_area(
145146
connection.send_error(msg["id"], "invalid_info", str(err))
146147
else:
147148
connection.send_result(msg["id"], entry.json_fragment)
149+
150+
151+
@websocket_api.websocket_command(
152+
{
153+
vol.Required("type"): "config/area_registry/reorder",
154+
vol.Required("area_ids"): [str],
155+
}
156+
)
157+
@websocket_api.require_admin
158+
@callback
159+
def websocket_reorder_areas(
160+
hass: HomeAssistant,
161+
connection: websocket_api.ActiveConnection,
162+
msg: dict[str, Any],
163+
) -> None:
164+
"""Handle reorder areas websocket command."""
165+
registry = ar.async_get(hass)
166+
167+
try:
168+
registry.async_reorder(msg["area_ids"])
169+
except ValueError as err:
170+
connection.send_error(msg["id"], websocket_api.ERR_INVALID_FORMAT, str(err))
171+
else:
172+
connection.send_result(msg["id"])

homeassistant/components/config/floor_registry.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ def async_setup(hass: HomeAssistant) -> bool:
1818
websocket_api.async_register_command(hass, websocket_create_floor)
1919
websocket_api.async_register_command(hass, websocket_delete_floor)
2020
websocket_api.async_register_command(hass, websocket_update_floor)
21+
websocket_api.async_register_command(hass, websocket_reorder_floors)
2122
return True
2223

2324

@@ -127,6 +128,28 @@ def websocket_update_floor(
127128
connection.send_result(msg["id"], _entry_dict(entry))
128129

129130

131+
@websocket_api.websocket_command(
132+
{
133+
vol.Required("type"): "config/floor_registry/reorder",
134+
vol.Required("floor_ids"): [str],
135+
}
136+
)
137+
@websocket_api.require_admin
138+
@callback
139+
def websocket_reorder_floors(
140+
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
141+
) -> None:
142+
"""Handle reorder floors websocket command."""
143+
registry = fr.async_get(hass)
144+
145+
try:
146+
registry.async_reorder(msg["floor_ids"])
147+
except ValueError as err:
148+
connection.send_error(msg["id"], websocket_api.ERR_INVALID_FORMAT, str(err))
149+
else:
150+
connection.send_result(msg["id"])
151+
152+
130153
@callback
131154
def _entry_dict(entry: FloorEntry) -> dict[str, Any]:
132155
"""Convert entry to API format."""

homeassistant/helpers/area_registry.py

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,8 @@ class AreasRegistryStoreData(TypedDict):
6868
class EventAreaRegistryUpdatedData(TypedDict):
6969
"""EventAreaRegistryUpdated data."""
7070

71-
action: Literal["create", "remove", "update"]
72-
area_id: str
71+
action: Literal["create", "remove", "update", "reorder"]
72+
area_id: str | None
7373

7474

7575
@dataclass(frozen=True, kw_only=True, slots=True)
@@ -420,6 +420,26 @@ def _async_update(
420420
self.async_schedule_save()
421421
return new
422422

423+
@callback
424+
def async_reorder(self, area_ids: list[str]) -> None:
425+
"""Reorder areas."""
426+
self.hass.verify_event_loop_thread("area_registry.async_reorder")
427+
428+
if set(area_ids) != set(self.areas.data.keys()):
429+
raise ValueError(
430+
"The area_ids list must contain all existing area IDs exactly once"
431+
)
432+
433+
reordered_data = {area_id: self.areas.data[area_id] for area_id in area_ids}
434+
self.areas.data.clear()
435+
self.areas.data.update(reordered_data)
436+
437+
self.async_schedule_save()
438+
self.hass.bus.async_fire_internal(
439+
EVENT_AREA_REGISTRY_UPDATED,
440+
EventAreaRegistryUpdatedData(action="reorder", area_id=None),
441+
)
442+
423443
async def async_load(self) -> None:
424444
"""Load the area registry."""
425445
self._async_setup_cleanup()
@@ -489,7 +509,9 @@ def _removed_from_registry_filter(
489509
@callback
490510
def _handle_floor_registry_update(event: fr.EventFloorRegistryUpdated) -> None:
491511
"""Update areas that are associated with a floor that has been removed."""
492-
floor_id = event.data["floor_id"]
512+
floor_id = event.data.get("floor_id")
513+
if floor_id is None:
514+
return
493515
for area in self.areas.get_areas_for_floor(floor_id):
494516
self.async_update(area.id, floor_id=None)
495517

homeassistant/helpers/floor_registry.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,8 @@ class FloorRegistryStoreData(TypedDict):
5454
class EventFloorRegistryUpdatedData(TypedDict):
5555
"""Event data for when the floor registry is updated."""
5656

57-
action: Literal["create", "remove", "update"]
58-
floor_id: str
57+
action: Literal["create", "remove", "update", "reorder"]
58+
floor_id: str | None
5959

6060

6161
type EventFloorRegistryUpdated = Event[EventFloorRegistryUpdatedData]
@@ -261,6 +261,28 @@ def async_update(
261261

262262
return new
263263

264+
@callback
265+
def async_reorder(self, floor_ids: list[str]) -> None:
266+
"""Reorder floors."""
267+
self.hass.verify_event_loop_thread("floor_registry.async_reorder")
268+
269+
if set(floor_ids) != set(self.floors.data.keys()):
270+
raise ValueError(
271+
"The floor_ids list must contain all existing floor IDs exactly once"
272+
)
273+
274+
reordered_data = {
275+
floor_id: self.floors.data[floor_id] for floor_id in floor_ids
276+
}
277+
self.floors.data.clear()
278+
self.floors.data.update(reordered_data)
279+
280+
self.async_schedule_save()
281+
self.hass.bus.async_fire_internal(
282+
EVENT_FLOOR_REGISTRY_UPDATED,
283+
EventFloorRegistryUpdatedData(action="reorder", floor_id=None),
284+
)
285+
264286
async def async_load(self) -> None:
265287
"""Load the floor registry."""
266288
data = await self._store.async_load()

tests/components/config/test_area_registry.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Test area_registry API."""
22

33
from datetime import datetime
4+
from typing import Any
45

56
from freezegun.api import FrozenDateTimeFactory
67
import pytest
@@ -346,3 +347,92 @@ async def test_update_area_with_name_already_in_use(
346347
assert msg["error"]["code"] == "invalid_info"
347348
assert msg["error"]["message"] == "The name mock 2 (mock2) is already in use"
348349
assert len(area_registry.areas) == 2
350+
351+
352+
async def test_reorder_areas(
353+
client: MockHAClientWebSocket, area_registry: ar.AreaRegistry
354+
) -> None:
355+
"""Test reorder areas."""
356+
area1 = area_registry.async_create("mock 1")
357+
area2 = area_registry.async_create("mock 2")
358+
area3 = area_registry.async_create("mock 3")
359+
360+
await client.send_json_auto_id({"type": "config/area_registry/list"})
361+
msg = await client.receive_json()
362+
assert [area["area_id"] for area in msg["result"]] == [area1.id, area2.id, area3.id]
363+
364+
await client.send_json_auto_id(
365+
{
366+
"type": "config/area_registry/reorder",
367+
"area_ids": [area3.id, area1.id, area2.id],
368+
}
369+
)
370+
msg = await client.receive_json()
371+
assert msg["success"]
372+
373+
await client.send_json_auto_id({"type": "config/area_registry/list"})
374+
msg = await client.receive_json()
375+
assert [area["area_id"] for area in msg["result"]] == [area3.id, area1.id, area2.id]
376+
377+
378+
async def test_reorder_areas_invalid_area_ids(
379+
client: MockHAClientWebSocket, area_registry: ar.AreaRegistry
380+
) -> None:
381+
"""Test reorder with invalid area IDs."""
382+
area1 = area_registry.async_create("mock 1")
383+
area_registry.async_create("mock 2")
384+
385+
await client.send_json_auto_id(
386+
{
387+
"type": "config/area_registry/reorder",
388+
"area_ids": [area1.id],
389+
}
390+
)
391+
msg = await client.receive_json()
392+
assert not msg["success"]
393+
assert msg["error"]["code"] == "invalid_format"
394+
assert "must contain all existing area IDs" in msg["error"]["message"]
395+
396+
397+
async def test_reorder_areas_with_nonexistent_id(
398+
client: MockHAClientWebSocket, area_registry: ar.AreaRegistry
399+
) -> None:
400+
"""Test reorder with nonexistent area ID."""
401+
area1 = area_registry.async_create("mock 1")
402+
area2 = area_registry.async_create("mock 2")
403+
404+
await client.send_json_auto_id(
405+
{
406+
"type": "config/area_registry/reorder",
407+
"area_ids": [area1.id, area2.id, "nonexistent"],
408+
}
409+
)
410+
msg = await client.receive_json()
411+
assert not msg["success"]
412+
assert msg["error"]["code"] == "invalid_format"
413+
414+
415+
async def test_reorder_areas_persistence(
416+
hass: HomeAssistant,
417+
client: MockHAClientWebSocket,
418+
area_registry: ar.AreaRegistry,
419+
hass_storage: dict[str, Any],
420+
) -> None:
421+
"""Test that area reordering is persisted."""
422+
area1 = area_registry.async_create("mock 1")
423+
area2 = area_registry.async_create("mock 2")
424+
area3 = area_registry.async_create("mock 3")
425+
426+
await client.send_json_auto_id(
427+
{
428+
"type": "config/area_registry/reorder",
429+
"area_ids": [area2.id, area3.id, area1.id],
430+
}
431+
)
432+
msg = await client.receive_json()
433+
assert msg["success"]
434+
435+
await hass.async_block_till_done()
436+
437+
area_ids = [area.id for area in area_registry.async_list_areas()]
438+
assert area_ids == [area2.id, area3.id, area1.id]

tests/components/config/test_floor_registry.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Test floor registry API."""
22

33
from datetime import datetime
4+
from typing import Any
45

56
from freezegun.api import FrozenDateTimeFactory
67
import pytest
@@ -275,3 +276,100 @@ async def test_update_with_name_already_in_use(
275276
== "The name Second floor (secondfloor) is already in use"
276277
)
277278
assert len(floor_registry.floors) == 2
279+
280+
281+
async def test_reorder_floors(
282+
client: MockHAClientWebSocket, floor_registry: fr.FloorRegistry
283+
) -> None:
284+
"""Test reorder floors."""
285+
floor1 = floor_registry.async_create("First floor")
286+
floor2 = floor_registry.async_create("Second floor")
287+
floor3 = floor_registry.async_create("Third floor")
288+
289+
await client.send_json_auto_id({"type": "config/floor_registry/list"})
290+
msg = await client.receive_json()
291+
assert [floor["floor_id"] for floor in msg["result"]] == [
292+
floor1.floor_id,
293+
floor2.floor_id,
294+
floor3.floor_id,
295+
]
296+
297+
await client.send_json_auto_id(
298+
{
299+
"type": "config/floor_registry/reorder",
300+
"floor_ids": [floor3.floor_id, floor1.floor_id, floor2.floor_id],
301+
}
302+
)
303+
msg = await client.receive_json()
304+
assert msg["success"]
305+
306+
await client.send_json_auto_id({"type": "config/floor_registry/list"})
307+
msg = await client.receive_json()
308+
assert [floor["floor_id"] for floor in msg["result"]] == [
309+
floor3.floor_id,
310+
floor1.floor_id,
311+
floor2.floor_id,
312+
]
313+
314+
315+
async def test_reorder_floors_invalid_floor_ids(
316+
client: MockHAClientWebSocket, floor_registry: fr.FloorRegistry
317+
) -> None:
318+
"""Test reorder with invalid floor IDs."""
319+
floor1 = floor_registry.async_create("First floor")
320+
floor_registry.async_create("Second floor")
321+
322+
await client.send_json_auto_id(
323+
{
324+
"type": "config/floor_registry/reorder",
325+
"floor_ids": [floor1.floor_id],
326+
}
327+
)
328+
msg = await client.receive_json()
329+
assert not msg["success"]
330+
assert msg["error"]["code"] == "invalid_format"
331+
assert "must contain all existing floor IDs" in msg["error"]["message"]
332+
333+
334+
async def test_reorder_floors_with_nonexistent_id(
335+
client: MockHAClientWebSocket, floor_registry: fr.FloorRegistry
336+
) -> None:
337+
"""Test reorder with nonexistent floor ID."""
338+
floor1 = floor_registry.async_create("First floor")
339+
floor2 = floor_registry.async_create("Second floor")
340+
341+
await client.send_json_auto_id(
342+
{
343+
"type": "config/floor_registry/reorder",
344+
"floor_ids": [floor1.floor_id, floor2.floor_id, "nonexistent"],
345+
}
346+
)
347+
msg = await client.receive_json()
348+
assert not msg["success"]
349+
assert msg["error"]["code"] == "invalid_format"
350+
351+
352+
async def test_reorder_floors_persistence(
353+
hass: HomeAssistant,
354+
client: MockHAClientWebSocket,
355+
floor_registry: fr.FloorRegistry,
356+
hass_storage: dict[str, Any],
357+
) -> None:
358+
"""Test that floor reordering is persisted."""
359+
floor1 = floor_registry.async_create("First floor")
360+
floor2 = floor_registry.async_create("Second floor")
361+
floor3 = floor_registry.async_create("Third floor")
362+
363+
await client.send_json_auto_id(
364+
{
365+
"type": "config/floor_registry/reorder",
366+
"floor_ids": [floor2.floor_id, floor3.floor_id, floor1.floor_id],
367+
}
368+
)
369+
msg = await client.receive_json()
370+
assert msg["success"]
371+
372+
await hass.async_block_till_done()
373+
374+
floor_ids = [floor.floor_id for floor in floor_registry.async_list_floors()]
375+
assert floor_ids == [floor2.floor_id, floor3.floor_id, floor1.floor_id]

0 commit comments

Comments
 (0)