From a952f5fd07ad66aeba83c2f5d39b6a0ba4fc4303 Mon Sep 17 00:00:00 2001 From: Matysh Date: Sun, 23 Aug 2026 10:22:51 +0300 Subject: [PATCH] feat(api): let config/get and layout/get return less Issue: #256 User-Visible: no --- custom_components/houseplan/projection.py | 95 +++++++++++++ custom_components/houseplan/websocket_api.py | 41 +++++- tests_backend/test_projection.py | 138 +++++++++++++++++++ 3 files changed, 269 insertions(+), 5 deletions(-) create mode 100644 custom_components/houseplan/projection.py create mode 100644 tests_backend/test_projection.py diff --git a/custom_components/houseplan/projection.py b/custom_components/houseplan/projection.py new file mode 100644 index 00000000..183be193 --- /dev/null +++ b/custom_components/houseplan/projection.py @@ -0,0 +1,95 @@ +"""Read-only projections of the stored plan (#256). + +A full configuration is 70 KB on a real installation: three spaces and 139 +markers. Every diagnostic question — "which space does this marker point at", +"how many markers are hidden", "what is on the first floor" — used to require +downloading all of it, because `houseplan/config/get` had no way to ask for +less. + +The functions here are pure and deliberately unaware of Home Assistant: the +websocket handlers stay thin, and the interesting part is covered by tests that +run without the HA harness. + +Two rules shape everything below. + +* Absent parameter means *no projection*. The response must stay byte-for-byte + what it was before this module existed; no existing client may notice it. +* A projection never invents or repairs data. An unknown field name simply adds + nothing, and an unknown space yields an empty list rather than an error — the + caller distinguishes "no such thing" from "broken" by content, not by an + error code. +""" +from __future__ import annotations + +from typing import Any, Iterable + + +def _names(value: Any) -> list[str] | None: + """Normalise a field list; anything unusable means "no projection".""" + if not isinstance(value, (list, tuple)): + return None + names = [str(item) for item in value if isinstance(item, str) and item] + return names or None + + +def project_markers(markers: Any, marker_fields: Iterable[str] | None) -> Any: + """Keep only the requested marker fields, plus `id`. + + `id` is added unconditionally: a marker without it cannot be matched to + anything, so a projection that drops it produces an answer nobody can use. + """ + names = _names(marker_fields) + if names is None or not isinstance(markers, list): + return markers + keep = {"id", *names} + out = [] + for marker in markers: + if not isinstance(marker, dict): + out.append(marker) + continue + out.append({key: value for key, value in marker.items() if key in keep}) + return out + + +def project_config( + config: Any, + *, + space_id: str | None = None, + fields: Iterable[str] | None = None, + marker_fields: Iterable[str] | None = None, +) -> Any: + """Return a narrowed copy of the configuration. + + The original object is never mutated: the caller hands us the store's + document, and a projection that edited it in place would corrupt the very + thing it was asked to read. + """ + if not isinstance(config, dict): + return config + field_names = _names(fields) + if space_id is None and field_names is None and _names(marker_fields) is None: + return config + + projected: dict[str, Any] = dict(config) + if space_id is not None: + spaces = projected.get("spaces") + projected["spaces"] = [ + space for space in spaces + if isinstance(space, dict) and str(space.get("id", "")) == str(space_id) + ] if isinstance(spaces, list) else spaces + if marker_fields is not None: + projected["markers"] = project_markers(projected.get("markers"), marker_fields) + if field_names is not None: + projected = {key: value for key, value in projected.items() if key in set(field_names)} + return projected + + +def project_layout(layout: Any, *, space_id: str | None = None) -> Any: + """Keep only the positions of one space.""" + if space_id is None or not isinstance(layout, dict): + return layout + wanted = str(space_id) + return { + key: position for key, position in layout.items() + if isinstance(position, dict) and str(position.get("s", "")) == wanted + } diff --git a/custom_components/houseplan/websocket_api.py b/custom_components/houseplan/websocket_api.py index ece0c582..aca102ca 100755 --- a/custom_components/houseplan/websocket_api.py +++ b/custom_components/houseplan/websocket_api.py @@ -59,6 +59,7 @@ from .virtual_lights import ( async_virtual_light_snapshot, ) from .registry_snapshot import import_registry_snapshot +from .projection import project_config, project_layout from .validation import ( CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_CONFIG_BYTES, MAX_PLAN_BYTES, PLAN_EXTENSIONS, POS_SCHEMA, MarkerControlError, OpeningPassageError, @@ -529,10 +530,15 @@ def _live_layout(config: dict[str, Any], layout: dict[str, Any]) -> dict[str, An return live_layout(config, layout) -@websocket_api.websocket_command({vol.Required("type"): "houseplan/layout/get"}) +@websocket_api.websocket_command( + { + vol.Required("type"): "houseplan/layout/get", + vol.Optional("space_id"): vol.All(str, vol.Length(min=1, max=200)), + } +) @websocket_api.async_response async def ws_layout_get(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None: - """Return the saved layout.""" + """Return the saved layout, optionally narrowed to one space (#256).""" rt = _runtime(hass, connection, msg["id"]) if rt is None: return @@ -540,7 +546,9 @@ async def ws_layout_get(hass: HomeAssistant, connection, msg: dict[str, Any]) -> config_data = await rt.config_store.async_load() or {} connection.send_result( msg["id"], { - "layout": data.get("layout", {}), + "layout": project_layout( + data.get("layout", {}), space_id=msg.get("space_id"), + ), "rev": int(data.get("rev", 0)), "can_optimize_undo": _optimizer_backup_is_current(config_data, data), "undo_kind": _undo_kind(config_data, data), @@ -1081,7 +1089,20 @@ async def ws_layout_delete(hass: HomeAssistant, connection, msg: dict[str, Any]) # ---------------- space configuration ---------------- -@websocket_api.websocket_command({vol.Required("type"): "houseplan/config/get"}) +_PROJECTION_FIELDS = vol.All([vol.All(str, vol.Length(min=1, max=100))], vol.Length(max=50)) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "houseplan/config/get", + # Проекция ответа (#256). Все параметры необязательны, и без них ответ + # прежний — это главный инвариант: ни один существующий клиент не + # должен заметить появление этой возможности. + vol.Optional("space_id"): vol.All(str, vol.Length(min=1, max=200)), + vol.Optional("fields"): _PROJECTION_FIELDS, + vol.Optional("marker_fields"): _PROJECTION_FIELDS, + } +) @websocket_api.async_response async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None: """Return the configuration, its revision, and whether this user may write. @@ -1089,6 +1110,11 @@ async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) -> `can_write` is the single source of truth for the card's editor chrome (audit P0-4): the UI must mirror `may_write`, not a hard-coded is_admin check that drifted from the integration option. + + Optional `space_id`/`fields`/`marker_fields` narrow ONLY the returned + document (#256). Revisions and capability flags are computed from the whole + stored configuration: a caller that asked for one floor must not receive a + revision that describes only that floor. """ rt = _runtime(hass, connection, msg["id"]) if rt is None: @@ -1112,7 +1138,12 @@ async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) -> connection.send_result( msg["id"], { - "config": config, + "config": project_config( + config, + space_id=msg.get("space_id"), + fields=msg.get("fields"), + marker_fields=msg.get("marker_fields"), + ), "rev": config_rev, "virtual_lights": virtual_lights, "can_write": may_write(hass, getattr(connection, "user", None)), diff --git a/tests_backend/test_projection.py b/tests_backend/test_projection.py new file mode 100644 index 00000000..c81549b3 --- /dev/null +++ b/tests_backend/test_projection.py @@ -0,0 +1,138 @@ +"""Pure tests for the read-only projections (#256). + +The main invariant is negative: without parameters nothing may change. Every +other case is measured against that one, because a projection that quietly +reshapes the default response would break clients that never asked for it. +""" +from __future__ import annotations + +import copy +import importlib.util +import os + +# Модуль грузится по пути, а не импортом пакета: `custom_components.houseplan` +# исполняет `__init__`, который безусловно тянет homeassistant, и на машине без +# HA ломается сбор всего каталога tests_backend, а не только этого файла (#135). +# Тот же приём, что в test_virtual_lights.py; сам модуль не зависит ни от чего, +# кроме стандартной библиотеки. +_PATH = os.path.join( + os.path.dirname(os.path.dirname(__file__)), + "custom_components", "houseplan", "projection.py", +) +_spec = importlib.util.spec_from_file_location("hp_projection", _PATH) +_projection = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_projection) + +project_config = _projection.project_config +project_layout = _projection.project_layout +project_markers = _projection.project_markers + + +CONFIG = { + "spaces": [ + {"id": "f1", "title": "First", "rooms": [{"id": "r1"}]}, + {"id": "f2", "title": "Second", "rooms": []}, + ], + "markers": [ + {"id": "m1", "binding": "virtual", "space": "f1", "icon": "mdi:lamp", "pdfs": []}, + {"id": "m2", "binding": "device:abc", "space": "f2", "icon": None}, + ], + "settings": {"glow_radius_cm": 200}, + "model_version": 6, +} + +LAYOUT = { + "m1": {"s": "f1", "x": 0.1, "y": 0.2}, + "m2": {"s": "f2", "x": 0.3, "y": 0.4}, + "rl_r1": {"s": "f1", "x": 0.5, "y": 0.6}, +} + + +def test_no_parameters_change_nothing(): + original = copy.deepcopy(CONFIG) + assert project_config(CONFIG) == original + assert project_config(CONFIG) is CONFIG + assert project_layout(LAYOUT) is LAYOUT + assert CONFIG == original + + +def test_space_id_narrows_spaces_only(): + projected = project_config(CONFIG, space_id="f1") + assert [space["id"] for space in projected["spaces"]] == ["f1"] + # Остальные разделы не урезаются: клиент просил меньше пространств, а не + # меньше конфигурации. + assert projected["markers"] == CONFIG["markers"] + assert projected["settings"] == CONFIG["settings"] + assert projected["model_version"] == 6 + # Исходный документ не тронут. + assert len(CONFIG["spaces"]) == 2 + + +def test_unknown_space_returns_empty_list_not_an_error(): + projected = project_config(CONFIG, space_id="nope") + assert projected["spaces"] == [] + assert projected["markers"] == CONFIG["markers"] + + +def test_marker_fields_keep_id_even_when_not_asked(): + projected = project_config(CONFIG, marker_fields=["binding", "space"]) + assert projected["markers"] == [ + {"id": "m1", "binding": "virtual", "space": "f1"}, + {"id": "m2", "binding": "device:abc", "space": "f2"}, + ] + assert CONFIG["markers"][0]["icon"] == "mdi:lamp" + + +def test_unknown_marker_field_adds_nothing(): + projected = project_config(CONFIG, marker_fields=["binding", "does_not_exist"]) + assert projected["markers"][0] == {"id": "m1", "binding": "virtual"} + + +def test_fields_narrow_top_level_keys(): + projected = project_config(CONFIG, fields=["spaces"]) + assert set(projected) == {"spaces"} + projected = project_config(CONFIG, fields=["markers", "settings"]) + assert set(projected) == {"markers", "settings"} + + +def test_fields_and_marker_fields_combine(): + projected = project_config(CONFIG, fields=["markers"], marker_fields=["space"]) + assert projected == {"markers": [{"id": "m1", "space": "f1"}, {"id": "m2", "space": "f2"}]} + + +def test_empty_or_malformed_lists_mean_no_projection(): + # Пустой список — это «я ничего не выбрал», а не «оставь ноль полей»: + # второе трактование превращает опечатку клиента в пустой ответ. + assert project_config(CONFIG, fields=[]) is CONFIG + assert project_config(CONFIG, marker_fields=[]) is CONFIG + assert project_config(CONFIG, fields="spaces") is CONFIG + assert project_markers(CONFIG["markers"], None) == CONFIG["markers"] + + +def test_layout_space_filter(): + assert project_layout(LAYOUT, space_id="f1") == { + "m1": {"s": "f1", "x": 0.1, "y": 0.2}, + "rl_r1": {"s": "f1", "x": 0.5, "y": 0.6}, + } + assert project_layout(LAYOUT, space_id="nope") == {} + assert LAYOUT == { + "m1": {"s": "f1", "x": 0.1, "y": 0.2}, + "m2": {"s": "f2", "x": 0.3, "y": 0.4}, + "rl_r1": {"s": "f1", "x": 0.5, "y": 0.6}, + } + + +def test_non_dict_documents_pass_through(): + # Хранилище может отдать что угодно после ручной правки файла: проекция — + # не место для валидации, она обязана не мешать читать то, что есть. + assert project_config(None) is None + assert project_config([1, 2], space_id="f1") == [1, 2] + assert project_layout("nope", space_id="f1") == "nope" + assert project_markers("nope", ["id"]) == "nope" + + +def test_markers_survive_unexpected_entries(): + markers = [{"id": "m1", "binding": "virtual"}, "junk", None] + assert project_markers(markers, ["binding"]) == [ + {"id": "m1", "binding": "virtual"}, "junk", None, + ]