mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
feat(api): let config/get and layout/get return less
Issue: #256 User-Visible: no
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)),
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
Reference in New Issue
Block a user