feat(api): let config/get and layout/get return less

Issue: #256
User-Visible: no
This commit is contained in:
Matysh
2026-08-23 10:22:51 +03:00
parent 6d0fa3b819
commit a952f5fd07
3 changed files with 269 additions and 5 deletions
+95
View File
@@ -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
}
+36 -5
View File
@@ -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)),
+138
View File
@@ -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,
]