Files
houseplan-card/custom_components/houseplan/projection.py
2026-08-23 10:22:51 +03:00

96 lines
3.5 KiB
Python

"""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
}