mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 19:28:46 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
764e023996 | ||
|
|
ce6a11f53f | ||
|
|
3e976562ff | ||
|
|
f56bceef27 | ||
|
|
96a01e1380 | ||
|
|
08a9cc7d27 | ||
|
|
1be79a1427 | ||
|
|
d7c20ff6a5 | ||
|
|
fa59767c69 | ||
|
|
60d6167ecd | ||
|
|
110fabd038 | ||
|
|
3456706ef6 | ||
|
|
1310c84a32 | ||
|
|
94563d2a0c | ||
|
|
5615afa33b | ||
|
|
4083e14247 | ||
|
|
518d72fb74 | ||
|
|
fc95a1f09b | ||
|
|
4e736d49a3 | ||
|
|
aa3c379540 | ||
|
|
fa15598e67 | ||
|
|
9eec856d50 | ||
|
|
0bb9282edd | ||
|
|
09f4dc4115 | ||
|
|
2551b4ea0e | ||
|
|
694e1e9a3b | ||
|
|
996a7442ec | ||
|
|
098a147f87 | ||
|
|
14f7c06bf4 | ||
|
|
9f36b39379 | ||
|
|
9da96abb05 | ||
|
|
df65d25348 | ||
|
|
8db6b2673f | ||
|
|
c9030af900 | ||
|
|
5392dadeaa | ||
|
|
aa53b33dd6 | ||
|
|
a8ce6020f4 | ||
|
|
9282c28830 | ||
|
|
8c5d5ba5c5 | ||
|
|
6c90e03427 | ||
|
|
9b180c5917 | ||
|
|
3084472c75 | ||
|
|
c00048611e | ||
|
|
f5e6c0318d | ||
|
|
e1e730560d | ||
|
|
94b298962a | ||
|
|
f7fe63776a | ||
|
|
01bc4f9711 | ||
|
|
85491d0fea | ||
|
|
d37a67c29f | ||
|
|
a66272c6f4 | ||
|
|
f4af2fe508 | ||
|
|
8e07e3c958 | ||
|
|
9868f1035f | ||
|
|
f2c9b07cc1 | ||
|
|
2c7a2f849d | ||
|
|
33e71ca96c | ||
|
|
7128ab504d | ||
|
|
f953a3c286 | ||
|
|
ef270d11b7 | ||
|
|
dc24390222 | ||
|
|
254354bf56 | ||
|
|
c9a60a110d | ||
|
|
75279308c1 |
@@ -75,6 +75,12 @@ Key advantages in short:
|
||||
- **Automatic device placement.** Outline a room and bind it to a Home Assistant area — the devices of that area appear on the plan by themselves.
|
||||
- **Manual additions of your own.** Any device, group or even a "virtual" point can be placed on the plan manually, with a name, icon, model, link and an attached PDF manual.
|
||||
- **Live states.** Temperature, Zigbee signal strength, on/off, open/closed — everything updates in real time.
|
||||
Icon colors follow one principle — **yellow means the device is doing its main job right now**:
|
||||
a light is shining, a socket is powering, a fan is spinning, media is playing, a vacuum is
|
||||
cleaning, a radiator valve is actually heating (not merely enabled). Orange = open / unlocked.
|
||||
A pulsing red ring = an emergency (leak, smoke, gas). An RGB bulb's colour lives in its glow
|
||||
spot (glow fill), where the spot itself is the on/off indicator and the badge stays standard.
|
||||
A translucent icon = unavailable. Dark = idle.
|
||||
- **Crisp zoom.** Zooming in does not "blur" the picture: the plan, labels and icons remain vector-sharp at any scale.
|
||||
|
||||
---
|
||||
@@ -156,7 +162,7 @@ for each floor (names prefilled, a plan image is asked for one by one; any floor
|
||||
|
||||

|
||||
|
||||
In the dialog, set a **name** (for example, "1st floor") and **upload a background** — a floor-plan image in SVG, PNG or JPG format. Both fields are required: without a plan the "Save" button stays disabled.
|
||||
In the dialog, set a **name** (for example, "1st floor") and pick the background: **upload** a floor-plan image (SVG, PNG, JPG, WebP), **choose one already uploaded** to the server earlier, or select **"no background, I'll draw the rooms"** for a hand-drawn space. The canvas is always square; an image keeps its own proportions and is centred inside it.
|
||||
|
||||

|
||||
|
||||
@@ -199,7 +205,7 @@ walls** (it slides around corners too), and a **double click opens its propertie
|
||||
|
||||
As soon as you save a room bound to an area, **the devices of that area are automatically laid out inside the outline**. These are the same devices shown on the **Settings → Devices → (filtered by the room)** page — only the meaningful ones, without service records, bridges and duplicates.
|
||||
|
||||
By default only meaningful devices make it onto the plan — service records, bridges and duplicates are filtered out. If you need to see **absolutely all** devices of the area, enable the **👁 "Show all devices"** button in the header.
|
||||
By default only meaningful devices make it onto the plan: non-physical ones (service records, bridges, scenes, individual lamps folded into a light group) arrive with the **"Hide device from plan"** checkbox already ticked. The checkbox is yours from then on — every device dialog has it, virtual devices included. To see and un-hide them, open the device editor and press **"Show hidden"**: hidden devices appear as translucent blue ghosts, a click opens the dialog. Hidden devices still count toward the room's Zigbee signal, but cast no light.
|
||||
|
||||
From here on you can just use the plan: clicking an icon opens the device card with the model, link and a button to jump into Home Assistant.
|
||||
|
||||
@@ -281,7 +287,7 @@ Services → House Plan**.
|
||||
|
||||
**Do I need to write anything in YAML?** No. The only line is adding the card to the dashboard; everything else is done with the mouse.
|
||||
|
||||
**My devices did not appear on the plan.** A device appears only if its Home Assistant area is bound to a drawn room. Check that the device has a room assigned (Settings → Devices) and that the room is outlined and bound to that area. If the device exists but is hidden by curation (bridges, service records, duplicates) — enable the **👁 "Show all devices"** button in the header.
|
||||
**My devices did not appear on the plan.** A device appears only if its Home Assistant area is bound to a drawn room. Check that the device has a room assigned (Settings → Devices) and that the room is outlined and bound to that area. If the device exists but is hidden (the "Hide device from plan" checkbox — set automatically for bridges, scenes and other non-physical records) — open the device editor, press **"Show hidden"** and untick the box in its dialog.
|
||||
|
||||
**Can I hide an unwanted device or rename it?** Yes — click the device on the plan and press "Edit" in its card: there you can change the name, icon, model or hide the icon.
|
||||
|
||||
|
||||
+9
-3
@@ -74,6 +74,12 @@ House Plan показывает ваш умный дом так, как он в
|
||||
- **Автоматическое добавление устройств.** Обвели комнату и привязали её к зоне Home Assistant — устройства этой зоны сами появляются на плане.
|
||||
- **Ручное добавление своих.** Любое устройство, группу или даже «виртуальную» точку можно поставить на план вручную, задать имя, иконку, модель, ссылку и приложить PDF-инструкцию.
|
||||
- **Живые состояния.** Температура, уровень сигнала Zigbee, вкл/выкл, открыто/закрыто — всё обновляется в реальном времени.
|
||||
Цвета значков подчиняются одному принципу — **жёлтый значит «устройство прямо сейчас выполняет свою основную работу»**:
|
||||
лампа светит, розетка подаёт, вентилятор крутится, медиа играет, пылесос убирает, термоголовка
|
||||
реально греет (а не просто включена). Оранжевый = открыто / не заперто. Пульсирующее красное
|
||||
кольцо = авария (протечка, дым, газ). Цвет RGB-лампы живёт в её пятне света (режим glow),
|
||||
где само пятно — индикатор включения, а подложка значка остаётся стандартной.
|
||||
Полупрозрачный значок = недоступно. Тёмный = покой.
|
||||
- **Чёткий зум.** Приближение не «мылит» картинку: план, подписи и иконки остаются векторно-чёткими на любом масштабе.
|
||||
|
||||
---
|
||||
@@ -157,7 +163,7 @@ title: План дома
|
||||
|
||||

|
||||
|
||||
В диалоге задайте **название** (например, «1 этаж») и **загрузите подложку** — картинку плана этажа в формате SVG, PNG или JPG. Оба поля обязательны: без плана кнопка «Сохранить» неактивна.
|
||||
В диалоге задайте **название** (например, «1 этаж») и выберите подложку: **загрузите** картинку плана (SVG, PNG, JPG, WebP), **возьмите уже загруженную** на сервер ранее или отметьте **«без подложки, нарисую комнаты сам»**. Холст всегда квадратный; картинка сохраняет свои пропорции и центрируется внутри него.
|
||||
|
||||

|
||||
|
||||
@@ -200,7 +206,7 @@ title: План дома
|
||||
|
||||
Как только вы сохранили комнату с привязкой к зоне, **устройства этой зоны автоматически расставляются внутри контура**. Берутся те же устройства, что показаны на странице **Настройки → Устройства → (фильтр по нужной комнате)** — только осмысленные, без служебных записей, мостов и дубликатов.
|
||||
|
||||
По умолчанию на план попадают только осмысленные устройства — служебные записи, мосты и дубликаты отфильтрованы. Если нужно видеть **вообще все** устройства зоны, включите в шапке кнопку **👁 «Показать все устройства»**.
|
||||
По умолчанию на план попадают только осмысленные устройства: нефизические (служебные записи, мосты, сцены, лампы, свёрнутые в световую группу) приходят с уже установленной галкой **«Скрыть устройство с плана»**. Дальше галка принадлежит вам — она есть в диалоге каждого устройства, включая виртуальные. Чтобы увидеть и вернуть скрытые, откройте редактор устройств и нажмите **«Показать скрытые»**: скрытые отобразятся полупрозрачными синими призраками, клик открывает диалог. Скрытые устройства учитываются в Zigbee-сигнале комнаты, но света не дают.
|
||||
|
||||
Дальше можно просто пользоваться планом: клик по иконке открывает карточку устройства с моделью, ссылкой и кнопкой перехода в Home Assistant.
|
||||
|
||||
@@ -283,7 +289,7 @@ title: План дома
|
||||
|
||||
**Нужно ли что-то писать в YAML?** Нет. Единственная строчка — это добавление карточки на дашборд; всё остальное делается мышкой.
|
||||
|
||||
**Мои устройства не появились на плане.** Устройство появляется, только если его зона в Home Assistant привязана к нарисованной комнате. Проверьте, что у устройства задана комната (Настройки → Устройства), а комната обведена и привязана к этой зоне. Если устройство есть, но скрыто курированием (мосты, служебные, дубликаты) — включите в шапке кнопку **👁 «Показать все устройства»**.
|
||||
**Мои устройства не появились на плане.** Устройство появляется, только если его зона в Home Assistant привязана к нарисованной комнате. Проверьте, что у устройства задана комната (Настройки → Устройства), а комната обведена и привязана к этой зоне. Если устройство есть, но скрыто (галка «Скрыть устройство с плана» — для мостов, сцен и прочих нефизических записей она ставится автоматически) — откройте редактор устройств, нажмите **«Показать скрытые»** и снимите галку в его диалоге.
|
||||
|
||||
**Можно ли скрыть лишнее устройство или переименовать его?** Да — кликните по устройству на плане и в его карточке нажмите «Редактировать»: там можно сменить имя, иконку, модель или скрыть значок.
|
||||
|
||||
|
||||
@@ -20,9 +20,10 @@ from .const import (
|
||||
PLANS_URL,
|
||||
VERSION,
|
||||
)
|
||||
from .geometry_migration import migrate_config, migrate_layout, pending_from_config
|
||||
from .plans import collect_attachments, collect_plans, sweep_upload_temps
|
||||
from .repairs import async_check_plan_files
|
||||
from .store import HouseplanConfigEntry, create_data, get_data
|
||||
from .store import HouseplanConfigEntry, create_data
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -99,6 +100,46 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
|
||||
module_url, module_url,
|
||||
)
|
||||
|
||||
# One-time move to the square canvas (v1.48.0). Coordinates used to be
|
||||
# normalised against a per-space aspect ratio; the canvas is now always
|
||||
# square and a plan is centred inside it. Nothing about the drawing changes
|
||||
# — the box is padded and the numbers re-expressed against it.
|
||||
# The two stores are written independently, and the lock is no transaction:
|
||||
# a crash between the writes used to leave the config in square coordinates
|
||||
# with the layout still in the old ones — permanently, because the config
|
||||
# write had already deleted the `aspect` fields the layout half needed
|
||||
# (HP-1490-01). So the intent is made durable FIRST, in the layout store,
|
||||
# and each half carries its own trigger with its own write: the config half
|
||||
# removes `aspect`, the layout half removes the saved intent. Whatever
|
||||
# half is missing after a crash, the next start finishes exactly it.
|
||||
async with data.write_lock:
|
||||
stored = await data.config_store.async_load() or {}
|
||||
cfg = stored.get("config")
|
||||
lay_stored = await data.store.async_load() or {}
|
||||
layout = lay_stored.get("layout") or {}
|
||||
pending = {
|
||||
str(k): v for k, v in (lay_stored.get("geom_pending") or {}).items()
|
||||
}
|
||||
merged = {**pending, **pending_from_config(cfg)}
|
||||
if merged:
|
||||
lay_rev = int(lay_stored.get("rev", 0))
|
||||
if merged != pending: # 1. the durable intent, before anything moves
|
||||
await data.store.async_save(
|
||||
{"layout": layout, "rev": lay_rev, "geom_pending": merged}
|
||||
)
|
||||
rev = int(stored.get("rev", 0))
|
||||
if cfg and migrate_config(cfg): # 2. the config half
|
||||
rev += 1
|
||||
await data.config_store.async_save({"config": cfg, "rev": rev})
|
||||
migrate_layout(layout, merged) # 3. the layout half + intent cleared
|
||||
await data.store.async_save({"layout": layout, "rev": lay_rev + 1})
|
||||
_LOGGER.info(
|
||||
"House Plan: migrated %s space(s) to the square canvas", len(merged)
|
||||
)
|
||||
# only once both halves are durable — a client refetching on this
|
||||
# event must never see one migrated half and one old one
|
||||
hass.bus.async_fire("houseplan_config_updated", {"rev": rev})
|
||||
|
||||
await async_check_plan_files(hass, entry)
|
||||
|
||||
# Scheduled collection of everything nobody ended up referencing.
|
||||
@@ -118,16 +159,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
|
||||
files_dir = Path(hass.config.path(FILES_DIR))
|
||||
plans_dir = Path(hass.config.path(PLANS_DIR))
|
||||
try:
|
||||
data = get_data(hass)
|
||||
if data is None: # entry unloaded — nothing authoritative to compare against
|
||||
await hass.async_add_executor_job(sweep_upload_temps, files_dir)
|
||||
return
|
||||
# `data` from the closure, NOT get_data(hass): during
|
||||
# async_setup_entry the entry is still SETUP_IN_PROGRESS, so
|
||||
# async_loaded_entries() does not list it and the lookup returned
|
||||
# None. The startup pass then silently degraded to removing
|
||||
# streaming temporaries only, and the real collection waited a full
|
||||
# day — restarting more often than that meant it never ran at all
|
||||
# (HP-1462-01). The callback is unregistered with the entry, so
|
||||
# closing over its runtime data matches the lifecycle exactly.
|
||||
async with data.write_lock:
|
||||
stored = await data.config_store.async_load() or {}
|
||||
cfg = stored.get("config") or {}
|
||||
|
||||
def _collect() -> int:
|
||||
n = sweep_upload_temps(files_dir)
|
||||
# same config on both sides: nothing is superseded, so this
|
||||
# only ever collects what the shared rules call abandoned
|
||||
n += collect_attachments(files_dir, cfg, cfg)
|
||||
n += collect_plans(plans_dir, cfg, cfg)
|
||||
return n
|
||||
@@ -138,6 +185,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
|
||||
except Exception: # noqa: BLE001 — housekeeping must never fail a setup
|
||||
_LOGGER.exception("House Plan: sweeping unreferenced files failed")
|
||||
|
||||
data.sweep = _sweep
|
||||
await _sweep()
|
||||
entry.async_on_unload(
|
||||
async_track_time_interval(hass, _sweep, timedelta(hours=24))
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -17,14 +17,35 @@ CONTENT_URL = "/api/houseplan/content"
|
||||
# way to tell which paths were dropped (review R2-2).
|
||||
MAX_SIGN_PATHS = 200
|
||||
|
||||
# Nothing is ever deleted for being old (docs/SCOPE.md), so growth has to be
|
||||
# stopped at the door instead. These bound the whole store, not one request: by
|
||||
# default any authenticated user may upload, and a per-request cap of 8/50 MB
|
||||
# says nothing about how many requests there are (HP-1470-01).
|
||||
MAX_PLANS_BYTES = 256 * 1024 * 1024
|
||||
MAX_PLANS_FILES = 200
|
||||
# How many the picker asks for at once — newest first.
|
||||
MAX_PLANS_LISTED = 60
|
||||
MAX_FILES_BYTES = 1024 * 1024 * 1024
|
||||
MAX_FILES_COUNT = 1000
|
||||
# Refuse to write when the disk is nearly full: filling the config partition
|
||||
# breaks .storage, the recorder and backups, not just this card.
|
||||
MIN_FREE_BYTES = 512 * 1024 * 1024
|
||||
|
||||
# An uploaded plan that no accepted configuration references is collected only
|
||||
# once it is this old. Age is a race guard, not a policy: a plan uploaded
|
||||
# seconds ago may belong to another client's transaction that has not written
|
||||
# its configuration yet (review R3-1).
|
||||
PLAN_ORPHAN_TTL_S = 3600
|
||||
|
||||
# Kept for compatibility with anything reading it; the collectors no longer use
|
||||
# a long grace at all. Every attempt to age files out ended badly — first by
|
||||
# deleting detached plans, then by racing the save that was about to reference a
|
||||
# retried upload. What is left is deliberately simple: files go when the user's
|
||||
# action says so, plus staging folders after PLAN_ORPHAN_TTL_S.
|
||||
SCHEDULED_GRACE_S = 30 * 24 * 3600
|
||||
FILES_DIR = "houseplan/files"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.46.2"
|
||||
VERSION = "1.53.0"
|
||||
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"spaces": [],
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,161 @@
|
||||
"""One-time migration to a square canvas — pure, so it can be tested alone.
|
||||
|
||||
Until v1.48.0 a space had an `aspect`, and coordinates were normalised against
|
||||
it: x by the width, y by the HEIGHT. Making every canvas square without touching
|
||||
the numbers would stretch every plan vertically.
|
||||
|
||||
Nothing about the drawing changes here. The canvas is padded to a square —
|
||||
top and bottom for a wide plan, left and right for a tall one — and the
|
||||
coordinates are re-expressed against that larger box. In render units it is a
|
||||
uniform scale plus an offset, so angles, room proportions and relative positions
|
||||
survive exactly. `cell_cm` follows, because the grid is tied to the width: for a
|
||||
tall plan the width grew, so the same wall would otherwise measure less.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def transform_for(aspect: float) -> tuple[float, float, float, float]:
|
||||
"""(dx, dy, kx, ky) that map old normalised coordinates onto the square.
|
||||
|
||||
x' = dx + x * kx, y' = dy + y * ky. Lengths along an axis scale by that
|
||||
axis's factor; both are the same uniform scale in RENDER units, which is
|
||||
why angles are preserved.
|
||||
"""
|
||||
a = float(aspect)
|
||||
if not a or a <= 0:
|
||||
a = 1.0
|
||||
k = min(1.0, a) # how much the old box shrinks inside the square
|
||||
kx = k # x was normalised by the width
|
||||
ky = k / a # y was normalised by the height (= width / aspect)
|
||||
return (1.0 - kx) / 2, (1.0 - ky) / 2, kx, ky
|
||||
|
||||
|
||||
def _pt(p: Any, dx: float, dy: float, kx: float, ky: float) -> Any:
|
||||
if isinstance(p, (list, tuple)) and len(p) >= 2:
|
||||
return [dx + float(p[0]) * kx, dy + float(p[1]) * ky]
|
||||
return p
|
||||
|
||||
|
||||
def migrate_space(space: dict[str, Any]) -> bool:
|
||||
"""Rewrite one space in place. Returns True when anything was changed."""
|
||||
if "aspect" not in space:
|
||||
return False
|
||||
try:
|
||||
aspect = float(space.get("aspect") or 1)
|
||||
except (TypeError, ValueError):
|
||||
aspect = 1.0
|
||||
dx, dy, kx, ky = transform_for(aspect)
|
||||
space.pop("aspect", None)
|
||||
|
||||
for room in space.get("rooms") or []:
|
||||
if room.get("x") is not None:
|
||||
room["x"] = dx + float(room["x"]) * kx
|
||||
if room.get("y") is not None:
|
||||
room["y"] = dy + float(room["y"]) * ky
|
||||
if room.get("w") is not None:
|
||||
room["w"] = float(room["w"]) * kx
|
||||
if room.get("h") is not None:
|
||||
room["h"] = float(room["h"]) * ky
|
||||
if room.get("poly"):
|
||||
room["poly"] = [_pt(p, dx, dy, kx, ky) for p in room["poly"]]
|
||||
|
||||
for op in space.get("openings") or []:
|
||||
op["x"] = dx + float(op.get("x", 0)) * kx
|
||||
op["y"] = dy + float(op.get("y", 0)) * ky
|
||||
# a length is measured along the wall, and the render scale is uniform
|
||||
if op.get("length") is not None:
|
||||
op["length"] = float(op["length"]) * kx
|
||||
|
||||
for shape in space.get("decor") or []:
|
||||
for a, b, fx, fy in (("x1", "y1", kx, ky), ("x2", "y2", kx, ky), ("x", "y", kx, ky)):
|
||||
if shape.get(a) is not None:
|
||||
shape[a] = dx + float(shape[a]) * fx
|
||||
if shape.get(b) is not None:
|
||||
shape[b] = dy + float(shape[b]) * fy
|
||||
if shape.get("w") is not None:
|
||||
shape["w"] = float(shape["w"]) * kx
|
||||
if shape.get("h") is not None:
|
||||
shape["h"] = float(shape["h"]) * ky
|
||||
|
||||
# The viewport becomes the whole square rather than the transformed old
|
||||
# rectangle. It is what the grid is drawn over and what "fit to screen"
|
||||
# fits, so keeping the old box would leave the new margins outside the
|
||||
# canvas — no dots, nothing to draw on — which is exactly the room this
|
||||
# change was meant to give.
|
||||
space["view_box"] = [0.0, 0.0, 1.0, 1.0]
|
||||
|
||||
# The grid pitch is a fraction of the WIDTH. A tall plan just got a wider
|
||||
# canvas, so a wall now covers fewer cells; without this every measurement
|
||||
# in the plan would silently shrink.
|
||||
if kx != 1:
|
||||
try:
|
||||
cell = float(space.get("cell_cm") or 5)
|
||||
except (TypeError, ValueError):
|
||||
cell = 5.0
|
||||
space["cell_cm"] = round(cell / kx, 4)
|
||||
|
||||
# The image keeps its own proportions and is centred; the space no longer
|
||||
# has any of its own.
|
||||
if space.get("plan_url") and not space.get("plan_aspect"):
|
||||
space["plan_aspect"] = round(aspect, 6)
|
||||
return True
|
||||
|
||||
|
||||
def pending_from_config(config: dict[str, Any] | None) -> dict[str, float]:
|
||||
"""{space_id: old aspect} for every space still carrying one.
|
||||
|
||||
This is the migration INTENT. The two stores are written independently and
|
||||
either write can fail, so the intent has to survive on its own: it is saved
|
||||
into the layout store BEFORE anything changes (HP-1490-01), and cleared by
|
||||
the same write that stores the migrated layout. A crash between the writes
|
||||
leaves the intent behind, and the next start finishes the missing half —
|
||||
each half is idempotent because its trigger (`aspect` in the config, the
|
||||
saved intent for the layout) travels with that half's own write.
|
||||
"""
|
||||
out: dict[str, float] = {}
|
||||
for space in (config or {}).get("spaces") or []:
|
||||
if "aspect" not in space:
|
||||
continue
|
||||
try:
|
||||
out[str(space.get("id"))] = float(space.get("aspect") or 1) or 1.0
|
||||
except (TypeError, ValueError):
|
||||
out[str(space.get("id"))] = 1.0
|
||||
return out
|
||||
|
||||
|
||||
def migrate_config(config: dict[str, Any], layout: dict[str, Any] | None = None) -> bool:
|
||||
"""The config half: migrate every space still carrying an `aspect`.
|
||||
|
||||
`layout` is accepted for backward compatibility and migrated with the
|
||||
factors found in the config — callers that can crash between store writes
|
||||
should use `pending_from_config()` + `migrate_layout()` instead, so the
|
||||
layout half does not depend on state the config half just deleted.
|
||||
"""
|
||||
factors = pending_from_config(config)
|
||||
if not factors:
|
||||
return False
|
||||
for space in config.get("spaces") or []:
|
||||
migrate_space(space)
|
||||
if layout:
|
||||
migrate_layout(layout, factors)
|
||||
return True
|
||||
|
||||
|
||||
def migrate_layout(layout: dict[str, Any] | None, pending: dict[str, float]) -> bool:
|
||||
"""The layout half: marker and label positions of the spaces in `pending`."""
|
||||
changed = False
|
||||
for pos in (layout or {}).values():
|
||||
if not isinstance(pos, dict) or str(pos.get("s")) not in pending:
|
||||
continue
|
||||
dx, dy, kx, ky = transform_for(pending[str(pos.get("s"))])
|
||||
if pos.get("x") is not None:
|
||||
pos["x"] = dx + float(pos["x"]) * kx
|
||||
if pos.get("y") is not None:
|
||||
pos["y"] = dy + float(pos["y"]) * ky
|
||||
changed = True
|
||||
return changed
|
||||
@@ -20,9 +20,12 @@ except ImportError: # older HA versions
|
||||
KEY_HASS = "hass" # type: ignore[assignment]
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import CONF_ADMIN_ONLY, CONTENT_URL, FILES_DIR, FILES_URL, PLANS_DIR
|
||||
from .const import (
|
||||
CONF_ADMIN_ONLY, CONTENT_URL, FILES_DIR, FILES_URL, MAX_FILES_BYTES,
|
||||
MAX_FILES_COUNT, PLANS_DIR,
|
||||
)
|
||||
from .auth import may_write
|
||||
from .plans import TMP_PREFIX, reserve_filename
|
||||
from .plans import TMP_PREFIX, QuotaError, check_quota, reserve_filename
|
||||
from .validation import (
|
||||
FILE_EXTENSIONS,
|
||||
MAX_FILE_BYTES,
|
||||
@@ -204,6 +207,16 @@ class HouseplanUploadView(HomeAssistantView):
|
||||
return web.json_response({"error": "no_file"}, status=400)
|
||||
|
||||
tmp_path = temps[0]
|
||||
try:
|
||||
await hass.async_add_executor_job(
|
||||
check_quota, files_root, tmp_path.stat().st_size,
|
||||
MAX_FILES_BYTES, MAX_FILES_COUNT,
|
||||
)
|
||||
except QuotaError as err:
|
||||
_LOGGER.warning("House Plan upload refused: %s", err.detail)
|
||||
return web.json_response({"error": err.reason, "detail": err.detail}, status=507)
|
||||
except OSError:
|
||||
pass
|
||||
target_dir = files_root / marker_id
|
||||
safe_name = filename
|
||||
|
||||
|
||||
@@ -16,5 +16,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.46.2"
|
||||
"version": "1.53.0"
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .const import PLAN_ORPHAN_TTL_S
|
||||
from .const import MIN_FREE_BYTES, PLAN_ORPHAN_TTL_S
|
||||
from .validation import MAX_FILENAME, PLAN_EXTENSIONS, sanitize_filename
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -121,15 +121,25 @@ def collect_attachments(
|
||||
) -> int:
|
||||
"""The same commit-scoped rule as `collect_plans`, for marker attachments.
|
||||
|
||||
A file the old revision referenced and the new one does not was superseded
|
||||
by this commit and goes. Anything else unreferenced is an upload that was
|
||||
never saved — a cancelled dialog, a rejected write — and waits out
|
||||
PLAN_ORPHAN_TTL_S first, because a fresh one may belong to a dialog the user
|
||||
still has open. Never raises: it runs behind a durable write.
|
||||
A file the old revision referenced and the new one does not, whose marker
|
||||
still exists, was removed on purpose — the dialog has a trash button and
|
||||
promises nothing. It goes. Everything else is kept, except a staging folder
|
||||
(`up_*`), which by construction only ever holds an upload from a dialog that
|
||||
was never saved: those go after PLAN_ORPHAN_TTL_S. Never raises: it runs
|
||||
behind a durable write.
|
||||
"""
|
||||
new_refs = attachment_refs(new_cfg)
|
||||
old_refs = attachment_refs(old_cfg)
|
||||
cutoff = (time.time() if now is None else now) - PLAN_ORPHAN_TTL_S
|
||||
# Removing an attachment from a device that still exists is the user saying
|
||||
# "drop this one" — a trash button, no promise that anything is kept. A
|
||||
# device that is GONE is a different transition, and its files follow the
|
||||
# same rule as a deleted space's plan: kept.
|
||||
live_markers = {str(m.get("id")) for m in (new_cfg or {}).get("markers") or []}
|
||||
# Same distinction as for plans. A staging folder (`up_*`) is different: it
|
||||
# only ever holds an upload from a dialog that was never saved, so the short
|
||||
# rule is exactly right there even on the timer.
|
||||
now_s = time.time() if now is None else now
|
||||
staging_cutoff = now_s - PLAN_ORPHAN_TTL_S
|
||||
removed = 0
|
||||
try:
|
||||
folders = sorted(p for p in files_dir.iterdir() if p.is_dir()) if files_dir.is_dir() else []
|
||||
@@ -138,6 +148,9 @@ def collect_attachments(
|
||||
return 0
|
||||
removed += sweep_upload_temps(files_dir, now)
|
||||
for folder in folders:
|
||||
# A staging folder only ever holds an upload from a dialog that was never
|
||||
# saved — unambiguous, so an hour is right, and no device owns it.
|
||||
staging = folder.name.startswith("up_")
|
||||
try:
|
||||
items = sorted(p for p in folder.iterdir() if p.is_file())
|
||||
except OSError:
|
||||
@@ -146,12 +159,19 @@ def collect_attachments(
|
||||
rel = f"{folder.name}/{item.name}"
|
||||
if rel in new_refs:
|
||||
continue
|
||||
try:
|
||||
stale = item.stat().st_mtime < cutoff
|
||||
except OSError:
|
||||
stale = False
|
||||
if rel not in old_refs and not stale:
|
||||
continue
|
||||
dropped = rel in old_refs and folder.name in live_markers
|
||||
if not dropped:
|
||||
if not staging:
|
||||
# Same rule as for plans: not asked for, so kept. A file in
|
||||
# a device's folder that the device does not list is an
|
||||
# upload whose save was rejected — and ageing those out
|
||||
# raced the retry that was about to reference them.
|
||||
continue
|
||||
try:
|
||||
if item.stat().st_mtime >= staging_cutoff:
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
try:
|
||||
item.unlink()
|
||||
removed += 1
|
||||
@@ -169,6 +189,56 @@ def collect_attachments(
|
||||
return removed
|
||||
|
||||
|
||||
class QuotaError(Exception):
|
||||
"""A store limit would be exceeded. Carries what to tell the user."""
|
||||
|
||||
def __init__(self, reason: str, detail: str) -> None:
|
||||
super().__init__(detail)
|
||||
self.reason = reason
|
||||
self.detail = detail
|
||||
|
||||
|
||||
def dir_usage(path: Path) -> tuple[int, int]:
|
||||
"""(bytes, files) below `path`, ignoring what we cannot read."""
|
||||
total = count = 0
|
||||
if not path.is_dir():
|
||||
return 0, 0
|
||||
for item in path.rglob("*"):
|
||||
try:
|
||||
if item.is_file():
|
||||
total += item.stat().st_size
|
||||
count += 1
|
||||
except OSError:
|
||||
continue
|
||||
return total, count
|
||||
|
||||
|
||||
def check_quota(path: Path, incoming: int, max_bytes: int, max_files: int) -> None:
|
||||
"""Raise QuotaError unless `incoming` more bytes fit.
|
||||
|
||||
Deliberately not an age rule. Files are never removed for getting old — that
|
||||
cost real plans twice — so the limit sits where a decision is being made
|
||||
anyway: at the moment somebody asks to store something new.
|
||||
"""
|
||||
import shutil
|
||||
|
||||
used, count = dir_usage(path)
|
||||
if count + 1 > max_files:
|
||||
raise QuotaError("too_many_files", f"{count} files already stored, the limit is {max_files}")
|
||||
if used + incoming > max_bytes:
|
||||
raise QuotaError(
|
||||
"quota_exceeded",
|
||||
f"{(used + incoming) // 1024 // 1024} MB would be stored, the limit is "
|
||||
f"{max_bytes // 1024 // 1024} MB",
|
||||
)
|
||||
try:
|
||||
free = shutil.disk_usage(str(path if path.is_dir() else path.parent)).free
|
||||
except OSError:
|
||||
return
|
||||
if free - incoming < MIN_FREE_BYTES:
|
||||
raise QuotaError("low_disk_space", f"only {free // 1024 // 1024} MB free on the disk")
|
||||
|
||||
|
||||
def plan_basename(url: Any) -> str:
|
||||
"""File name a stored plan_url points at ('' when there is none)."""
|
||||
if not isinstance(url, str) or not url:
|
||||
@@ -186,6 +256,14 @@ def plan_refs(cfg: dict[str, Any] | None) -> set[str]:
|
||||
return out
|
||||
|
||||
|
||||
def plan_by_space(cfg: dict[str, Any] | None) -> dict[str, str]:
|
||||
"""space id -> the plan file it references ('' when it has none)."""
|
||||
return {
|
||||
str(sp.get("id")): plan_basename(sp.get("plan_url"))
|
||||
for sp in (cfg or {}).get("spaces") or []
|
||||
}
|
||||
|
||||
|
||||
def is_plan_file(name: str) -> bool:
|
||||
"""Does this look like a plan we wrote: <space>.<ext> or <space>.<token>.<ext>?"""
|
||||
parts = name.split(".")
|
||||
@@ -212,7 +290,7 @@ def collect_plans(
|
||||
* a file the OLD configuration referenced and the new one does not was
|
||||
authoritative and has been superseded — remove it;
|
||||
* any other unreferenced plan file is a rejected or abandoned upload, and
|
||||
is removed only once PLAN_ORPHAN_TTL_S has passed: a fresh one may
|
||||
is KEPT — see the rule above; only a staging folder ages out: a fresh one may
|
||||
belong to a transaction that has not committed yet.
|
||||
|
||||
Never raises: the configuration is already stored by the time this runs, so
|
||||
@@ -220,7 +298,29 @@ def collect_plans(
|
||||
"""
|
||||
new_refs = plan_refs(new_cfg)
|
||||
old_refs = plan_refs(old_cfg)
|
||||
cutoff = (time.time() if now is None else now) - PLAN_ORPHAN_TTL_S
|
||||
# A commit knows what it superseded. The timer only knows what nothing
|
||||
# points at *right now*, and for a plan that is a reversible state: the
|
||||
# editor detaches the image when a space switches to "draw" and says the
|
||||
# file stays on disk. So the scheduled pass keeps anything belonging to a
|
||||
# space that still exists, and waits a month for the rest.
|
||||
# A space with NO plan_url has had its image detached — reversible, and the
|
||||
# editor promises the file stays. A space that HAS one is different: any
|
||||
# other file of its own is a superseded or rejected upload, so the short
|
||||
# rule is right for those. Getting this distinction wrong (protecting
|
||||
# nothing) destroyed two detached plans on 2026-07-28.
|
||||
# The short rule fits exactly one case: a space that HAS a plan, where any
|
||||
# other file of its own can only be a superseded or rejected upload.
|
||||
old_by_space = plan_by_space(old_cfg)
|
||||
new_by_space = plan_by_space(new_cfg)
|
||||
# A file that left the configuration tells us nothing on its own: replacing a
|
||||
# plan, detaching one and deleting a space all look identical from
|
||||
# `old_refs - new_refs`. Only the first is a deletion the user asked for
|
||||
# (HP-1465-01 — the guards below were written and then never reached,
|
||||
# because the code decided "superseded" before asking why).
|
||||
replaced = {
|
||||
name for space, name in old_by_space.items()
|
||||
if new_by_space.get(space) and new_by_space[space] != name
|
||||
}
|
||||
removed = 0
|
||||
try:
|
||||
items = sorted(plans_dir.iterdir()) if plans_dir.is_dir() else []
|
||||
@@ -233,12 +333,21 @@ def collect_plans(
|
||||
for item in items:
|
||||
if not item.is_file() or item.name in new_refs or not is_plan_file(item.name):
|
||||
continue
|
||||
superseded = item.name in old_refs
|
||||
try:
|
||||
stale = item.stat().st_mtime < cutoff
|
||||
except OSError:
|
||||
stale = False
|
||||
if not superseded and not stale:
|
||||
if item.name not in replaced:
|
||||
# PRODUCT RULE (owner's decision, 2026-07-28): a plan file we were
|
||||
# not told to delete is kept, however long it sits there. Detaching
|
||||
# is one click to undo and the editor says the image stays; deleting
|
||||
# a space is deliberate but the image was imported and may be
|
||||
# nowhere else. The errors are not symmetrical — unnecessary
|
||||
# megabytes can be removed by hand, a deleted file cannot be
|
||||
# brought back.
|
||||
#
|
||||
# There is deliberately no age rule here. An earlier version aged
|
||||
# out "rejected uploads" — a file of a space that has a plan, which
|
||||
# was never the plan — and that raced a save: the sweep deleted the
|
||||
# upload from the failed attempt while a retry was committing a
|
||||
# reference to it. A rule that can delete a file somebody is about
|
||||
# to point at is not worth the disk it reclaims.
|
||||
continue
|
||||
try:
|
||||
item.unlink()
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
@@ -42,6 +43,17 @@ class HouseplanData:
|
||||
# One lock for every load→modify→save cycle of both stores: prevents
|
||||
# lost updates from concurrent WS calls and makes the rev check atomic.
|
||||
write_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||
# A separate, narrower lock for the check-quota→write-file pair of an
|
||||
# upload. Without it N parallel uploads all measure the store BEFORE any
|
||||
# of them writes, and all pass a quota only one of them fits under
|
||||
# (HP-1490-02). Separate from write_lock so a slow directory scan does not
|
||||
# stall config/layout commits.
|
||||
upload_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||
# Collect files nothing references any more. Set during setup, which also
|
||||
# runs it once and schedules it daily. Exposed so it can be invoked
|
||||
# directly — a test that fakes a 24 h jump proves the timer fires, not that
|
||||
# the work happens, and those are different claims.
|
||||
sweep: Callable[[], Awaitable[None]] | None = None
|
||||
|
||||
|
||||
HouseplanConfigEntry = ConfigEntry[HouseplanData]
|
||||
|
||||
@@ -95,13 +95,41 @@ _TEXT = vol.All(str, vol.Length(max=MAX_TEXT))
|
||||
_TEXT_OR_NONE = vol.Any(None, _TEXT)
|
||||
_URL = vol.All(str, vol.Length(max=MAX_URL))
|
||||
|
||||
# Positions are normalised to the canvas (0..1). Allow generous slack for an
|
||||
# icon dragged past an edge, but not arbitrary magnitudes: any finite float
|
||||
# used to pass, and a single stored 1e100 stretched every client's view of the
|
||||
# space until the plan was invisible (HP-1500-03).
|
||||
_COORD = vol.All(_finite, vol.Range(min=-4.0, max=4.0))
|
||||
|
||||
POS_SCHEMA = vol.Schema(
|
||||
{vol.Required("x"): _finite, vol.Required("y"): _finite},
|
||||
{vol.Required("x"): _COORD, vol.Required("y"): _COORD},
|
||||
extra=vol.ALLOW_EXTRA, # v2 records carry the "s" key (space id)
|
||||
)
|
||||
LAYOUT_SCHEMA = vol.All(vol.Schema({str: POS_SCHEMA}), vol.Length(max=MAX_LAYOUT))
|
||||
|
||||
POINT = vol.All([_finite], vol.Length(min=2, max=2))
|
||||
# Geometry is normalised to the canvas (0..1). ±4 is generous slack for a
|
||||
# vertex nudged past an edge, not an envelope for arbitrary magnitudes: any
|
||||
# finite float used to pass here, and a single 1e100 room vertex stretched the
|
||||
# frame until the whole space was unviewable for every client (HP-1501-01) —
|
||||
# the exact failure HP-1500-03 closed for layout positions, one schema over.
|
||||
_GEOM = vol.All(_finite, vol.Range(min=-4.0, max=4.0))
|
||||
|
||||
# A SIZE is not a coordinate (HP-1502-01): SVG requires positive width/height,
|
||||
# and the clients divide by these. `view_box: [0,0,0,0]` passed the shared
|
||||
# validator and serialised into viewBox="0 0 0 0" — a blank plan on every
|
||||
# client. The floor is one thousandth of the canvas (1 render unit): far below
|
||||
# any real room, but keeps the maths finite.
|
||||
_EXTENT = vol.All(_finite, vol.Range(min=0.001, max=4.0))
|
||||
|
||||
|
||||
def _view_box(value):
|
||||
"""[x, y, w, h]: the first two are coordinates, the last two are sizes."""
|
||||
if not isinstance(value, (list, tuple)) or len(value) != 4:
|
||||
raise vol.Invalid("view_box must be [x, y, w, h]")
|
||||
return [_GEOM(value[0]), _GEOM(value[1]), _EXTENT(value[2]), _EXTENT(value[3])]
|
||||
|
||||
|
||||
POINT = vol.All([_GEOM], vol.Length(min=2, max=2))
|
||||
|
||||
|
||||
def _require_geometry(room: dict) -> dict:
|
||||
@@ -130,10 +158,10 @@ ROOM_SCHEMA = vol.All(
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
),
|
||||
),
|
||||
vol.Optional("x"): _finite,
|
||||
vol.Optional("y"): _finite,
|
||||
vol.Optional("w"): _finite,
|
||||
vol.Optional("h"): _finite,
|
||||
vol.Optional("x"): _GEOM,
|
||||
vol.Optional("y"): _GEOM,
|
||||
vol.Optional("w"): _EXTENT,
|
||||
vol.Optional("h"): _EXTENT,
|
||||
vol.Optional("poly"): vol.All([POINT], vol.Length(min=3, max=MAX_POLY_POINTS)),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
@@ -188,8 +216,16 @@ SPACE_SCHEMA = vol.Schema(
|
||||
vol.Required("title"): str,
|
||||
vol.Optional("settings"): SPACE_DISPLAY_SCHEMA,
|
||||
vol.Optional("plan_url"): vol.Any(str, None),
|
||||
vol.Required("aspect"): vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20)),
|
||||
vol.Required("view_box"): vol.All([_finite], vol.Length(min=4, max=4)),
|
||||
# The canvas is square since v1.48.0. What used to be the space's own
|
||||
# `aspect` is gone; the background image keeps its own proportions and
|
||||
# is centred, so only the IMAGE's ratio is stored. A stale tab may still
|
||||
# send the old field — it is dropped rather than trusted, because the
|
||||
# coordinates it comes with were normalised against a different box.
|
||||
vol.Remove("aspect"): object,
|
||||
vol.Optional("plan_aspect"): vol.Any(
|
||||
None, vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20))
|
||||
),
|
||||
vol.Required("view_box"): _view_box,
|
||||
vol.Required("rooms"): vol.All([ROOM_SCHEMA], vol.Length(max=MAX_ROOMS)),
|
||||
vol.Optional("decor"): vol.All([DECOR_SCHEMA], vol.Length(max=MAX_DECOR)),
|
||||
vol.Optional("openings"): vol.All([
|
||||
@@ -197,9 +233,9 @@ SPACE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("id"): str,
|
||||
vol.Required("type"): vol.Any("door", "window"),
|
||||
vol.Required("x"): _finite,
|
||||
vol.Required("y"): _finite,
|
||||
vol.Required("angle"): _finite,
|
||||
vol.Required("x"): _GEOM,
|
||||
vol.Required("y"): _GEOM,
|
||||
vol.Required("angle"): vol.All(_finite, vol.Range(min=-360.0, max=360.0)),
|
||||
vol.Required("length"): vol.All(vol.Coerce(float), vol.Range(min=0.001, max=1)),
|
||||
vol.Optional("contact"): vol.Any(str, None),
|
||||
vol.Optional("lock"): vol.Any(str, None),
|
||||
@@ -233,7 +269,12 @@ MARKER_SCHEMA = vol.Schema(
|
||||
vol.Optional("model"): _TEXT_OR_NONE,
|
||||
vol.Optional("link"): vol.Any(None, _URL),
|
||||
vol.Optional("description"): vol.Any(None, vol.All(str, vol.Length(max=MAX_DESCRIPTION))),
|
||||
vol.Optional("tap_action"): vol.Any("info", "more-info", "toggle", None),
|
||||
vol.Optional("tap_action"): vol.Any("info", "more-info", "toggle", "run", None),
|
||||
# the 'run' target: only the runnable domains, nothing else is callable
|
||||
vol.Optional("tap_target"): vol.Any(
|
||||
None, vol.All(str, vol.Length(max=MAX_TEXT), vol.Match(r"^(automation|script|scene)\.[A-Za-z0-9_]+$"))
|
||||
),
|
||||
vol.Optional("tap_confirm"): vol.Any(bool, None),
|
||||
vol.Optional("controls"): vol.Any(None, vol.All([_TEXT], vol.Length(max=MAX_CONTROLS))),
|
||||
vol.Optional("glow_radius_cm"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)), None),
|
||||
vol.Optional("is_light"): vol.Any(bool, None),
|
||||
|
||||
@@ -17,14 +17,19 @@ from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
from .const import (
|
||||
CONF_ADMIN_ONLY, DEFAULT_CONFIG,
|
||||
CONTENT_URL, FILES_DIR, MAX_SIGN_PATHS, PLANS_DIR, PLANS_URL,
|
||||
CONTENT_URL, FILES_DIR, MAX_PLANS_BYTES, MAX_PLANS_FILES, MAX_PLANS_LISTED,
|
||||
MAX_SIGN_PATHS,
|
||||
PLANS_DIR, PLANS_URL,
|
||||
)
|
||||
from .auth import may_write
|
||||
from .plans import collect_attachments, collect_plans, reserve_filename
|
||||
from .plans import (
|
||||
QuotaError, check_quota, collect_attachments, collect_plans, is_plan_file,
|
||||
plan_basename, plan_refs, reserve_filename,
|
||||
)
|
||||
from .store import HouseplanData, get_data, get_entry
|
||||
from .validation import (
|
||||
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_CONFIG_BYTES, MAX_PLAN_BYTES,
|
||||
PLAN_EXTENSIONS, POS_SCHEMA, valid_space_id,
|
||||
PLAN_EXTENSIONS, POS_SCHEMA, sanitize_filename, valid_space_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -36,11 +41,14 @@ def async_register(hass: HomeAssistant) -> None:
|
||||
"""Register the WS commands."""
|
||||
websocket_api.async_register_command(hass, ws_layout_get)
|
||||
websocket_api.async_register_command(hass, ws_layout_set)
|
||||
websocket_api.async_register_command(hass, ws_geometry_repair)
|
||||
websocket_api.async_register_command(hass, ws_layout_update)
|
||||
websocket_api.async_register_command(hass, ws_layout_delete)
|
||||
websocket_api.async_register_command(hass, ws_config_get)
|
||||
websocket_api.async_register_command(hass, ws_config_set)
|
||||
websocket_api.async_register_command(hass, ws_plan_set)
|
||||
websocket_api.async_register_command(hass, ws_plans_list)
|
||||
websocket_api.async_register_command(hass, ws_plans_delete)
|
||||
websocket_api.async_register_command(hass, ws_files_migrate)
|
||||
websocket_api.async_register_command(hass, ws_files_cleanup)
|
||||
websocket_api.async_register_command(hass, ws_content_sign)
|
||||
@@ -111,7 +119,8 @@ async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
)
|
||||
return
|
||||
new_rev = current_rev + 1
|
||||
await rt.store.async_save({"layout": msg["layout"], "rev": new_rev})
|
||||
await rt.store.async_save({**{k: v for k, v in data.items() if k not in ("layout", "rev")},
|
||||
"layout": msg["layout"], "rev": new_rev})
|
||||
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
|
||||
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
|
||||
|
||||
@@ -140,11 +149,100 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
# optimistic locking on layout/set meaningless — every drag reset the
|
||||
# counter to 0 (HP-1454-08)
|
||||
new_rev = int(data.get("rev", 0)) + 1
|
||||
await rt.store.async_save({"layout": layout, "rev": new_rev})
|
||||
await rt.store.async_save({**{k: v for k, v in data.items() if k not in ("layout", "rev")},
|
||||
"layout": layout, "rev": new_rev})
|
||||
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
|
||||
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/geometry/repair",
|
||||
vol.Required("space_id"): str,
|
||||
vol.Required("aspect"): vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20)),
|
||||
vol.Optional("dry_run"): bool,
|
||||
vol.Optional("undo"): bool,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_geometry_repair(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Re-apply the square-canvas transform to ONE space's layout, explicitly.
|
||||
|
||||
For installations that hit the v1.48/v1.49 crash window: the config write
|
||||
of the migration landed, the layout write did not, and the trigger fields
|
||||
were already gone — markers and labels of that space are stranded in the
|
||||
old coordinates with nothing able to tell (HP-1500-01). Nothing can be
|
||||
detected reliably after the fact, and re-running a transform on a layout
|
||||
that is already correct would corrupt it, so this NEVER runs by itself:
|
||||
an administrator names the space and its old aspect, may preview with
|
||||
`dry_run`, and gets a one-deep backup written in the same store write —
|
||||
`undo` restores it.
|
||||
"""
|
||||
if not _check_write(hass, connection):
|
||||
connection.send_error(msg["id"], "unauthorized", "Only administrators may repair the layout")
|
||||
return
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
from .geometry_migration import migrate_layout
|
||||
|
||||
space_id = msg["space_id"]
|
||||
if not valid_space_id(space_id):
|
||||
connection.send_error(msg["id"], "invalid_space_id", "space_id: only [a-z0-9_-], up to 64 characters")
|
||||
return
|
||||
async with rt.write_lock:
|
||||
data = await rt.store.async_load() or {}
|
||||
layout = data.get("layout") or {}
|
||||
current_rev = int(data.get("rev", 0))
|
||||
if msg.get("undo"):
|
||||
backup = data.get("repair_backup")
|
||||
if not isinstance(backup, dict) or backup.get("space") != space_id:
|
||||
connection.send_error(msg["id"], "no_backup", "No repair backup stored for this space")
|
||||
return
|
||||
restored = dict(layout)
|
||||
for key, pos in (backup.get("positions") or {}).items():
|
||||
restored[key] = pos
|
||||
new_rev = current_rev + 1
|
||||
await rt.store.async_save({"layout": restored, "rev": new_rev})
|
||||
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
|
||||
connection.send_result(msg["id"], {"ok": True, "rev": new_rev,
|
||||
"restored": len(backup.get("positions") or {})})
|
||||
return
|
||||
touched = {
|
||||
k: dict(v) for k, v in layout.items()
|
||||
if isinstance(v, dict) and str(v.get("s")) == space_id
|
||||
}
|
||||
if not touched:
|
||||
# A typo'd space id used to "succeed" with moved: 0 — and its
|
||||
# empty result REPLACED the one-deep backup, destroying the very
|
||||
# undo this endpoint promises (HP-1501-02). Nothing to move means
|
||||
# nothing to save: no write, no revision bump, the backup stays.
|
||||
connection.send_error(
|
||||
msg["id"], "nothing_to_repair",
|
||||
f"No stored positions belong to space '{space_id}'",
|
||||
)
|
||||
return
|
||||
preview = {k: dict(v) for k, v in touched.items()}
|
||||
migrate_layout(preview, {space_id: msg["aspect"]})
|
||||
if msg.get("dry_run"):
|
||||
connection.send_result(msg["id"], {
|
||||
"ok": True, "dry_run": True, "moved": len(preview),
|
||||
"before": touched, "after": preview,
|
||||
})
|
||||
return
|
||||
new_layout = {**layout, **preview}
|
||||
new_rev = current_rev + 1
|
||||
# the backup rides the same store write: either both are durable or
|
||||
# neither — the deletion-shy rules of this project apply to positions
|
||||
# too
|
||||
await rt.store.async_save({
|
||||
"layout": new_layout, "rev": new_rev,
|
||||
"repair_backup": {"space": space_id, "positions": touched},
|
||||
})
|
||||
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
|
||||
connection.send_result(msg["id"], {"ok": True, "rev": new_rev, "moved": len(preview)})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/files/migrate",
|
||||
@@ -215,6 +313,105 @@ async def ws_files_migrate(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
connection.send_result(msg["id"], {"ok": True, "mapping": mapping, "copied": len(mapping)})
|
||||
|
||||
|
||||
@websocket_api.websocket_command({vol.Required("type"): "houseplan/plans/list"})
|
||||
@websocket_api.async_response
|
||||
async def ws_plans_list(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Plan images on the server, with what still uses them.
|
||||
|
||||
Files are never removed for being unreferenced (docs/SCOPE.md), which only
|
||||
works as a policy if the user can see them: detaching a plan keeps the
|
||||
image, and this is how it gets picked up again — or deleted on purpose.
|
||||
"""
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
stored = await rt.config_store.async_load() or {}
|
||||
cfg = stored.get("config") or {}
|
||||
used: dict[str, list[str]] = {}
|
||||
for space in cfg.get("spaces") or []:
|
||||
name = plan_basename(space.get("plan_url"))
|
||||
if name:
|
||||
used.setdefault(name, []).append(space.get("title") or space.get("id") or "?")
|
||||
|
||||
plans_dir = Path(hass.config.path(PLANS_DIR))
|
||||
|
||||
def _scan() -> list[dict[str, Any]]:
|
||||
out: list[dict[str, Any]] = []
|
||||
if not plans_dir.is_dir():
|
||||
return out
|
||||
for item in sorted(plans_dir.iterdir()):
|
||||
if not item.is_file() or not is_plan_file(item.name):
|
||||
continue
|
||||
try:
|
||||
st = item.stat()
|
||||
except OSError:
|
||||
continue
|
||||
out.append({
|
||||
"name": item.name,
|
||||
"url": f"{CONTENT_URL}/plans/_/{item.name}",
|
||||
"size": st.st_size,
|
||||
"modified": int(st.st_mtime),
|
||||
"used_by": used.get(item.name, []),
|
||||
})
|
||||
out.sort(key=lambda x: -x["modified"])
|
||||
return out
|
||||
|
||||
plans = await hass.async_add_executor_job(_scan)
|
||||
# newest first and capped: a folder with thousands of files would otherwise
|
||||
# become one huge message, one huge list and a signing request per row
|
||||
connection.send_result(
|
||||
msg["id"], {"plans": plans[:MAX_PLANS_LISTED], "total": len(plans)}
|
||||
)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/plans/delete",
|
||||
vol.Required("name"): str,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_plans_delete(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Delete a plan image because the user asked — the only way one goes.
|
||||
|
||||
Refuses while a space still references it: the answer to "can I delete this"
|
||||
is the stored configuration's, not the client's.
|
||||
"""
|
||||
if not _check_write(hass, connection):
|
||||
connection.send_error(msg["id"], "unauthorized", "Only administrators may delete plans")
|
||||
return
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
name = sanitize_filename(msg["name"])
|
||||
if not is_plan_file(name):
|
||||
connection.send_error(msg["id"], "invalid_name", "Not a plan file")
|
||||
return
|
||||
|
||||
async with rt.write_lock:
|
||||
stored = await rt.config_store.async_load() or {}
|
||||
cfg = stored.get("config") or {}
|
||||
if name in plan_refs(cfg):
|
||||
connection.send_error(
|
||||
msg["id"], "in_use", "A space still uses this plan — detach it first"
|
||||
)
|
||||
return
|
||||
path = Path(hass.config.path(PLANS_DIR)) / name
|
||||
|
||||
def _rm() -> bool:
|
||||
try:
|
||||
path.unlink()
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
except OSError as err:
|
||||
_LOGGER.warning("House Plan: could not delete %s: %s", path, err)
|
||||
return False
|
||||
|
||||
removed = await hass.async_add_executor_job(_rm)
|
||||
connection.send_result(msg["id"], {"ok": True, "removed": removed})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/content/sign",
|
||||
@@ -263,34 +460,68 @@ async def ws_content_sign(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_files_cleanup(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Delete a marker's file folder — called only AFTER the config is committed."""
|
||||
"""Drop a marker folder's leftovers after its files moved elsewhere.
|
||||
|
||||
Called after a rebind: the files were copied to the new marker id and the
|
||||
config that references them is committed, so the source folder is spent.
|
||||
|
||||
It used to `rmtree` the folder on the client's word alone. Two ways that
|
||||
ends badly: a partial copy leaves some urls still pointing INTO this folder
|
||||
(the migration deliberately does not rewrite those), and a wrong or stale
|
||||
id from any client deletes a live marker's attachments outright. So the
|
||||
server checks for itself — under the config lock — and removes only files
|
||||
the stored configuration does not reference. Same principle as the
|
||||
collector: a client may say what it no longer needs, never what may go.
|
||||
"""
|
||||
if not _check_write(hass, connection):
|
||||
connection.send_error(msg["id"], "unauthorized", "Only administrators may edit files")
|
||||
return
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
from .const import FILES_DIR
|
||||
from .plans import attachment_refs
|
||||
from .validation import sanitize_marker_id
|
||||
|
||||
mid = sanitize_marker_id(msg["marker_id"])
|
||||
if not mid:
|
||||
connection.send_result(msg["id"], {"ok": True, "removed": False})
|
||||
return
|
||||
base = Path(hass.config.path(FILES_DIR)).resolve()
|
||||
target = (base / mid).resolve()
|
||||
if not str(target).startswith(str(base)) or target == base:
|
||||
connection.send_result(msg["id"], {"ok": True, "removed": False})
|
||||
target = (base / mid).resolve() if mid else base
|
||||
if not mid or not str(target).startswith(str(base)) or target == base:
|
||||
connection.send_result(msg["id"], {"ok": True, "removed": 0, "kept": 0})
|
||||
return
|
||||
|
||||
def _rm() -> bool:
|
||||
if not target.is_dir():
|
||||
return False
|
||||
shutil.rmtree(target, ignore_errors=True)
|
||||
return True
|
||||
async with rt.write_lock:
|
||||
stored = await rt.config_store.async_load() or {}
|
||||
refs = attachment_refs(stored.get("config") or {})
|
||||
|
||||
removed = await hass.async_add_executor_job(_rm)
|
||||
connection.send_result(msg["id"], {"ok": True, "removed": removed})
|
||||
def _rm() -> tuple[int, int]:
|
||||
if not target.is_dir():
|
||||
return 0, 0
|
||||
removed = kept = 0
|
||||
for item in sorted(target.iterdir()):
|
||||
if not item.is_file():
|
||||
continue
|
||||
if f"{mid}/{item.name}" in refs:
|
||||
kept += 1
|
||||
continue
|
||||
try:
|
||||
item.unlink()
|
||||
removed += 1
|
||||
except OSError as err:
|
||||
_LOGGER.warning("House Plan: could not remove %s: %s", item, err)
|
||||
if not kept:
|
||||
try:
|
||||
target.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
return removed, kept
|
||||
|
||||
removed, kept = await hass.async_add_executor_job(_rm)
|
||||
if kept:
|
||||
_LOGGER.info(
|
||||
"House Plan: kept %s file(s) in %s — the configuration still references them", kept, mid
|
||||
)
|
||||
connection.send_result(msg["id"], {"ok": True, "removed": removed, "kept": kept})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
@@ -315,7 +546,8 @@ async def ws_layout_delete(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
if msg["device_id"] in layout:
|
||||
del layout[msg["device_id"]]
|
||||
new_rev = int(data.get("rev", 0)) + 1
|
||||
await rt.store.async_save({"layout": layout, "rev": new_rev})
|
||||
await rt.store.async_save({**{k: v for k, v in data.items() if k not in ("layout", "rev")},
|
||||
"layout": layout, "rev": new_rev})
|
||||
if new_rev is not None:
|
||||
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
|
||||
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
|
||||
@@ -337,6 +569,46 @@ async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
|
||||
|
||||
|
||||
def _internal_plan_names(config: dict[str, Any]) -> set[str]:
|
||||
"""Plan file names a configuration names through OUR urls.
|
||||
|
||||
Only `/api/houseplan/content/plans/_/<name>` and the legacy static path
|
||||
count. Anything else belongs to the user and may point wherever they like.
|
||||
"""
|
||||
out: set[str] = set()
|
||||
for space in (config or {}).get("spaces") or []:
|
||||
url = space.get("plan_url")
|
||||
if not isinstance(url, str) or not url:
|
||||
continue
|
||||
if not (url.startswith(CONTENT_URL + "/plans/") or url.startswith(PLANS_URL + "/")):
|
||||
continue
|
||||
name = plan_basename(url)
|
||||
if name:
|
||||
out.add(name)
|
||||
return out
|
||||
|
||||
|
||||
def _missing_internal_plans(
|
||||
plans_dir: Path, config: dict[str, Any], previous: dict[str, Any] | None = None
|
||||
) -> set[str]:
|
||||
"""Newly named plan files that are not on disk.
|
||||
|
||||
Guards the pick-then-save window: another client may delete a plan between
|
||||
the moment this one chose it and the moment it saves, which would otherwise
|
||||
store a url with nothing behind it (HP-1470-02).
|
||||
|
||||
A name the stored configuration already carries is deliberately let through.
|
||||
It is already broken — repairs says so — and refusing the write would lock
|
||||
the owner out of every other edit, including the one that detaches it.
|
||||
"""
|
||||
known = _internal_plan_names(previous or {})
|
||||
return {
|
||||
name
|
||||
for name in _internal_plan_names(config)
|
||||
if name not in known and not (plans_dir / name).is_file()
|
||||
}
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/config/set",
|
||||
@@ -386,6 +658,23 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
f"Configuration was changed in another window (rev {current_rev} != {msg['expected_rev']})",
|
||||
)
|
||||
return
|
||||
# An internal plan url must name a file that exists. The card can pick a
|
||||
# plan and then delete it from the same dialog, and two clients can do
|
||||
# the same thing in either order — the lock serialises them but says
|
||||
# nothing about whether the file survived (HP-1470-02). External and
|
||||
# legacy urls are not ours to check and are left alone.
|
||||
missing = await hass.async_add_executor_job(
|
||||
_missing_internal_plans,
|
||||
Path(hass.config.path(PLANS_DIR)),
|
||||
msg["config"],
|
||||
data.get("config"),
|
||||
)
|
||||
if missing:
|
||||
connection.send_error(
|
||||
msg["id"], "missing_plan",
|
||||
"Plan file no longer exists: " + ", ".join(sorted(missing)),
|
||||
)
|
||||
return
|
||||
new_rev = current_rev + 1
|
||||
await rt.config_store.async_save({"config": msg["config"], "rev": new_rev})
|
||||
# Still holding the lock: the file system is not part of the store's
|
||||
@@ -446,10 +735,11 @@ async def ws_plan_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> N
|
||||
# deleted here (review R2-1). The old name stays readable, so a config write
|
||||
# that is later rejected — revision conflict, validation, lost connection —
|
||||
# leaves the stored plan exactly as it was. The card calls
|
||||
# nothing here; the superseded file is collected by `config/set` itself,
|
||||
# inside the write lock, once a revision that no longer references it has
|
||||
# been accepted (review R3-1). A crash in between leaves an orphan, which
|
||||
# the same collector removes on a later commit once it is old enough.
|
||||
# nothing here; the file a commit REPLACES is collected by `config/set`
|
||||
# itself, inside the write lock (review R3-1). An upload that never gets
|
||||
# committed is not collected at all — it is offered back in the space
|
||||
# dialog's "already uploaded" list, where the user can attach or delete it.
|
||||
# Every attempt to age these out ended in data loss or a race (v1.46.4-6).
|
||||
#
|
||||
# `.` separates the id from the token because a space id cannot contain one
|
||||
# (SPACE_ID_RE), so "<space>.<token>.<ext>" can never be confused with the
|
||||
@@ -458,9 +748,22 @@ async def ws_plan_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> N
|
||||
name = f"{space_id}.{secrets.token_hex(4)}.{msg['ext']}"
|
||||
path = plans_dir / name
|
||||
|
||||
def _write() -> None:
|
||||
def _check_and_write() -> None:
|
||||
# one executor job for the pair, under upload_lock: the measurement
|
||||
# is only a bound if nothing else writes between it and our write
|
||||
# (HP-1490-02). A failed write reserves nothing — the file either
|
||||
# exists and is counted by the next scan, or does not and is not.
|
||||
check_quota(plans_dir, len(raw), MAX_PLANS_BYTES, MAX_PLANS_FILES)
|
||||
plans_dir.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(raw)
|
||||
|
||||
await hass.async_add_executor_job(_write)
|
||||
data = _runtime(hass, connection, msg["id"])
|
||||
if data is None:
|
||||
return
|
||||
async with data.upload_lock:
|
||||
try:
|
||||
await hass.async_add_executor_job(_check_and_write)
|
||||
except QuotaError as err:
|
||||
connection.send_error(msg["id"], err.reason, err.detail)
|
||||
return
|
||||
connection.send_result(msg["id"], {"ok": True, "url": f"{CONTENT_URL}/plans/_/{name}"})
|
||||
|
||||
@@ -33,7 +33,7 @@ const res = await page.evaluate(async () => {
|
||||
const a = devs[0], b = devs[1];
|
||||
const pa = c._pos(a);
|
||||
// поставим b на тот же Y, начнём drag
|
||||
c._layout = { ...c._layout, [b.id]: { s: c._space, x: (pa.x + g * 12) / 1000, y: pa.y / (1000 / (c._curSpaceCfg.aspect || 1)) } };
|
||||
c._layout = { ...c._layout, [b.id]: { s: c._space, x: (pa.x + g * 12) / 1000, y: pa.y / 1000 } };
|
||||
c._drag = { id: b.id, sx: 0, sy: 0, ox: 0, oy: 0, moved: true };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
out.devGuide = guides() >= 1;
|
||||
@@ -42,7 +42,7 @@ const res = await page.evaluate(async () => {
|
||||
// 4) подложка: рисование прямоугольника с углом на одном X с углом другой фигуры
|
||||
c._setMode('decor'); await c.updateComplete;
|
||||
c._curSpaceCfg.decor = [{ id: 'd1', kind: 'rect', x: 0.2, y: 0.2, w: 0.1, h: 0.1, color: '#ff0000', width: 3 }];
|
||||
const W = 1000, H = 1000 / (c._curSpaceCfg.aspect || 1);
|
||||
const W = 1000, H = 1000; // square canvas
|
||||
c._decorDraft = { kind: 'rect', a: [0.5 * W, 0.5 * H], b: [0.2 * W, 0.6 * H], pid: 9 }; // b.x == углу d1
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
out.decorGuide = guides() >= 1;
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// Аудит v1.49.0: HP-1490-03 (редакторы видят весь холст) и HP-1490-04
|
||||
// (Save ждёт пропорции выбранного сохранённого плана, старые не наследуются).
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 900, height: 1000 }, 1);
|
||||
const out = {};
|
||||
|
||||
// ---- HP-1490-03: content-fit только в просмотре -------------------------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
// рукописное пространство: одна маленькая комната в центре квадрата
|
||||
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
|
||||
cfg.spaces[0].plan_url = null; cfg.spaces[0].plan_aspect = null;
|
||||
cfg.spaces[0].rooms = [{ id: 'r1', name: 'One', area: 'living_room',
|
||||
poly: [[0.4, 0.4], [0.6, 0.4], [0.6, 0.6], [0.4, 0.6]] }];
|
||||
c._serverCfg = cfg; c._model = null; c._view = null; c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
await new Promise((r) => requestAnimationFrame(r));
|
||||
const inView = c._baseVb();
|
||||
o.viewIsContentFit = inView[2] < 999; // меньше холста (устройства тоже содержимое)
|
||||
c._setMode('plan'); await c.updateComplete;
|
||||
await new Promise((r) => requestAnimationFrame(r));
|
||||
const inPlan = c._baseVb();
|
||||
o.editorSeesWholeCanvas = inPlan[2] === 1000 && inPlan[3] === 1000;
|
||||
const v = c._viewOr(c._baseVb());
|
||||
o.editorViewCoversCanvas = v.w >= 999; // старый cropped view не пережил смену
|
||||
// в редакторе можно ткнуть в дальний угол холста
|
||||
o.canReachFarCorner = (() => {
|
||||
const stage = (c.shadowRoot || c.renderRoot).querySelector('.stage');
|
||||
const pt = c._screenToVb(stage.clientWidth - 1, stage.clientHeight - 1);
|
||||
return pt[0] > 900 || pt[1] > 900;
|
||||
})();
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
await new Promise((r) => requestAnimationFrame(r));
|
||||
const back = c._baseVb();
|
||||
o.contentFitRestored = back[2] < 999;
|
||||
return o;
|
||||
}));
|
||||
|
||||
// ---- HP-1490-04: Save ждёт aspect --------------------------------------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const base = c.hass.callWS;
|
||||
let saved = null;
|
||||
let signDelay = 500; // подпись приходит поздно
|
||||
c.hass = { ...c.hass, callWS: async (m) => {
|
||||
if (m.type === 'houseplan/plans/list') return { plans: [
|
||||
{ name: 'wide.svg', url: '/api/houseplan/content/plans/_/wide.svg', size: 10, modified: 1, used_by: [] },
|
||||
] };
|
||||
if (m.type === 'houseplan/content/sign') {
|
||||
await new Promise((r) => setTimeout(r, signDelay));
|
||||
const urls = {}; for (const p of m.paths) urls[p] = '/assets/wide.svg'; return { urls };
|
||||
}
|
||||
if (m.type === 'houseplan/config/set') { saved = m.config; return { rev: (c._cfgRev || 0) + 1 }; }
|
||||
if (m.type === 'houseplan/config/get') return { config: saved || c._serverCfg, rev: c._cfgRev || 0 };
|
||||
return base(m);
|
||||
} };
|
||||
// страница отдаёт /assets/wide.svg размером 800x200 (создан рядом)
|
||||
c._openSpaceDialog('edit', 'f1'); await c.updateComplete;
|
||||
c._spaceDialog = { ...c._spaceDialog, source: 'file', planUrl: null, planFile: null };
|
||||
await c.updateComplete;
|
||||
c._useServerPlan('/api/houseplan/content/plans/_/wide.svg');
|
||||
o.oldAspectCleared = c._spaceDialog.savedAspect === undefined;
|
||||
// Save сразу, до прихода подписи
|
||||
const p = c._saveSpaceDialog();
|
||||
await p;
|
||||
o.savedUrl = saved?.spaces?.[0]?.plan_url === '/api/houseplan/content/plans/_/wide.svg';
|
||||
const a = saved?.spaces?.[0]?.plan_aspect;
|
||||
o.savedAspectIsReal = Math.abs((a || 0) - 4) < 0.01; // 800x200
|
||||
o.notTheOldAspect = a !== 1.25;
|
||||
return o;
|
||||
}));
|
||||
|
||||
await finish(browser, checkAll(out));
|
||||
@@ -11,7 +11,7 @@ const res = await page.evaluate(async () => {
|
||||
document.body.appendChild(c1);
|
||||
c1.hass = { language:'en', locale:{language:'en'}, devices:{}, entities:{}, areas:{}, states:{},
|
||||
callWS: async (m) => m.type==='houseplan/config/get'
|
||||
? { config:{ spaces:[{ id:'s1', title:'Empty', plan_url:null, aspect:1.4, view_box:[0,0,1,1], rooms:[], segments:[] }], markers:[], settings:{} }, rev:1 }
|
||||
? { config:{ spaces:[{ id:'s1', title:'Empty', plan_url:null, view_box:[0,0,1,1], rooms:[], segments:[] }], markers:[], settings:{} }, rev:1 }
|
||||
: { layout:{} },
|
||||
connection:{ subscribeEvents: async()=>()=>{} } };
|
||||
await new Promise(r=>setTimeout(r,150));
|
||||
@@ -31,7 +31,7 @@ const res = await page.evaluate(async () => {
|
||||
devices:{ d1:{ id:'d1', name: evil, model:'M<script>1</script>', area_id:'a1', identifiers:[['x','1']] } },
|
||||
entities:{}, areas:{ a1:{ area_id:'a1', name:'A1' } }, states:{},
|
||||
callWS: async (m) => m.type==='houseplan/config/get'
|
||||
? { config:{ spaces:[{ id:'s1', title:'S', plan_url:null, aspect:1, view_box:[0,0,1,1],
|
||||
? { config:{ spaces:[{ id:'s1', title:'S', plan_url:null, view_box:[0,0,1,1],
|
||||
rooms:[{ id:'r1', name: evil, area:'a1', poly:[[0.1,0.1],[0.9,0.1],[0.9,0.9],[0.1,0.9]] }], segments:[] }], markers:[], settings:{} }, rev:1 }
|
||||
: { layout:{} },
|
||||
connection:{ subscribeEvents: async()=>()=>{} } };
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// На телефоне в редакторах не работали зум и навигация жестами: pointerdown
|
||||
// сцены выходил сразу при _markup, так что пинч и пан не начинались вовсе.
|
||||
// Рисование кликается, жесты двигаются — они совместимы; палец с движением
|
||||
// панорамирует, два пальца зумируют, отпускание после жеста не рисует точку.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const out = {};
|
||||
|
||||
const pd = (c, id, x, y) => c._stagePointerDown({ pointerId: id, clientX: x, clientY: y, target: c._stageEl, preventDefault() {} });
|
||||
const pm = (c, id, x, y) => c._stagePointerMove({ pointerId: id, clientX: x, clientY: y });
|
||||
const pu = (c, id, x, y) => c._stagePointerUp({ pointerId: id, clientX: x, clientY: y });
|
||||
|
||||
// --- пинч-зум в редакторе плана -----------------------------------------
|
||||
out.pinchZoomsInPlanEditor = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
c._setMode('plan');
|
||||
const pd = (id, x, y) => c._stagePointerDown({ pointerId: id, clientX: x, clientY: y, target: c._stageEl, preventDefault() {} });
|
||||
const pm = (id, x, y) => c._stagePointerMove({ pointerId: id, clientX: x, clientY: y });
|
||||
const pu = (id, x, y) => c._stagePointerUp({ pointerId: id, clientX: x, clientY: y });
|
||||
c._resetZoom();
|
||||
const z0 = c._zoom;
|
||||
pd(1, 300, 300); pd(2, 400, 300); // два пальца
|
||||
pm(1, 250, 300); pm(2, 450, 300); // разводим
|
||||
const zoomed = c._zoom > z0 * 1.5;
|
||||
pu(1, 250, 300); pu(2, 450, 300);
|
||||
return zoomed && c._path.length === 0;
|
||||
});
|
||||
|
||||
// --- пан одним пальцем в редакторе, точка не рисуется --------------------
|
||||
out.panWorksAndDoesNotDraw = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const pd = (id, x, y) => c._stagePointerDown({ pointerId: id, clientX: x, clientY: y, target: c._stageEl, preventDefault() {} });
|
||||
const pm = (id, x, y) => c._stagePointerMove({ pointerId: id, clientX: x, clientY: y });
|
||||
const pu = (id, x, y) => c._stagePointerUp({ pointerId: id, clientX: x, clientY: y });
|
||||
const st = c._stageEl;
|
||||
c._zoomAt(st.clientWidth / 2, st.clientHeight / 2, 3); // есть куда панорамировать
|
||||
const v0 = { ...c._view };
|
||||
pd(3, 300, 300);
|
||||
pm(3, 380, 340); pm(3, 420, 360);
|
||||
const panned = Math.abs(c._view.x - v0.x) > 1 || Math.abs(c._view.y - v0.y) > 1;
|
||||
const suppressed = c._suppressClick === true;
|
||||
c._markupClick({ composedPath: () => [], clientX: 420, clientY: 360 }); // синтезированный click после пана
|
||||
const noDot = c._path.length === 0;
|
||||
pu(3, 420, 360);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
return panned && suppressed && noDot;
|
||||
});
|
||||
|
||||
// --- обычный клик без движения по-прежнему рисует ------------------------
|
||||
out.tapStillDraws = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const pd = (id, x, y) => c._stagePointerDown({ pointerId: id, clientX: x, clientY: y, target: c._stageEl, preventDefault() {} });
|
||||
const pu = (id, x, y) => c._stagePointerUp({ pointerId: id, clientX: x, clientY: y });
|
||||
c._resetZoom();
|
||||
c._tool = 'draw';
|
||||
const before = c._path.length;
|
||||
pd(4, 300, 300); pu(4, 300, 300);
|
||||
const st = c._stageEl.getBoundingClientRect();
|
||||
c._markupClick({ composedPath: () => [], clientX: st.left + 200, clientY: st.top + 200 });
|
||||
const drew = c._path.length > before;
|
||||
c._path = []; c._setMode('view');
|
||||
return drew;
|
||||
});
|
||||
|
||||
await finish(browser, checkAll(out));
|
||||
@@ -9,10 +9,13 @@ const res = await page.evaluate(async () => {
|
||||
const btn = sr().querySelector('.rlgearbtn');
|
||||
out.gearButtonShown = !!btn;
|
||||
const cs = btn ? getComputedStyle(btn) : null;
|
||||
out.gearReadable = cs ? parseFloat(cs.fontSize) >= 10 && cs.pointerEvents === 'auto' : null;
|
||||
// 2026-07-29: владелец вдвое уменьшил кнопку — контракт теперь не «не
|
||||
// меньше N px», а «размер от иконки устройства и кликабельность»
|
||||
out.gearReadable = cs ? parseFloat(cs.fontSize) > 0 && cs.pointerEvents === 'auto' : null;
|
||||
out.gearHasLabel = btn ? btn.textContent.trim().length > 0 : null;
|
||||
const box = btn?.getBoundingClientRect();
|
||||
out.gearTapTarget = box ? box.height >= 18 && box.width >= 40 : null;
|
||||
// (в редакторе плана .dev скрыты display:none — сравнивать не с чем)
|
||||
out.gearTapTarget = box ? box.height > 6 && box.height < 40 : null;
|
||||
btn.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
|
||||
await c.updateComplete;
|
||||
out.gearOpensDialog = c._roomDialog === true && !!c._roomEditId;
|
||||
|
||||
+3
-4
@@ -44,7 +44,7 @@ const res = await page.evaluate(async () => {
|
||||
const c2 = c._roomCenter(r2);
|
||||
const poly1 = r1.poly || [[r1.x, r1.y], [r1.x + r1.w, r1.y], [r1.x + r1.w, r1.y + r1.h], [r1.x, r1.y + r1.h]];
|
||||
// общая стена вертикальная — дверь ставим на неё
|
||||
const H = 1000 / (c._curSpaceCfg.aspect || 1);
|
||||
const H = 1000; // square canvas
|
||||
const doorPt = (() => {
|
||||
let best = null, bd = 1e9;
|
||||
for (const [x, y] of [[550, 150], [550, 200], [550, 250]]) {
|
||||
@@ -57,9 +57,8 @@ const res = await page.evaluate(async () => {
|
||||
...s, openings: [{ id: 'gd', type: 'door', x: doorPt[0] / 1000, y: doorPt[1] / H, angle: 90, length: 0.09 }] })) };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
// источник детерминированно ставим в центр r1 (двигаем реальную включённую лампу)
|
||||
const aspect = c._curSpaceCfg.aspect || 1;
|
||||
const c1 = c._roomCenter(r1);
|
||||
c._layout = { ...c._layout, [litLight.id]: { s: spId, x: c1[0] / 1000, y: c1[1] / (1000 / aspect) } };
|
||||
c._layout = { ...c._layout, [litLight.id]: { s: spId, x: c1[0] / 1000, y: c1[1] / 1000 } };
|
||||
// радиус 6 м, чтобы дверь заведомо была в зоне досягаемости
|
||||
c._serverCfg = { ...c._serverCfg, settings: { ...(c._serverCfg.settings || {}), glow_radius_cm: 600 } };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
@@ -73,7 +72,7 @@ const res = await page.evaluate(async () => {
|
||||
const minX = Math.min(...poly1.map((p) => p[0]));
|
||||
const yMid = (Math.min(...poly1.map((p) => p[1])) + Math.max(...poly1.map((p) => p[1]))) / 2;
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== spId ? s : ({
|
||||
...s, openings: [{ id: 'gd2', type: 'door', x: minX / 1000, y: yMid / (1000 / aspect), angle: 90, length: 0.09 }] })) };
|
||||
...s, openings: [{ id: 'gd2', type: 'door', x: minX / 1000, y: yMid / 1000, angle: 90, length: 0.09 }] })) };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const clipEls2 = [...sr().querySelectorAll('defs clipPath[id^="hp-glowclip"]')];
|
||||
out.entranceNoSector = clipEls2.every((cp) => cp.querySelectorAll('path').length === 1);
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
// docs/FILTERING.md: скрытие — явная галка. Конфиг материализуется сеятелем
|
||||
// (filter_seeded), галка в диалоге у всех устройств, «Показать скрытые» —
|
||||
// локальный режим редактора, скрытые рисуются призраками и только там.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const out = {};
|
||||
|
||||
// --- сеятель материализует конфиг при загрузке ---------------------------
|
||||
out.configSeeded = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const t0 = Date.now();
|
||||
while (!c._serverCfg?.settings?.filter_seeded && Date.now() - t0 < 3000) {
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
}
|
||||
return c._serverCfg?.settings?.filter_seeded === true;
|
||||
});
|
||||
|
||||
// --- галка прячет, счётчик не считает, призрак только в редакторе --------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const visibleIds = () => [...sr().querySelectorAll('.dev')].length;
|
||||
const before = visibleIds();
|
||||
|
||||
const d = c._devices.find((x) => x.space === 'f1' && x.bindingKind === 'device');
|
||||
c._serverCfg.markers = c._serverCfg.markers || [];
|
||||
c._serverCfg.markers.push({ id: d.id, binding: 'device:' + d.bindingRef, hidden: true });
|
||||
c._cfgEpoch++; c._regSignature = '';
|
||||
c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
|
||||
|
||||
o.hiddenNotRendered = visibleIds() === before - 1;
|
||||
const built = c._devices.find((x) => x.id === d.id);
|
||||
o.stillBuilt = !!built && built.hidden === true;
|
||||
o.countExcludesHidden = (sr().querySelector('.count')?.textContent || '').includes(String(before - 1));
|
||||
|
||||
// в просмотре тумблера нет эффекта — призраки только в редакторе устройств
|
||||
c._showHidden = true; c.requestUpdate(); await c.updateComplete;
|
||||
o.noGhostsInView = !sr().querySelector('.dev.ghost');
|
||||
c._setMode('devices'); c.requestUpdate(); await c.updateComplete;
|
||||
o.ghostInEditor = !!sr().querySelector('.dev.ghost');
|
||||
// призрак — конфигурация, не статус: ни жёлтого, ни unavail, ни тревоги
|
||||
const g = sr().querySelector('.dev.ghost');
|
||||
o.ghostHasNoState = !!g && !g.classList.contains('on') && !g.classList.contains('open')
|
||||
&& !g.classList.contains('unavail') && !g.classList.contains('alarm');
|
||||
// и он синий, а не тёмный — отличим от недоступного устройства
|
||||
o.ghostIsBlue = !!g && getComputedStyle(g).borderStyle.includes('dashed')
|
||||
&& getComputedStyle(g).borderColor !== 'rgb(255, 255, 255)';
|
||||
// тумблер локальный: конфиг не трогается
|
||||
o.toggleIsLocal = c._serverCfg.settings.show_all === undefined;
|
||||
|
||||
// --- галка в диалоге у авто-устройства, кнопки «Удалить» нет -----------
|
||||
const ghost = c._devices.find((x) => x.id === d.id);
|
||||
c._openMarkerDialog(ghost); await c.updateComplete;
|
||||
o.checkboxOn = c._markerDialog?.hideFromPlan === true;
|
||||
o.noDeleteForAuto = !sr().querySelector('.dialog .btn.danger');
|
||||
// снимаем галку и сохраняем — маркер остаётся с hidden:false (анти-повтор)
|
||||
c._markerDialog = { ...c._markerDialog, hideFromPlan: false };
|
||||
await c._saveMarker(); await c.updateComplete;
|
||||
const m = (c._serverCfg.markers || []).find((x) => x.binding === 'device:' + d.bindingRef);
|
||||
o.untickKeepsMarker = !!m && m.hidden === false;
|
||||
const back = c._devices.find((x) => x.id === d.id) || c._devices.find((x) => x.bindingRef === d.bindingRef);
|
||||
o.deviceVisibleAgain = !!back && !back.hidden;
|
||||
|
||||
// --- у виртуального кнопка «Удалить» есть -------------------------------
|
||||
c._openMarkerDialog(); await c.updateComplete;
|
||||
c._markerDialog = { ...c._markerDialog, name: 'Тест', binding: 'virtual' };
|
||||
await c._saveMarker(); await c.updateComplete;
|
||||
const virt = c._devices.find((x) => x.virtual);
|
||||
c._openMarkerDialog(virt); await c.updateComplete;
|
||||
o.deleteForVirtual = !!sr().querySelector('.dialog .btn.danger');
|
||||
c._markerDialog = null; c._setMode('view');
|
||||
return o;
|
||||
}));
|
||||
|
||||
// --- HP-1510-02: призрак не показывает live-значения ----------------------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
// датчик с числом + display:value, скрыт
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'sensor.power_meter': { state: '42', attributes: { unit_of_measurement: 'kW' } } } };
|
||||
c._serverCfg.markers = c._serverCfg.markers || [];
|
||||
c._serverCfg.markers.push({ id: 'pm', binding: 'entity:sensor.power_meter',
|
||||
display: 'value', hidden: true, space: 'f1' });
|
||||
c._cfgEpoch++; c._regSignature = '';
|
||||
c._maybeRebuildDevices();
|
||||
c._setMode('devices'); c._showHidden = true;
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const ghosts = [...sr().querySelectorAll('.dev.ghost')];
|
||||
const pm = ghosts.find((g) => g.textContent.includes('42')) || null;
|
||||
o.ghostHidesValue = pm === null; // «42 kW» не отрисован
|
||||
o.ghostNoLiveBadges = ghosts.every((g) =>
|
||||
!g.querySelector('.valtext') && !g.querySelector('.tval') && !g.querySelector('.hval') && !g.querySelector('.lqi'));
|
||||
// у КАЖДОГО призрака есть базовая иконка — «noicon» не оправдание
|
||||
// (HP-1511-02: ripple-призрак был безликим пульсом)
|
||||
o.ghostKeepsIcon = ghosts.every((g) => !!g.querySelector('ha-icon') && !g.classList.contains('noicon'));
|
||||
c._setMode('view'); c._showHidden = false;
|
||||
return o;
|
||||
}));
|
||||
|
||||
// --- HP-1511-02: ripple-призрак с базовой иконкой, без пульса -------------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const lamp = c._devices.find((x) => x.space === 'f1' && x.bindingKind === 'device' && !x.hidden);
|
||||
c._serverCfg.markers = c._serverCfg.markers || [];
|
||||
c._serverCfg.markers = c._serverCfg.markers.filter((m) => m.id !== lamp.id);
|
||||
c._serverCfg.markers.push({ id: lamp.id, binding: 'device:' + lamp.bindingRef,
|
||||
display: 'ripple', hidden: true });
|
||||
c._cfgEpoch++; c._regSignature = '';
|
||||
c._maybeRebuildDevices();
|
||||
c._setMode('devices'); c._showHidden = true;
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const g = [...sr().querySelectorAll('.dev.ghost')].find((x) => x.querySelector('.ripple') || !x.classList.contains('noicon'));
|
||||
const ghosts = [...sr().querySelectorAll('.dev.ghost')];
|
||||
o.rippleGhostHasIcon = ghosts.length > 0 && ghosts.every((x) => !!x.querySelector('ha-icon'));
|
||||
o.rippleGhostNoNoicon = ghosts.every((x) => !x.classList.contains('noicon'));
|
||||
o.rippleGhostNoRipple = ghosts.every((x) => !x.querySelector('.ripple'));
|
||||
c._serverCfg.markers = c._serverCfg.markers.filter((m) => m.id !== lamp.id);
|
||||
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
|
||||
c._setMode('view'); c._showHidden = false;
|
||||
return o;
|
||||
}));
|
||||
|
||||
// --- HP-1511-01: авто-сетка одинакова на обеих карточках ------------------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
await customElements.whenDefined('houseplan-space-card');
|
||||
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
|
||||
const f1 = cfg.spaces.find((s) => s.id === 'f1');
|
||||
// один видимый, остальные зоны living_room скрыты; layout пуст
|
||||
const vis = c._devices.filter((x) => x.area === 'living_room' && !x.virtual);
|
||||
const keep = vis[0];
|
||||
for (const d of vis.slice(1)) {
|
||||
cfg.markers = (cfg.markers || []).filter((m) => m.id !== d.id);
|
||||
cfg.markers.push({ id: d.id, binding: d.bindingKind + ':' + d.bindingRef, hidden: true });
|
||||
}
|
||||
cfg.markers = cfg.markers.filter((m) => m.id !== keep.id || !m.hidden);
|
||||
c._serverCfg = cfg; c._cfgEpoch++; c._regSignature = '';
|
||||
c._layout = {}; // пустой layout: работают только авто-позиции
|
||||
c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
|
||||
const full = [...(c.shadowRoot || c.renderRoot).querySelectorAll('.dev')]
|
||||
.find((el) => el.style.left && !el.classList.contains('ghost'));
|
||||
// проценты полной карточки считаются от content-fit view — переводим в vb
|
||||
const v = c._viewOr(c._baseVb());
|
||||
const fullVb = full && [
|
||||
v.x + (parseFloat(full.style.left) / 100) * v.w,
|
||||
v.y + (parseFloat(full.style.top) / 100) * v.h,
|
||||
];
|
||||
|
||||
const hass = { ...c.hass, callWS: async (m) => {
|
||||
// rev уникален: у статичной карточки модульный кэш конфига по rev,
|
||||
// и одинаковый rev в соседних тестах подсовывает чужой конфиг
|
||||
if (m.type === 'houseplan/config/get') return { config: cfg, rev: 31 };
|
||||
if (m.type === 'houseplan/layout/get') return { layout: {}, rev: 31 };
|
||||
return { ok: true };
|
||||
},
|
||||
// модульный кэш конфига инвалидируется только через это событие — стаб
|
||||
// сохраняет колбэк, чтобы следующий тест мог сбросить кэш
|
||||
connection: { subscribeEvents: async (cb) => { window.__hpInvalidate = cb; return () => {}; } } };
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const card = document.createElement('houseplan-space-card');
|
||||
card.setConfig({ type: 'custom:houseplan-space-card', space: 'f1' });
|
||||
card.hass = hass;
|
||||
host.appendChild(card);
|
||||
const t0 = Date.now();
|
||||
while (!card.renderRoot?.querySelector('[style*="left"]') && Date.now() - t0 < 6000) {
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
}
|
||||
await card.updateComplete;
|
||||
const st = [...card.renderRoot.querySelectorAll('[style*="left"]')]
|
||||
.find((el) => /%$/.test(el.style.left || '') && el.style.top);
|
||||
// статичная карточка рендерит от полного квадрата 0..1000
|
||||
const stVb = st && [parseFloat(st.style.left) * 10, parseFloat(st.style.top) * 10];
|
||||
o.autoGridParity = !!fullVb && !!stVb
|
||||
&& Math.abs(fullVb[0] - stVb[0]) < 6 && Math.abs(fullVb[1] - stVb[1]) < 6;
|
||||
if (!o.autoGridParity) console.log('full', fullVb, 'static', stVb);
|
||||
host.remove();
|
||||
return o;
|
||||
}));
|
||||
|
||||
// --- HP-1510-01: LQI комнаты одинаков на полной и статичной карточке ------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
await customElements.whenDefined('houseplan-space-card');
|
||||
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
|
||||
const f1 = cfg.spaces.find((s) => s.id === 'f1');
|
||||
f1.settings = { ...(f1.settings || {}), fill_mode: 'lqi', show_borders: true };
|
||||
// прячем ВСЕ устройства зоны living_room: комнату красит только скрытое
|
||||
for (const d of c._devices.filter((x) => x.area === 'living_room' && !x.virtual)) {
|
||||
if (!cfg.markers.some((m) => m.id === d.id)) {
|
||||
cfg.markers.push({ id: d.id, binding: d.bindingKind + ':' + d.bindingRef, hidden: true });
|
||||
} else {
|
||||
cfg.markers = cfg.markers.map((m) => (m.id === d.id ? { ...m, hidden: true } : m));
|
||||
}
|
||||
}
|
||||
c._serverCfg = cfg; c._cfgEpoch++; c._regSignature = '';
|
||||
c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
|
||||
const fullRoom = [...(c.shadowRoot || c.renderRoot).querySelectorAll('.room')]
|
||||
.find((r) => (r.getAttribute('style') || '').includes('--room-fill'));
|
||||
const fullFill = fullRoom ? (fullRoom.getAttribute('style').match(/--room-fill:([^;]+)/) || [])[1] : null;
|
||||
|
||||
window.__hpInvalidate?.(); // сбросить модульный кэш от предыдущей карточки
|
||||
const hass = { ...c.hass, callWS: async (m) => {
|
||||
if (m.type === 'houseplan/config/get') return { config: cfg, rev: 57 };
|
||||
if (m.type === 'houseplan/layout/get') return { layout: {}, rev: 57 };
|
||||
return { ok: true };
|
||||
},
|
||||
connection: { subscribeEvents: async () => () => {} } };
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const card = document.createElement('houseplan-space-card');
|
||||
card.setConfig({ type: 'custom:houseplan-space-card', space: 'f1' });
|
||||
card.hass = hass;
|
||||
host.appendChild(card);
|
||||
const t0 = Date.now();
|
||||
while (!card.renderRoot?.querySelector('.room') && Date.now() - t0 < 6000) {
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
}
|
||||
await card.updateComplete;
|
||||
const stRoom = [...card.renderRoot.querySelectorAll('.room')]
|
||||
.find((r) => (r.getAttribute('style') || '').includes('--room-fill'));
|
||||
const stFill = stRoom ? (stRoom.getAttribute('style').match(/--room-fill:([^;]+)/) || [])[1] : null;
|
||||
o.fullPaintsHiddenLqi = !!fullFill;
|
||||
o.staticPaintsHiddenLqi = !!stFill;
|
||||
o.lqiParity = !!fullFill && fullFill === stFill;
|
||||
host.remove();
|
||||
return o;
|
||||
}));
|
||||
|
||||
await finish(browser, checkAll(out));
|
||||
@@ -0,0 +1,38 @@
|
||||
// Множитель «размер значка» обязан масштабировать ГЛИФ вместе с подложкой:
|
||||
// --mdc-icon-size считался от базового --icon-size, и «увеличенный» значок
|
||||
// был пустой рамкой вокруг дефолтной иконки (репорт пользователя, 2026-07-29).
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const out = await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const d = c._devices.find((x) => x.space === 'f1' && x.bindingKind === 'device');
|
||||
const glyphW = () => {
|
||||
const el = [...sr().querySelectorAll('.dev')].find((e) => !e.classList.contains('ghost'));
|
||||
const ic = el?.querySelector('ha-icon');
|
||||
return ic ? ic.getBoundingClientRect().width : 0;
|
||||
};
|
||||
const badgeW = () => {
|
||||
const el = [...sr().querySelectorAll('.dev')].find((e) => !e.classList.contains('ghost'));
|
||||
return el ? el.getBoundingClientRect().width : 0;
|
||||
};
|
||||
// базовый размер
|
||||
const g1 = glyphW(), b1 = badgeW();
|
||||
// множитель ×3 на всех авто-устройствах через маркер первого
|
||||
c._serverCfg.markers = (c._serverCfg.markers || []).filter((m) => m.id !== d.id);
|
||||
c._serverCfg.markers.push({ id: d.id, binding: 'device:' + d.bindingRef, size: 3 });
|
||||
c._cfgEpoch++; c._regSignature = '';
|
||||
c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
const scaled = [...sr().querySelectorAll('.dev')].find((e) => (e.getAttribute('style') || '').includes('--dev-scale:3'));
|
||||
const g3 = scaled?.querySelector('ha-icon')?.getBoundingClientRect().width || 0;
|
||||
const b3 = scaled?.getBoundingClientRect().width || 0;
|
||||
o.badgeScales = b3 > b1 * 2.5;
|
||||
o.glyphScalesWithBadge = g3 > g1 * 2.5; // раньше глиф оставался базовым
|
||||
o.glyphFillsBadge = g3 / b3 > 0.45 && g3 / b3 < 0.8; // пропорция сохранена
|
||||
c._serverCfg.markers = c._serverCfg.markers.filter((m) => m.id !== d.id);
|
||||
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
|
||||
return o;
|
||||
});
|
||||
await finish(browser, checkAll(out));
|
||||
@@ -0,0 +1,65 @@
|
||||
// HP-1503-01: один и тот же повреждённый store обязан рендериться одинаково в
|
||||
// ОБЕИХ карточках. Полная карточка строила модель рукописной копией
|
||||
// spaceModels и прошла мимо safeViewBox/normRect: статическая показывала
|
||||
// восстановленный план, полная — viewBox="0 0 0 0" и rect с отрицательной
|
||||
// шириной. Вектор аудита исполняется через обе модели и оба DOM-дерева.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 900, height: 900 }, 1);
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
await customElements.whenDefined('houseplan-space-card');
|
||||
const main = window.__card;
|
||||
|
||||
// ровно вектор из отчёта: нулевой viewport + отрицательный legacy-rect
|
||||
const cfg = JSON.parse(JSON.stringify(main._serverCfg));
|
||||
const f1 = cfg.spaces.find((s) => s.id === 'f1');
|
||||
f1.plan_url = null; f1.plan_aspect = null;
|
||||
f1.view_box = [0, 0, 0, 0];
|
||||
f1.rooms = [{ id: 'r1', name: 'R', area: 'living_room', x: 0.6, y: 0.7, w: -0.2, h: -0.3 }];
|
||||
f1.settings = { ...(f1.settings || {}), show_borders: true };
|
||||
const hass = { ...main.hass, callWS: async (m) => {
|
||||
if (m.type === 'houseplan/config/get') return { config: cfg, rev: 1 };
|
||||
if (m.type === 'houseplan/layout/get') return { layout: {}, rev: 1 };
|
||||
return { ok: true };
|
||||
} };
|
||||
|
||||
main._serverCfg = cfg;
|
||||
main._cfgEpoch++;
|
||||
main._view = null;
|
||||
main.requestUpdate(); await main.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
|
||||
// модель полной карточки: fallback на весь холст + нормализованный rect
|
||||
const m = main._spaceModel('f1');
|
||||
out.fullVbFallsBack = JSON.stringify(m.vb) === JSON.stringify([0, 0, 1000, 1000]);
|
||||
const r = m.rooms[0];
|
||||
out.fullRectNormalised =
|
||||
Math.round(r.x) === 400 && Math.round(r.y) === 400
|
||||
&& Math.round(r.w) === 200 && Math.round(r.h) === 300;
|
||||
|
||||
// DOM полной карточки: конечный положительный viewBox, никаких минусов в rect
|
||||
const svg = (main.shadowRoot || main.renderRoot).querySelector('.stage svg');
|
||||
const vbAttr = (svg?.getAttribute('viewBox') || '').split(/\s+/).map(Number);
|
||||
out.fullDomViewBoxSane = vbAttr.length === 4 && vbAttr[2] > 0 && vbAttr[3] > 0;
|
||||
const rect = (main.shadowRoot || main.renderRoot).querySelector('.stage svg rect.room, .stage svg .room rect, .stage svg rect[width]');
|
||||
out.fullDomRectSane = !rect || (Number(rect.getAttribute('width')) >= 0 && Number(rect.getAttribute('height')) >= 0);
|
||||
|
||||
// статическая карточка того же store — паритет
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const card = document.createElement('houseplan-space-card');
|
||||
card.setConfig({ type: 'custom:houseplan-space-card', space: 'f1' });
|
||||
card.hass = hass;
|
||||
host.appendChild(card);
|
||||
const t0 = Date.now();
|
||||
while (!card.renderRoot?.querySelector('svg') && Date.now() - t0 < 6000) {
|
||||
await new Promise((r2) => setTimeout(r2, 60));
|
||||
}
|
||||
await card.updateComplete;
|
||||
const svb = (card.renderRoot.querySelector('svg')?.getAttribute('viewBox') || '').split(/\s+/).map(Number);
|
||||
out.staticDomViewBoxSane = svb.length === 4 && svb[2] > 0 && svb[3] > 0;
|
||||
out.parity = JSON.stringify(vbAttr.length === 4 && svb.length === 4
|
||||
? [vbAttr[2] > 0, vbAttr[3] > 0] : null) === JSON.stringify([svb[2] > 0, svb[3] > 0]);
|
||||
return out;
|
||||
});
|
||||
await finish(browser, checkAll(res));
|
||||
@@ -0,0 +1,78 @@
|
||||
// Правило владельца (2026-07-29): у источников света подложка в glow-режиме
|
||||
// всегда стандартная — индикатор включения это ПЯТНО; в остальных режимах
|
||||
// горящий источник жёлтый, как греющая термоголовка. RGB лампы красит только
|
||||
// пятно (и фолбэк цвета пульсации); окраска иконки/рамки убрана. Морф иконки
|
||||
// остаётся везде. Розетка в glow-режиме остаётся жёлтой — она не источник.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const out = await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const setFill = async (mode) => {
|
||||
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
|
||||
const f1 = cfg.spaces.find((s) => s.id === 'f1');
|
||||
f1.settings = { ...(f1.settings || {}), fill_mode: mode };
|
||||
c._serverCfg = cfg; c._cfgEpoch++; c._regSignature = '';
|
||||
c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
};
|
||||
const lamp = c._devices.find((x) => x.id === 'd_lamp');
|
||||
const kettle = c._devices.find((x) => x.id === 'd_kettle'); // розетка/чайник
|
||||
// зажигаем лампу с RGB и розетку
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
[lamp.primary]: { ...c.hass.states[lamp.primary], state: 'on',
|
||||
attributes: { ...(c.hass.states[lamp.primary]?.attributes || {}), rgb_color: [255, 120, 40] } },
|
||||
[kettle.primary]: { ...c.hass.states[kettle.primary], state: 'on' } } };
|
||||
const devEl = (id) => {
|
||||
// надёжнее по индексу в списке devs: ищем по позиции через _pos
|
||||
const d = c._devices.find((x) => x.id === id);
|
||||
const v = c._viewOr(c._baseVb());
|
||||
const p = c._pos(d);
|
||||
const left = (((p.x - v.x) / v.w) * 100).toFixed(0);
|
||||
return [...sr().querySelectorAll('.dev')].find((e) => Math.abs(parseFloat(e.style.left) - left) < 1.5);
|
||||
};
|
||||
|
||||
// --- режим light: горящая RGB-лампа ЖЁЛТАЯ, без rgb-класса --------------
|
||||
await setFill('light');
|
||||
let le = devEl('d_lamp');
|
||||
o.litLampYellowInLightMode = !!le && le.classList.contains('on') && !le.classList.contains('rgb');
|
||||
o.morphInLightMode = le?.querySelector('ha-icon')?.getAttribute('icon')?.includes('lightbulb') ?? false;
|
||||
|
||||
// --- режим glow: лампа ТЁМНАЯ (индикатор — пятно), розетка жёлтая -------
|
||||
await setFill('glow');
|
||||
le = devEl('d_lamp');
|
||||
const ke = devEl('d_kettle');
|
||||
o.litLampDarkInGlow = !!le && !le.classList.contains('on') && !le.classList.contains('rgb');
|
||||
o.glowSpotPresent = !!sr().querySelector('.stage svg radialGradient, .stage svg [id*=glow]');
|
||||
o.socketStaysYellowInGlow = !!ke && ke.classList.contains('on');
|
||||
o.morphInGlow = le?.querySelector('ha-icon')?.getAttribute('icon')?.includes('lightbulb') ?? false;
|
||||
|
||||
// --- HP-1520-01: в редакторах, где glow-слой скрыт, индикатор возвращается
|
||||
// (лампа горит; в devices-редакторе слой рисуется — там лампа тёмная,
|
||||
// в plan-редакторе слоя нет — там класс on должен вернуться)
|
||||
c._setMode('devices'); c.requestUpdate(); await c.updateComplete;
|
||||
le = devEl('d_lamp');
|
||||
o.devicesEditorLampDark = !!le && !le.classList.contains('on');
|
||||
o.devicesEditorHasGlow = !!sr().querySelector('.stage svg radialGradient, .stage svg [id*=glow]');
|
||||
c._setMode('plan'); c.requestUpdate(); await c.updateComplete;
|
||||
// ИМЕННО лампа (mutation-проверка аудита HP-1521-01: розетка тоже on и
|
||||
// удовлетворяла бы поиску «любой .dev.on», пряча регресс самой лампы)
|
||||
le = devEl('d_lamp');
|
||||
o.planEditorLampYellow = !!le && le.classList.contains('on'); // слой скрыт — жёлтый вернулся
|
||||
o.planEditorKettleStillOn = !!devEl('d_kettle')?.classList.contains('on');
|
||||
o.planEditorNoGlow = !sr().querySelector('.stage svg radialGradient, .stage svg [id*=glow]');
|
||||
c._setMode('view'); c.requestUpdate(); await c.updateComplete;
|
||||
|
||||
// --- выключенная лампа тёмная в обоих режимах ---------------------------
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
[lamp.primary]: { ...c.hass.states[lamp.primary], state: 'off' } } };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
le = devEl('d_lamp');
|
||||
o.offLampDarkInGlow = !!le && !le.classList.contains('on');
|
||||
await setFill('light');
|
||||
le = devEl('d_lamp');
|
||||
o.offLampDarkInLightMode = !!le && !le.classList.contains('on');
|
||||
return o;
|
||||
});
|
||||
await finish(browser, checkAll(out));
|
||||
@@ -38,8 +38,7 @@ const res = await page.evaluate(async () => {
|
||||
const vid = c._serverCfg.markers.find((m) => m.name === 'Тест')?.id;
|
||||
const center = c._roomCenter(room);
|
||||
const vpos = c._layout[vid];
|
||||
const aspect = c._curSpaceCfg.aspect || 1;
|
||||
out.newCentered = vpos && Math.abs(vpos.x * 1000 - center[0]) < 1 && Math.abs(vpos.y * (1000 / aspect) - center[1]) < 1;
|
||||
out.newCentered = vpos && Math.abs(vpos.x * 1000 - center[0]) < 1 && Math.abs(vpos.y * 1000 - center[1]) < 1;
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
|
||||
@@ -9,7 +9,7 @@ const restore = () => page.evaluate((s) => {
|
||||
}, snap);
|
||||
// norm→render helper mirrors what _markupClick passes to handlers
|
||||
const R = (nx, ny) => page.evaluate(([nx, ny]) => {
|
||||
const c = window.__card; const H = 1000 / c._curSpaceCfg.aspect; return [nx * 1000, ny * H];
|
||||
return [nx * 1000, ny * 1000]; // the canvas is square (v1.48.0)
|
||||
}, [nx, ny]);
|
||||
const S = () => page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
|
||||
@@ -6,7 +6,7 @@ const res = await page.evaluate(async () => {
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
c._setMode('plan'); c._tool = 'openwall'; await c.updateComplete;
|
||||
// r1|r2 делят стену x=0.55 → клик по ней открывает границу
|
||||
const H = 1000 / (c._curSpaceCfg.aspect || 1);
|
||||
const H = 1000; // square canvas
|
||||
c._openWallClick([550, 0.25 * H]);
|
||||
await c.updateComplete;
|
||||
const r1 = c._curSpaceCfg.rooms.find((r) => r.id === 'r1');
|
||||
@@ -53,8 +53,7 @@ const res = await page.evaluate(async () => {
|
||||
...s, settings: { ...(s.settings || {}), fill_mode: 'glow' } })) };
|
||||
const litLight = c._devices.find((d) => d.space === c._space && d.entities.some((e) => e.startsWith('light.') && c.hass.states[e]?.state === 'on'));
|
||||
const c1 = c._roomCenter(c._spaceModel().rooms.find((r) => r.id === 'r1'));
|
||||
const aspect = c._curSpaceCfg.aspect || 1;
|
||||
c._layout = { ...c._layout, [litLight.id]: { s: c._space, x: c1[0] / 1000, y: c1[1] / (1000 / aspect) } };
|
||||
c._layout = { ...c._layout, [litLight.id]: { s: c._space, x: c1[0] / 1000, y: c1[1] / 1000 } };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const clip = sr().querySelector('defs clipPath[id^="hp-glowclip"]');
|
||||
out.zoneClip = clip ? clip.querySelectorAll('path').length >= 2 : false;
|
||||
|
||||
@@ -6,7 +6,7 @@ const res = await page.evaluate(async () => {
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const stage = () => sr().querySelector('.stage');
|
||||
c._setMode('plan'); c._tool = 'openwall'; await c.updateComplete;
|
||||
const H = 1000 / (c._curSpaceCfg.aspect || 1);
|
||||
const H = 1000; // square canvas
|
||||
// 1) без наведения: курсор default, превью нет
|
||||
c._cursorPt = null; c.requestUpdate(); await c.updateComplete;
|
||||
out.idleCursor = getComputedStyle(stage()).cursor === 'default';
|
||||
|
||||
@@ -39,7 +39,7 @@ const res = await page.evaluate(async () => {
|
||||
const sentF1 = (c.__sent?.spaces || []).find((s) => s.id === 'f1');
|
||||
const liveF1 = (c._serverCfg?.spaces || []).find((s) => s.id === 'f1');
|
||||
out.sentPlanUrl = sentF1?.plan_url;
|
||||
out.sentAspect = sentF1?.aspect;
|
||||
out.sentPlanAspect = sentF1?.plan_aspect; // the IMAGE's ratio; the canvas is square
|
||||
out.sentTitle = sentF1?.title;
|
||||
out.livePlanUrl = liveF1?.plan_url;
|
||||
out.dialogClosed = c._spaceDialog === null;
|
||||
@@ -52,19 +52,19 @@ const res = await page.evaluate(async () => {
|
||||
const attic = (c.__sent?.spaces || []).find((s) => s.title === 'Attic');
|
||||
out.atticSaved = !!attic;
|
||||
out.atticHasPlan = !!attic && typeof attic.plan_url === 'string' && attic.plan_url.includes('/content/plans/');
|
||||
out.atticAspect = attic?.aspect;
|
||||
out.atticPlanAspect = attic?.plan_aspect;
|
||||
return out;
|
||||
});
|
||||
// зафиксировано прогоном на v1.44.8 и сверено с кодом
|
||||
checkAll(res, {
|
||||
reloadHappened: true,
|
||||
sentPlanUrl: '/api/houseplan/content/plans/_/f1.png?v=42',
|
||||
sentAspect: 1.6,
|
||||
sentPlanAspect: 1.6,
|
||||
sentTitle: 'Ground',
|
||||
livePlanUrl: '/api/houseplan/content/plans/_/f1.png?v=42',
|
||||
dialogClosed: true,
|
||||
atticSaved: true,
|
||||
atticHasPlan: true,
|
||||
atticAspect: 0.8,
|
||||
atticPlanAspect: 0.8,
|
||||
});
|
||||
await finish(browser);
|
||||
|
||||
@@ -8,9 +8,20 @@ const res = await page.evaluate(async () => {
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'light.ceiling': { entity_id: 'light.ceiling', state: 'on', attributes: { friendly_name: 'Ceiling light', rgb_color: [255, 0, 128] } } } };
|
||||
await c.updateComplete;
|
||||
const rgbDev = sr().querySelector('.dev.rgb');
|
||||
out.rgbClass = !!rgbDev;
|
||||
out.rgbVar = rgbDev?.getAttribute('style')?.includes('--light-color:rgb(255, 0, 128)');
|
||||
// v1.52.0 (правило владельца): RGB больше НЕ красит значок — цвет лампы
|
||||
// живёт только в glow-пятне и в фолбэке цвета пульсации
|
||||
out.rgbClassGone = !sr().querySelector('.dev.rgb');
|
||||
out.litLampIsYellow = [...sr().querySelectorAll('.dev.on')].length > 0;
|
||||
// фолбэк пульсации сохраняет цвет свечения: маркер icon_ripple на лампе
|
||||
const lampDev = c._devices.find((x) => x.entities.includes('light.ceiling'));
|
||||
c._serverCfg.markers = (c._serverCfg.markers || []).filter((m) => m.id !== lampDev.id);
|
||||
c._serverCfg.markers.push({ id: lampDev.id, binding: 'device:' + lampDev.bindingRef, display: 'icon_ripple' });
|
||||
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const rippleDev = [...sr().querySelectorAll('.dev')].find((e) => (e.getAttribute('style') || '').includes('--ripple-color'));
|
||||
out.rippleKeepsLightColor = !!rippleDev && rippleDev.getAttribute('style').includes('rgb(255, 0, 128)');
|
||||
c._serverCfg.markers = c._serverCfg.markers.filter((m) => m.id !== lampDev.id);
|
||||
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices(); await c.updateComplete;
|
||||
// тревога: датчик протечки on
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'binary_sensor.sink_leak': { entity_id: 'binary_sensor.sink_leak', state: 'on', attributes: { friendly_name: 'Leak', device_class: 'moisture' } } } };
|
||||
|
||||
@@ -36,8 +36,14 @@ const res = await page.evaluate(async () => {
|
||||
c._setMode('plan'); await c.updateComplete;
|
||||
const planLbl = sr().querySelector('.roomlabel');
|
||||
out.handlesInPlan = planLbl?.querySelectorAll('.rlhandle').length === 4;
|
||||
// метрики скрыты в редакторе плана (карточка = имя, чтобы не мешать разметке)
|
||||
out.plainInPlan = sr().querySelectorAll('.roomlabel.card').length === 0;
|
||||
// метрики видны и в редакторе плана (решение владельца, 2026-07-29)
|
||||
out.plainInPlan = sr().querySelectorAll('.roomlabel.card').length > 0;
|
||||
// кнопка настроек — отдельно от подписи, в центре комнаты, с размером от иконки
|
||||
out.gearDetached = (() => {
|
||||
const g = sr().querySelector('.rlgearbtn');
|
||||
if (!g || g.closest('.roomlabel')) return false;
|
||||
return getComputedStyle(g).height !== 'auto';
|
||||
})();
|
||||
// масштаб: сымитируем resize через прямой вызов
|
||||
const room = c._spaceModel().rooms.find((r) => r.name);
|
||||
c._rlResize = { id: 'rl_' + room.id, space: spId, k0: 1, cx: 100, cy: 100, d0: 50 };
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
// «Уже загруженные»: план, который не удаляется за ненадобностью, обязан быть
|
||||
// находимым. Иначе обещание «отцепил — файл остался» неполноценно: вернуть его
|
||||
// из карточки было нельзя, старый URL нигде не хранится (HP-1466-02).
|
||||
// Заодно это единственный способ удалить план — явным действием.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 900, height: 1000 }, 1);
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const base = c.hass.callWS;
|
||||
|
||||
let serverPlans = [
|
||||
{ name: 'f1.aaa.png', url: '/api/houseplan/content/plans/_/f1.aaa.png', size: 121335, modified: 2, used_by: [] },
|
||||
{ name: 'f2.bbb.png', url: '/api/houseplan/content/plans/_/f2.bbb.png', size: 26931, modified: 1, used_by: ['2 этаж'] },
|
||||
];
|
||||
const deleted = [];
|
||||
c.hass = { ...c.hass, callWS: async (m) => {
|
||||
if (m.type === 'houseplan/plans/list') return { plans: serverPlans };
|
||||
if (m.type === 'houseplan/plans/delete') {
|
||||
const p = serverPlans.find((x) => x.name === m.name);
|
||||
if (p?.used_by.length) { const e = new Error('in_use'); e.code = 'in_use'; throw e; }
|
||||
deleted.push(m.name);
|
||||
serverPlans = serverPlans.filter((x) => x.name !== m.name);
|
||||
return { ok: true, removed: true };
|
||||
}
|
||||
if (m.type === 'houseplan/content/sign') {
|
||||
const urls = {}; for (const p of m.paths) urls[p] = p + '?authSig=X'; return { urls };
|
||||
}
|
||||
return base(m);
|
||||
} };
|
||||
window.confirm = () => true;
|
||||
|
||||
// пространство без плана — как после отцепления
|
||||
c._openSpaceDialog('edit', 'f1'); await c.updateComplete;
|
||||
c._spaceDialog = { ...c._spaceDialog, source: 'file', planUrl: null, planFile: null };
|
||||
await c.updateComplete;
|
||||
out.saveBlockedWithoutPlan = !!sr().querySelector('.dialog .btn.on[disabled]');
|
||||
|
||||
// открываем список сохранённых
|
||||
await c._toggleServerPlans();
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
await c.updateComplete;
|
||||
const rows = [...sr().querySelectorAll('.savedplan')];
|
||||
out.listed = rows.length;
|
||||
out.showsUsage = (rows[1]?.textContent || '').includes('2 этаж');
|
||||
out.deleteDisabledForUsed = !!rows[1]?.querySelector('.btn.danger[disabled]');
|
||||
out.deleteEnabledForFree = !rows[0]?.querySelector('.btn.danger[disabled]');
|
||||
out.thumbnailSigned = (rows[0]?.querySelector('img')?.getAttribute('src') || '').includes('authSig=');
|
||||
|
||||
// выбираем свободный план — он подставляется в диалог
|
||||
c._useServerPlan(serverPlans[0].url);
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
await c.updateComplete;
|
||||
out.picked = c._spaceDialog.planUrl === '/api/houseplan/content/plans/_/f1.aaa.png';
|
||||
out.listClosed = !c._spaceDialog.pickSaved;
|
||||
out.saveEnabledAfterPick = !sr().querySelector('.dialog .btn.on[disabled]');
|
||||
|
||||
// выбранный в этом же диалоге удалить нельзя: сохранение записало бы ссылку
|
||||
// на несуществующий файл (HP-1470-02)
|
||||
await c._toggleServerPlans();
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
await c.updateComplete;
|
||||
const rows2 = [...sr().querySelectorAll('.savedplan')];
|
||||
const picked = rows2.find((r) => r.textContent.includes('f1.aaa.png'));
|
||||
out.deleteDisabledForPicked = !!picked?.querySelector('.btn.danger[disabled]');
|
||||
c._spaceDialog = { ...c._spaceDialog, planUrl: null };
|
||||
await c.updateComplete;
|
||||
await c._deleteServerPlan('f2.bbb.png').catch(() => {});
|
||||
out.usedNotDeleted = !deleted.includes('f2.bbb.png');
|
||||
await c._deleteServerPlan('f1.aaa.png');
|
||||
await c.updateComplete;
|
||||
out.freeDeleted = deleted.includes('f1.aaa.png');
|
||||
out.rowGone = !(c._spaceDialog.saved || []).some((p) => p.name === 'f1.aaa.png');
|
||||
return out;
|
||||
});
|
||||
// зафиксировано прогоном на v1.47.0 и сверено с кодом
|
||||
checkAll(res, {
|
||||
saveBlockedWithoutPlan: true,
|
||||
listed: 2,
|
||||
showsUsage: true,
|
||||
deleteDisabledForUsed: true,
|
||||
deleteEnabledForFree: true,
|
||||
thumbnailSigned: true,
|
||||
picked: true,
|
||||
listClosed: true,
|
||||
saveEnabledAfterPick: true,
|
||||
deleteDisabledForPicked: true,
|
||||
usedNotDeleted: true,
|
||||
freeDeleted: true,
|
||||
rowGone: true,
|
||||
});
|
||||
await finish(browser);
|
||||
@@ -0,0 +1,61 @@
|
||||
// HP-1513-01: один и тот же маркер (size 3, angle 37) обязан выглядеть
|
||||
// одинаково на полной и статичной карточках — статичная игнорировала и
|
||||
// множитель размера, и поворот.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 900, height: 900 }, 1);
|
||||
const out = await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
await customElements.whenDefined('houseplan-space-card');
|
||||
const d = c._devices.find((x) => x.space === 'f1' && x.bindingKind === 'device');
|
||||
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
|
||||
cfg.markers = (cfg.markers || []).filter((m) => m.id !== d.id);
|
||||
cfg.markers.push({ id: d.id, binding: 'device:' + d.bindingRef, size: 3, angle: 37 });
|
||||
c._serverCfg = cfg; c._cfgEpoch++; c._regSignature = '';
|
||||
c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
const pick = (root) => [...root.querySelectorAll('.dev')]
|
||||
.find((e) => (e.getAttribute('style') || '').includes('--dev-scale:3'));
|
||||
const full = pick(c.shadowRoot || c.renderRoot);
|
||||
const fW = full?.getBoundingClientRect().width || 0;
|
||||
const fT = full ? getComputedStyle(full.querySelector('ha-icon')).transform : 'none';
|
||||
|
||||
window.__hpInvalidate?.();
|
||||
const hass = { ...c.hass, callWS: async (m) => {
|
||||
if (m.type === 'houseplan/config/get') return { config: cfg, rev: 83 };
|
||||
if (m.type === 'houseplan/layout/get') return { layout: c._layout, rev: 83 };
|
||||
return { ok: true };
|
||||
}, connection: { subscribeEvents: async (cb) => { window.__hpInvalidate = cb; return () => {}; } } };
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const card = document.createElement('houseplan-space-card');
|
||||
card.setConfig({ type: 'custom:houseplan-space-card', space: 'f1' });
|
||||
card.hass = hass;
|
||||
host.appendChild(card);
|
||||
const t0 = Date.now();
|
||||
while (!pick(card.renderRoot || { querySelectorAll: () => [] }) && Date.now() - t0 < 6000) {
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
}
|
||||
const st = pick(card.renderRoot);
|
||||
const sW = st?.getBoundingClientRect().width || 0;
|
||||
const sT = st ? getComputedStyle(st.querySelector('ha-icon')).transform : 'none';
|
||||
o.staticCarriesScale = sW > 0;
|
||||
// абсолютные px несравнимы (разные контейнеры/letterbox) — контракт в том,
|
||||
// что множитель РАБОТАЕТ на обеих карточках: базовый значок той же карточки
|
||||
// втрое меньше масштабированного
|
||||
const stBase = [...card.renderRoot.querySelectorAll('.dev')]
|
||||
.find((e) => !(e.getAttribute('style') || '').includes('--dev-scale'));
|
||||
const sBaseW = stBase?.getBoundingClientRect().width || 0;
|
||||
o.staticRatioIs3 = sBaseW > 0 && Math.abs(sW / sBaseW - 3) < 0.35;
|
||||
const fBase = [...(c.shadowRoot || c.renderRoot).querySelectorAll('.dev')]
|
||||
.find((e) => !(e.getAttribute('style') || '').includes('--dev-scale') && !e.classList.contains('ghost'));
|
||||
const fBaseW = fBase?.getBoundingClientRect().width || 0;
|
||||
o.fullRatioIs3 = fBaseW > 0 && Math.abs(fW / fBaseW - 3) < 0.35;
|
||||
o.bothRotated = fT !== 'none' && sT !== 'none' && fT.startsWith('matrix') && sT.startsWith('matrix');
|
||||
if (!o.staticRatioIs3) console.log('static', sW, sBaseW);
|
||||
host.remove();
|
||||
c._serverCfg.markers = c._serverCfg.markers.filter((m) => m.id !== d.id);
|
||||
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
|
||||
return o;
|
||||
});
|
||||
await finish(browser, checkAll(out));
|
||||
@@ -42,7 +42,7 @@ const res = await page.evaluate(async () => {
|
||||
out.saveEnabled = !sr().querySelector('.dialog .btn.on[disabled]');
|
||||
await c._saveSpaceDialog(); await c.updateComplete;
|
||||
const attic = c._serverCfg.spaces.find((s) => s.title === 'Attic');
|
||||
out.atticAspect = attic?.aspect;
|
||||
out.atticSquare = attic?.aspect === undefined; // no per-space ratio any more
|
||||
out.atticSettings = attic?.settings;
|
||||
out.atticNoPlan = attic ? attic.plan_url === null : null;
|
||||
return out;
|
||||
@@ -55,7 +55,7 @@ checkAll(res, {
|
||||
"labels": ["Living room", "Kitchen", "Bedroom", "Hallway"],
|
||||
"livingStyle": "--room-stroke:#ff8800;--room-stroke-op:0.8;--room-fill:#ffd45c;--room-fill-op:0.180",
|
||||
"lqiFills": 0,
|
||||
"atticAspect": 1,
|
||||
"atticSquare": true,
|
||||
"atticSettings": {"show_borders": true, "show_names": true, "room_color": "#3ea6ff", "room_opacity": 0.55, "fill_mode": "none", "temp_min": 20, "temp_max": 25, "show_lqi": true, "label_temp": false, "label_hum": false, "label_lqi": false, "label_light": false},
|
||||
});
|
||||
await finish(browser, res);
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const R = (nx, ny) => page.evaluate(([nx, ny]) => {
|
||||
const c = window.__card; const H = 1000 / c._curSpaceCfg.aspect; return [nx * 1000, ny * H];
|
||||
return [nx * 1000, ny * 1000]; // square canvas (v1.48.0)
|
||||
}, [nx, ny]);
|
||||
const out = {};
|
||||
await page.evaluate(()=>{const c=window.__card; if(!c._markup)c._setMode('plan'); c._tool='split';});
|
||||
// living room (r1) has walls at y=0.05 which are NOT grid nodes; click near the wall
|
||||
// living room (r1) has walls at y=0.14 which are NOT grid nodes; click near the wall
|
||||
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.3,0.3)); // pick living
|
||||
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.3,0.052)); // near top wall (off grid)
|
||||
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.3,0.142)); // near top wall (off grid)
|
||||
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.3,0.58)); // near bottom wall
|
||||
out.pending = await page.evaluate(()=>!!window.__card._pendingSplit);
|
||||
out.dialog = await page.evaluate(()=>!!window.__card._roomDialog);
|
||||
|
||||
@@ -12,7 +12,7 @@ const res = await page.evaluate(async () => {
|
||||
c._openMarkerDialog(dev); await c.updateComplete;
|
||||
const sel = [...sr().querySelectorAll('.dialog select')].find((s) =>
|
||||
[...s.options].some((o) => o.textContent === c._t('tap.toggle')));
|
||||
out.threeOptions = sel && sel.options.length === 3;
|
||||
out.threeOptions = sel && sel.options.length === 4; // + «Запустить…» (2026-07-29)
|
||||
out.noAutoOption = sel && ![...sel.options].some((o) => o.value === '');
|
||||
out.defaultInfo = sel && sel.value === 'info';
|
||||
c._markerDialog = null; await c.updateComplete;
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// ТЗ 2026-07-29: действие по нажатию «Запустить автоматизацию/скрипт/сцену»
|
||||
// с пикером и поиском + чекбокс «Спрашивать подтверждение» для toggle/run.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const out = await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const calls = [];
|
||||
c.hass = { ...c.hass,
|
||||
states: { ...c.hass.states,
|
||||
'automation.evening': { state: 'on', attributes: { friendly_name: 'Вечерний свет' } },
|
||||
'script.curtains': { state: 'off', attributes: { friendly_name: 'Шторы' } },
|
||||
'scene.movie': { state: '', attributes: { friendly_name: 'Кино' } } },
|
||||
callService: async (dom, svc, data) => { calls.push([dom, svc, data]); return {}; } };
|
||||
|
||||
// --- диалог: выбор run + пикер с поиском --------------------------------
|
||||
const d = c._devices.find((x) => x.space === 'f1' && x.bindingKind === 'device');
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
c._openMarkerDialog(d); await c.updateComplete;
|
||||
c._markerDialog = { ...c._markerDialog, tapAction: 'run' }; await c.updateComplete;
|
||||
o.pickerShown = !!sr().querySelector('.dialog .candlist');
|
||||
c._markerDialog = { ...c._markerDialog, runFilter: 'штор' }; await c.updateComplete;
|
||||
const cands = [...sr().querySelectorAll('.dialog .cand')].map((x) => x.textContent);
|
||||
o.searchWorks = cands.length >= 1 && cands.some((t) => t.includes('Шторы'));
|
||||
// сохранение без цели блокируется
|
||||
await c._saveMarker(); await c.updateComplete;
|
||||
o.saveBlockedWithoutTarget = !!c._markerDialog;
|
||||
// выбираем цель + подтверждение, сохраняем
|
||||
c._markerDialog = { ...c._markerDialog, tapTarget: 'script.curtains', tapConfirm: true };
|
||||
await c.updateComplete;
|
||||
o.confirmCheckboxShown = [...sr().querySelectorAll('.dialog .srcrow')].length >= 2;
|
||||
await c._saveMarker(); await c.updateComplete;
|
||||
const saved = (c._serverCfg.markers || []).find((m) => m.binding === 'device:' + d.bindingRef);
|
||||
o.markerSaved = saved?.tap_action === 'run' && saved?.tap_target === 'script.curtains' && saved?.tap_confirm === true;
|
||||
|
||||
// --- тап: подтверждение → отмена → вызова нет ---------------------------
|
||||
c._setMode('view'); c._regSignature = ''; c._maybeRebuildDevices();
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const dev = c._devices.find((x) => x.id === (saved?.id ?? d.id)) || c._devices.find((x) => x.bindingRef === d.bindingRef);
|
||||
c._clickDevice({ stopPropagation() {} }, dev); await c.updateComplete;
|
||||
o.confirmDialogShown = !!c._tapConfirm && c._tapConfirm.text.includes('Шторы');
|
||||
c._tapConfirm = null; await c.updateComplete;
|
||||
o.cancelNoCall = calls.length === 0;
|
||||
|
||||
// --- тап: подтвердили → script.turn_on ----------------------------------
|
||||
c._clickDevice({ stopPropagation() {} }, dev); await c.updateComplete;
|
||||
const conf = c._tapConfirm; c._tapConfirm = null; conf.exec();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
o.runCalled = calls.length === 1 && calls[0][0] === 'script' && calls[0][1] === 'turn_on'
|
||||
&& calls[0][2].entity_id === 'script.curtains';
|
||||
|
||||
// --- без подтверждения: сразу вызов; автоматизация → trigger ------------
|
||||
c._serverCfg.markers = c._serverCfg.markers.map((m) =>
|
||||
m.id === (saved?.id ?? d.id) ? { ...m, tap_target: 'automation.evening', tap_confirm: null } : m);
|
||||
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices(); await c.updateComplete;
|
||||
const dev2 = c._devices.find((x) => x.bindingRef === d.bindingRef);
|
||||
calls.length = 0;
|
||||
c._clickDevice({ stopPropagation() {} }, dev2); await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
o.automationTriggered = calls.length === 1 && calls[0][0] === 'automation' && calls[0][1] === 'trigger';
|
||||
o.noConfirmWhenOff = !c._tapConfirm;
|
||||
|
||||
// --- цель удалена → тост, вызова нет ------------------------------------
|
||||
const states2 = { ...c.hass.states }; delete states2['automation.evening'];
|
||||
c.hass = { ...c.hass, states: states2 };
|
||||
calls.length = 0;
|
||||
c._clickDevice({ stopPropagation() {} }, dev2); await c.updateComplete;
|
||||
o.missingTargetSafe = calls.length === 0 && !!c._toast;
|
||||
|
||||
// очистка
|
||||
c._serverCfg.markers = c._serverCfg.markers.filter((m) => m.id !== (saved?.id ?? d.id));
|
||||
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
|
||||
return o;
|
||||
});
|
||||
await finish(browser, checkAll(out));
|
||||
@@ -0,0 +1,60 @@
|
||||
// Единый принцип жёлтого (2026-07-29): жёлтый = устройство прямо сейчас
|
||||
// выполняет основную функцию. Термоголовка желтеет от реального нагрева
|
||||
// (hvac_action), а не от служебного свитча защиты от накипи; горящая лампа
|
||||
// желтит значок в любом режиме заливки тем же условием, что зажигает пятно.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const out = await page.evaluate(() => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const cls = (d, states) => {
|
||||
const saved = c.hass;
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, ...states } };
|
||||
const r = c._stateClass(d);
|
||||
c.hass = saved;
|
||||
return r;
|
||||
};
|
||||
const trv = { id: 't', primary: 'climate.trv', entities: ['climate.trv', 'switch.trv_anti_scaling'], marker: null };
|
||||
|
||||
// реально греет → жёлтая
|
||||
o.heatingIsYellow = cls(trv, {
|
||||
'climate.trv': { state: 'heat', attributes: { hvac_action: 'heating' } },
|
||||
'switch.trv_anti_scaling': { state: 'on' },
|
||||
}) === 'on';
|
||||
|
||||
// включена, но не греет (idle) → чёрная, даже со включённой защитой от накипи
|
||||
o.idleIsDark = cls(trv, {
|
||||
'climate.trv': { state: 'heat', attributes: { hvac_action: 'idle' } },
|
||||
'switch.trv_anti_scaling': { state: 'on' },
|
||||
}) === '';
|
||||
|
||||
// выключена → чёрная
|
||||
o.offIsDark = cls(trv, {
|
||||
'climate.trv': { state: 'off', attributes: { hvac_action: 'off' } },
|
||||
}) === '';
|
||||
|
||||
// без hvac_action фолбэк на state
|
||||
o.stateFallback = cls(trv, { 'climate.trv': { state: 'heat', attributes: {} } }) === ''
|
||||
? false : cls(trv, { 'climate.trv': { state: 'heat', attributes: {} } }) === 'on';
|
||||
|
||||
// горящая лампа устройства желтит значок, даже если primary — не она
|
||||
const lamp = { id: 'l', primary: 'sensor.lamp_power', entities: ['sensor.lamp_power', 'light.lamp'], marker: null };
|
||||
o.litLightWins = cls(lamp, {
|
||||
'sensor.lamp_power': { state: '5' },
|
||||
'light.lamp': { state: 'on' },
|
||||
}) === 'on';
|
||||
o.darkLampIdle = cls(lamp, {
|
||||
'sensor.lamp_power': { state: '5' },
|
||||
'light.lamp': { state: 'off' },
|
||||
}) === '';
|
||||
|
||||
// «источник света»: умный выключатель с глупыми светильниками — жёлтый от
|
||||
// того же условия, что и пятно glow
|
||||
const wall = { id: 'w', primary: 'sensor.w', entities: ['sensor.w'],
|
||||
marker: { is_light: true, controls: ['switch.fixtures'] } };
|
||||
o.forcedSourceYellow = cls(wall, {
|
||||
'sensor.w': { state: '1' }, 'switch.fixtures': { state: 'on' },
|
||||
}) === 'on';
|
||||
return o;
|
||||
});
|
||||
await finish(browser, checkAll(out));
|
||||
@@ -0,0 +1,81 @@
|
||||
// v1.49.x: zoom goes below the base fit, the editor does not shift the plan.
|
||||
// - the stage height follows the MEASURED header, not a hard-coded 118px, so
|
||||
// entering an editor keeps the plan inside the viewport;
|
||||
// - zoom < 1 centres the content instead of pinning it to a corner;
|
||||
// - the content frame (default zoom) includes devices standing outside rooms.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const out = {};
|
||||
|
||||
// -- editor entry keeps the stage inside the viewport --------------------
|
||||
const stageBox = () => page.evaluate(() => {
|
||||
const sr = window.__card.shadowRoot || window.__card.renderRoot;
|
||||
const b = sr.querySelector('.stage').getBoundingClientRect();
|
||||
return { top: Math.round(b.top), bottom: Math.round(b.bottom) };
|
||||
});
|
||||
const vh = await page.evaluate(() => window.innerHeight);
|
||||
const inView = await stageBox();
|
||||
await page.evaluate(() => window.__card._setMode('plan'));
|
||||
await page.waitForTimeout(400);
|
||||
const inPlan = await stageBox();
|
||||
out.viewFitsViewport = inView.bottom <= vh + 2;
|
||||
out.editorFitsViewport = inPlan.bottom <= vh + 2; // used to overflow by ~90px
|
||||
out.editorStageShrinks = inPlan.top > inView.top && inPlan.bottom <= inView.bottom + 2;
|
||||
await page.evaluate(() => window.__card._setMode('view'));
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// -- zoom out below the base fit -----------------------------------------
|
||||
out.zoomOut = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
c._resetZoom();
|
||||
const fit = { ...c._viewOr(c._baseVb()) };
|
||||
const stage = (c.shadowRoot || c.renderRoot).querySelector('.stage');
|
||||
c._zoomAt(stage.clientWidth / 2, stage.clientHeight / 2, 0.5);
|
||||
const v = { ...c._view };
|
||||
const base = c._baseVb();
|
||||
const cx = v.x + v.w / 2, cy = v.y + v.h / 2;
|
||||
return {
|
||||
zoom: c._zoom,
|
||||
wider: v.w > fit.w * 1.9, // actually zoomed out
|
||||
centredX: Math.abs(cx - (base[0] + base[2] / 2)) < 1, // not pinned to a corner
|
||||
centredY: Math.abs(cy - (base[1] + base[3] / 2)) < 1,
|
||||
};
|
||||
});
|
||||
out.zoomOutWorks = out.zoomOut.zoom === 0.5 && out.zoomOut.wider
|
||||
&& out.zoomOut.centredX && out.zoomOut.centredY;
|
||||
delete out.zoomOut;
|
||||
out.floorIsHalf = await page.evaluate(() => { window.__card._resetZoom(); const c = window.__card;
|
||||
c._applyView(0.1); return c._zoom; }) === 0.4; // clamped at the floor
|
||||
await page.evaluate(() => window.__card._resetZoom());
|
||||
|
||||
// -- devices outside rooms stretch the default frame ---------------------
|
||||
out.devicesStretchFrame = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
|
||||
cfg.spaces[0].plan_url = null; cfg.spaces[0].plan_aspect = null;
|
||||
c._serverCfg = cfg; c._model = null;
|
||||
const before = c._baseVb();
|
||||
// walk one lamp far outside every room
|
||||
c._layout = { ...c._layout, d_lamp: { s: 'f1', x: 0.99, y: 0.5 } };
|
||||
const after = c._baseVb();
|
||||
return after[0] + after[2] > before[0] + before[2] + 20; // right edge follows the lamp
|
||||
});
|
||||
|
||||
// -- a card BELOW other dashboard content still gets a stage (HP-1500-02) --
|
||||
out.stageSurvivesContentAbove = await page.evaluate(async () => {
|
||||
const spacer = document.createElement('div');
|
||||
spacer.style.height = '900px';
|
||||
document.body.insertBefore(spacer, document.body.firstChild);
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
await new Promise((r) => setTimeout(r, 120));
|
||||
const c = window.__card;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
const h = sr.querySelector('.stage').getBoundingClientRect().height;
|
||||
spacer.remove();
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
await new Promise((r) => setTimeout(r, 120));
|
||||
// the old code billed the 900px spacer as "header" and left a 0px stage
|
||||
return h > 300;
|
||||
});
|
||||
|
||||
await finish(browser, checkAll(out));
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="200" viewBox="0 0 800 200"><rect width="800" height="200" fill="#eee"/></svg>
|
||||
|
After Width: | Height: | Size: 137 B |
+7
-7
@@ -51,17 +51,17 @@ customElements.define('ha-card',HaCard);
|
||||
<script type="module">
|
||||
const CFG = {
|
||||
spaces: [
|
||||
{ id:'f1', title:'Ground floor', plan_url:'/assets/f1.svg', aspect:1.25,
|
||||
{ id:'f1', title:'Ground floor', plan_url:'/assets/f1.svg', plan_aspect:1.25,
|
||||
view_box:[0,0,1,1],
|
||||
rooms:[
|
||||
{id:'r1', name:'Living room', area:'living_room', poly:[[0.04,0.05],[0.55,0.05],[0.55,0.6],[0.04,0.6]]},
|
||||
{id:'r2', name:'Kitchen', area:'kitchen', poly:[[0.55,0.05],[0.96,0.05],[0.96,0.45],[0.55,0.45]]},
|
||||
{id:'r3', name:'Bedroom', area:'bedroom', poly:[[0.55,0.45],[0.96,0.45],[0.96,0.95],[0.55,0.95]]},
|
||||
{id:'r4', name:'Hallway', area:'hallway', poly:[[0.04,0.6],[0.55,0.6],[0.55,0.95],[0.04,0.95]]}
|
||||
{id:'r1', name:'Living room', area:'living_room', poly:[[0.04,0.14],[0.55,0.14],[0.55,0.58],[0.04,0.58]]},
|
||||
{id:'r2', name:'Kitchen', area:'kitchen', poly:[[0.55,0.14],[0.96,0.14],[0.96,0.46],[0.55,0.46]]},
|
||||
{id:'r3', name:'Bedroom', area:'bedroom', poly:[[0.55,0.46],[0.96,0.46],[0.96,0.86],[0.55,0.86]]},
|
||||
{id:'r4', name:'Hallway', area:'hallway', poly:[[0.04,0.58],[0.55,0.58],[0.55,0.86],[0.04,0.86]]}
|
||||
], segments:[] },
|
||||
{ id:'garden', title:'Garden', plan_url:'/assets/garden.svg', aspect:1.4286,
|
||||
{ id:'garden', title:'Garden', plan_url:'/assets/garden.svg', plan_aspect:1.4286,
|
||||
view_box:[0,0,1,1],
|
||||
rooms:[ {id:'g1', name:'Garden', area:'garden', poly:[[0.03,0.04],[0.97,0.04],[0.97,0.96],[0.03,0.96]]} ], segments:[] }
|
||||
rooms:[ {id:'g1', name:'Garden', area:'garden', poly:[[0.03,0.178],[0.97,0.178],[0.97,0.822],[0.03,0.822]]} ], segments:[] }
|
||||
],
|
||||
markers: [],
|
||||
settings: {}
|
||||
|
||||
Vendored
+230
-92
File diff suppressed because one or more lines are too long
+102
-20
@@ -10,7 +10,7 @@ houseplan-card/
|
||||
├─ src/ # card sources (TypeScript + Lit 3)
|
||||
│ ├─ houseplan-card.ts # the card: rendering, states, drag, tooltip, sticky header
|
||||
│ ├─ editor.ts # GUI config editor (ha-form + selectors)
|
||||
│ ├─ rules.ts # icon rules (iconFor), curation, groups, domain priority
|
||||
│ ├─ rules.ts # icon rules (iconFor), filtering, groups, domain priority
|
||||
│ └─ data/
|
||||
│ ├─ house.ts # geometry: ROOMS (rooms→area), FLOOR_VB (viewBox), names
|
||||
│ └─ backgrounds.ts # VECTOR plans (SVG base64) + FLOOR_BG_RECT (positioning)
|
||||
@@ -100,23 +100,36 @@ name?, icon?, model?, link?, description?, pdfs:[{name,url}]}`. A hybrid: auto-d
|
||||
appear on their own; a marker with `binding=device:<id>` overrides them (metadata/rebinding/hiding),
|
||||
`entity:<eid>` — for groups/helpers, `virtual` — a manual icon without HA. The marker id = device_id /
|
||||
`lg_<eid>` / `v_<rand>` (preserves the position in the layout). The binding picker excludes already-placed
|
||||
references and duplicates by name|area. Manual files: `houseplan/file/set` → `/config/houseplan/files/<id>/`,
|
||||
served from `/houseplan_files/files/`.
|
||||
references and duplicates by name|area. Manual files: transactional HTTP upload into `<config>/houseplan/files/<id>/`
|
||||
(staging `up_*` folders promoted on save), served via signed
|
||||
`/api/houseplan/content/files/…` urls.
|
||||
|
||||
## Server-side configuration (v1.3.0+)
|
||||
## Server-side configuration (current shape, v1.51+)
|
||||
|
||||
`.storage/houseplan.config` (Store):
|
||||
```json
|
||||
{ "spaces": [{ "id","title","plan_url","aspect","view_box":[4],"rooms":[{"id","name","area","x","y","w","h"}] }],
|
||||
"device_overrides": {"<device_id>": {"hidden","icon","name"}},
|
||||
"virtual_devices": [{"id","space","name","icon","x","y","note?","entity_id?"}],
|
||||
"settings": {"exclude_integrations":[],"group_lights":true} }
|
||||
{ "spaces": [{ "id","title","plan_url","plan_aspect","view_box":[4],
|
||||
"rooms":[{"id","name","area","poly|x/y/w/h","open_to","settings"}],
|
||||
"openings":[…], "decor":[…], "settings":{…} }],
|
||||
"markers": [{ "id","binding":"device:<id>|entity:<eid>|virtual","hidden",
|
||||
"name","icon","display","controls","is_light","tap_action",
|
||||
"room_id","pdfs",… }],
|
||||
"settings": { "exclude_integrations":[], "group_lights":true,
|
||||
"filter_seeded":true, "fill_colors":{…}, "icon_rules":[…],
|
||||
"known_devices":[…], "new_device_ids":[…] } }
|
||||
```
|
||||
All coordinates are **normalized (0..1 of the space plan)**; the render space is
|
||||
1000 × 1000/aspect. Layout v2: `{device_id: {"s": space, "x", "y"}}` (normalized).
|
||||
Plan files: `<config>/houseplan/plans/<space>.<ext>` → URL `/houseplan_files/plans/…`.
|
||||
If the server config is empty, the card falls back to the legacy bundle (the dacha) and shows a
|
||||
"To server" migration button in edit mode. The dacha was migrated on 2026-07-04.
|
||||
All coordinates are **normalized (0..1 of the canvas)**; the canvas is always
|
||||
**square** (v1.48.0), render space `NORM_W × NORM_W` (1000×1000). A space has no
|
||||
proportions of its own — `plan_aspect` is the IMAGE's ratio, used to letterbox
|
||||
it centred on the square. The schema bounds geometry to ±4 with strictly
|
||||
positive sizes (HP-1501/1502). `device_overrides`/`virtual_devices` are long
|
||||
gone — markers carry everything, `marker.hidden` is the explicit
|
||||
"hide from plan" flag seeded once by the old filter (docs/FILTERING.md).
|
||||
Layout v2: `{device_id | rl_<roomId>: {"s": space, "x", "y"}}` (normalized,
|
||||
bounded ±4). Plan files: `<config>/houseplan/plans/<space>.<token>.<ext>`
|
||||
(copy-on-write, never overwritten), served via signed
|
||||
`/api/houseplan/content/plans/_/<name>` urls; growth is bounded by store
|
||||
quotas, nothing is ever deleted for being old (docs/SCOPE.md).
|
||||
|
||||
## Room geometry rules (v1.19–v1.21)
|
||||
|
||||
@@ -178,7 +191,38 @@ double click → properties dialog. In markup mode the "Opening" tool handles cl
|
||||
| `houseplan/config/get` | — | `{config, rev}` |
|
||||
| `houseplan/config/set` | `config`, `expected_rev?` | `{ok, rev}` / err `conflict`; event `houseplan_config_updated` |
|
||||
| `houseplan/plan/set` | `space_id`, `ext` (svg/png/jpg/webp), `data` (b64, ≤8 MB) | `{ok, url}` — writes `<space>.<token>.<ext>`, deletes nothing |
|
||||
| `houseplan/file/set` | `marker_id`, `filename`, `data` (b64) | `{ok,url,name}` (legacy, WS limit) |
|
||||
| `houseplan/plans/list` | — | `{plans: [{name, url, size, modified, used_by}], total}` (newest 60) |
|
||||
| `houseplan/plans/delete` | `name` | `{ok, removed}` / err `in_use` |
|
||||
| `houseplan/layout/delete` | `device_id` | `{ok, rev}`; event `houseplan_layout_updated` |
|
||||
| `houseplan/geometry/repair` | `space_id`, `aspect`, `dry_run?`, `undo?` | preview / `{ok, rev, moved}` / `{restored}`; errs `nothing_to_repair`, `no_backup` |
|
||||
| `houseplan/files/migrate` | `from_id`, `to_id` | `{mapping}` — COPY, never move |
|
||||
| `houseplan/files/cleanup` | `marker_id`, `keep?` | replacement-only collection |
|
||||
| `houseplan/content/sign` | `paths[]` | `{urls}` — authSig for `<image>`/`<a>` fetches |
|
||||
|
||||
Manual attachments upload over HTTP (streaming, transactional staging), not WS —
|
||||
the old `houseplan/file/set` was removed in v1.10.0.
|
||||
|
||||
**If the v1.48 migration crashed halfway** (HP-1500-01): the config write
|
||||
landed, the layout write did not, and both triggers are gone — markers of that
|
||||
space sit in the old coordinates and nothing in the data can prove it. The
|
||||
`geom_pending` intent (v1.50.0) prevents this for any future migration, but
|
||||
cannot help an install that was already stranded. There is no safe automatic
|
||||
answer — re-transforming a layout that is actually correct would corrupt it —
|
||||
so the fix is explicit: `houseplan/geometry/repair {space_id, aspect}`
|
||||
re-applies the transform to that one space's positions. `dry_run: true`
|
||||
previews, the previous positions ride the same store write as a one-deep
|
||||
backup, and `undo: true` restores them. Admin-gated like every other write.
|
||||
|
||||
**The canvas is square, the image is not** (v1.48.0). A space used to carry an
|
||||
`aspect`, and coordinates were normalised against it — x by the width, y by the
|
||||
height. That made every geometric question depend on a per-space number for no
|
||||
benefit. Now the render space is `NORM_W × NORM_W` and a plan image is fitted
|
||||
inside it by its own ratio (`fitInSquare`, shared by both renderers), which is
|
||||
stored as `plan_aspect` so the layout does not jump before the file loads.
|
||||
Upgrading runs `geometry_migration.migrate_config` once: it pads the old box out
|
||||
to a square and re-expresses every coordinate against it — a uniform scale plus
|
||||
an offset in render units, so angles and proportions are exact — and scales
|
||||
`cell_cm` for tall plans, since the grid pitch is a fraction of the width.
|
||||
|
||||
**User content is served inert** (HP-1454-01). An uploaded SVG is the only
|
||||
thing here that a browser will happily treat as a *document* rather than an
|
||||
@@ -207,8 +251,37 @@ happen when somebody saves, and a file uploaded into a dialog that was then
|
||||
cancelled would wait for a write that may never come. A new icon has no id yet, so its
|
||||
files go to a per-dialog staging folder and move to the real id once the config
|
||||
write is accepted — the same copy → save → cleanup order as a rebind.
|
||||
`config/set` collects what its commit superseded, and anything unreferenced and
|
||||
older than `PLAN_ORPHAN_TTL_S`.
|
||||
`config/set` collects what its commit superseded — that much a commit knows for
|
||||
certain. *Unreferenced* is a far weaker signal, and the policy follows from one
|
||||
asymmetry: **a few unnecessary megabytes can always be removed by hand; a file
|
||||
we should not have removed cannot be brought back.** When the evidence is weak,
|
||||
keep the file. Owner's decision, 2026-07-28, after the one-hour rule applied to
|
||||
every unreferenced file destroyed two detached plans.
|
||||
|
||||
The classification is by **owner**, not by "is it referenced". A file leaving
|
||||
the configuration looks identical whether the plan was replaced, detached, or
|
||||
its space deleted — and only the first is a deletion the user asked for. Reading
|
||||
`old_refs - new_refs` and calling it "superseded" deleted a plan the moment it
|
||||
was detached, under documentation promising the opposite (HP-1465-01).
|
||||
|
||||
| Case | What it means | Rule |
|
||||
|---|---|---|
|
||||
| Space in both, plan A → plan B | the user picked another image | removed immediately |
|
||||
| Space in both, plan → none | detached; one click undoes it | **kept** |
|
||||
| Space gone | deliberate, but the image was imported and may be nowhere else | **kept** |
|
||||
| Space has a plan, plus another file of its own | an upload whose save was rejected | **kept** — ageing these out raced the retry that referenced them |
|
||||
| Marker in both, attachment dropped from its list | a trash button, promising nothing | removed immediately |
|
||||
| Marker gone | same call as a deleted space's plan | **kept** |
|
||||
| Attachment in `up_*` | a dialog that was never saved; no device owns it | `PLAN_ORPHAN_TTL_S` (1 h) |
|
||||
| Marker there, file it never listed | a rejected upload | **kept**, same reason |
|
||||
|
||||
Nothing is deleted for being old, with one exception: a per-dialog staging
|
||||
folder (`up_*`), which by construction can only hold an upload from a dialog
|
||||
that was never saved. The disk therefore stays bounded by the user, not by a
|
||||
timer — `houseplan/plans/list` shows every stored plan with its size and which
|
||||
space uses it, and `houseplan/plans/delete` removes one on request, refusing
|
||||
while a space still references it. That listing is what makes "we never delete"
|
||||
livable: a detached plan is not lost, it is one click away in the space dialog.
|
||||
|
||||
**Config writes are serialized** (HP-1454-03). `_writeConfig()` chains onto a
|
||||
single promise: one `config/set` in flight, each carrying the revision the
|
||||
@@ -224,12 +297,21 @@ removes nothing. Deciding what may then go is *not* a client's call — a cleanu
|
||||
request cannot be ordered against another client's commit, and a delayed one
|
||||
deletes a plan that was just saved. So `config/set` collects itself, inside its
|
||||
write lock, from the pair of configurations that bracket the commit
|
||||
(`plans.collect_plans`): superseded files go immediately, other unreferenced
|
||||
uploads only once `PLAN_ORPHAN_TTL_S` has passed, since a fresh one may belong
|
||||
to a transaction still in flight. The `.` between id and token is load-bearing —
|
||||
(`plans.collect_plans`): a file the commit REPLACED goes immediately, and
|
||||
nothing else goes at all — see the table above; only a per-dialog staging folder
|
||||
ages out. Growth is bounded at the door instead, by `plans.check_quota` on every
|
||||
upload (store size, file count, free disk), because a limit that deletes is how
|
||||
plans were lost twice. The `.` between id and token is load-bearing —
|
||||
a space id cannot contain one, so `<space>.<token>.<ext>` can never be confused
|
||||
with the files of a space whose name merely starts the same way.
|
||||
|
||||
**An internal plan url must exist when it is stored** (HP-1470-02). The picker
|
||||
can attach a plan and then delete it, and two clients can do the same in either
|
||||
order — the write lock orders the requests but says nothing about whether the
|
||||
file survived. `config/set` therefore checks every `/api/houseplan/content/plans/`
|
||||
url against the disk before saving, and refuses with `missing_plan`. External and
|
||||
legacy urls are the user's own and are never second-guessed.
|
||||
|
||||
**Signed content urls are batched, aged and deduplicated** (reviews R2-2, R3-2, R4-2). `ContentSigner`
|
||||
in `src/signing.ts` is the single implementation, used by both cards; the
|
||||
duplicate inside houseplan-space-card signed correctly and never handed the
|
||||
@@ -270,7 +352,7 @@ Shared, framework-light modules keep the two views from diverging:
|
||||
`roomCenter`, `defaultPositions`, `markerPos`, `labelPos`; no Lit import) — unit-tested,
|
||||
mirrors the full card's private geometry.
|
||||
- `src/space-render.ts` — `renderSpaceStatic()` draws the plan + configured room
|
||||
borders/names + device markers (via `buildDevices`, same curation) with NO handlers,
|
||||
borders/names + device markers (via `buildDevices`, same filtering) with NO handlers,
|
||||
NO live states, NO status/temperature fills. Uses the same CSS classes as the full card
|
||||
(the space-card imports `cardStyles`) for visual parity.
|
||||
- `src/config-store.ts` — module-level `{config, rev, layout}` cache shared by all embedded
|
||||
|
||||
@@ -1,5 +1,442 @@
|
||||
# Changelog
|
||||
|
||||
## v1.53.0 — 2026-07-30
|
||||
|
||||
**A tap can run your automation** (owner's spec)
|
||||
|
||||
- **New tap action: "Run automation/script/scene".** We cannot know every
|
||||
exotic device, but you know what a tap on it should do: pick any
|
||||
automation, script or scene from a searchable list, and the tap runs it —
|
||||
`automation.trigger`, `script.turn_on` or `scene.turn_on` per kind, with a
|
||||
"Started" toast. A script is the idiomatic HA "action", so all three
|
||||
runnable kinds are offered — no trigger-less dummy automations needed.
|
||||
Saving refuses a run action without a target; a target deleted later warns
|
||||
in the dialog and toasts safely on tap.
|
||||
- **"Ask for confirmation" checkbox** — the accidental-tap guard, available
|
||||
for any state-changing action: toggle and run alike, wall-switch markers
|
||||
with bound targets included. The dialog is the card's own (Esc, backdrop
|
||||
or Cancel = nothing happens), so it works on a wall tablet.
|
||||
- **Curtains toggle natively:** covers and valves joined the card-wide
|
||||
toggle domains — with one deliberate exception: garage doors, doors and
|
||||
gates (cover device classes garage/door/gate) stay OUT of the default
|
||||
toggle, an accidental tap must not open the driveway. An explicit
|
||||
per-device toggle remains the owner's conscious choice, and locks/alarm
|
||||
panels stay untouchable from the plan, as always.
|
||||
|
||||
## v1.52.2 — 2026-07-29
|
||||
|
||||
**From the v1.52.1 review** (no runtime changes — test and wording quality)
|
||||
|
||||
- The plan-editor regression check now targets THE lamp (HP-1521-01): the
|
||||
old assertion accepted any yellow badge, and the lit socket in the same
|
||||
fixture would have satisfied it with the lamp fix removed. Verified by
|
||||
mutation: reverting the v1.52.1 gate makes the smoke fail.
|
||||
- The one remaining "yellow in every fill mode" wording — the checklist
|
||||
entry and the _stateClass comment — now states the actual contract: the
|
||||
lit-source state is computed by the glow-pool condition, and the yellow
|
||||
badge shows only where the glow spot is not drawn (HP-1521-02).
|
||||
|
||||
## v1.52.1 — 2026-07-29
|
||||
|
||||
**From the v1.52.0 review**
|
||||
|
||||
- **A lit lamp always has exactly one indicator (HP-1520-01).** The glow
|
||||
layer is hidden in the plan editor, but the yellow suppression still
|
||||
applied there — a lit lamp showed neither the spot nor the badge. The
|
||||
suppression gate now equals the layer's actual visibility: wherever the
|
||||
spot is not drawn, the yellow badge returns.
|
||||
- **The static card honours a marker's size and rotation (HP-1513-01).** The
|
||||
same stored marker rendered at base size, unrotated, on the read-only
|
||||
card. It now mirrors the full card's --dev-scale and angle — geometry
|
||||
only, still a schematic.
|
||||
- Documentation caught up with the v1.52.0 colour contract (HP-1520-02):
|
||||
the RGB-tint expectations in TESTING/UX-MODES and a stale inline comment.
|
||||
|
||||
## v1.52.0 — 2026-07-29
|
||||
|
||||
**One look for light sources, whatever flipped them** (owner's rule)
|
||||
|
||||
- **A lamp's colour lives only in its glow.** The RGB tint of the icon,
|
||||
border and shadow is gone: depending on whether the state carried colour
|
||||
data, the same lamp used to land in the "coloured icon on a dark badge" or
|
||||
the "plain yellow badge" branch — turning one lamp off by tap and the rest
|
||||
by the wall switch produced visibly different results. The branch is gone.
|
||||
- **In glow fill the indicator IS the glow spot.** A light source's badge
|
||||
stays standard, lit or not — the pool of light around it says everything.
|
||||
A lit socket, fan or kettle keeps its yellow even in glow fill: they cast
|
||||
no light, the rule is for sources only.
|
||||
- **In every other fill a lit source is plain yellow**, like a heating
|
||||
radiator valve — RGB and white lamps alike.
|
||||
- Icon morphing (the shining-bulb outline) stays in every mode, and the
|
||||
ripple colour still falls back to the lamp's light colour.
|
||||
|
||||
## v1.51.3 — 2026-07-29
|
||||
|
||||
- **The icon size multiplier scales the glyph, not just the badge.** Changing
|
||||
a device's size grew the badge, the ripple and the value badges but left
|
||||
the icon itself at its default size — a big empty box around a small glyph
|
||||
(user report). The glyph now derives from the same per-device size as
|
||||
everything else and keeps its proportion at any multiplier and zoom.
|
||||
|
||||
## v1.51.2 — 2026-07-29
|
||||
|
||||
**From the v1.51.1 review**
|
||||
|
||||
- **The auto grid is the same on both cards (HP-1511-01).** The full card
|
||||
reserves grid cells for hidden devices (their ghosts keep a place in the
|
||||
device editor); the static card compacted the grid over visible ones only,
|
||||
so a freshly discovered marker with no saved position landed in different
|
||||
spots on the two cards. The static card now feeds the full roster to the
|
||||
same grid and still draws only the visible.
|
||||
- **A ripple-display ghost keeps its base icon (HP-1511-02).** Hidden markers
|
||||
with the "ripple" presentation rendered as an icon-less inactive pulse —
|
||||
unrecognisable in the editor. A ghost now drops the display dressing
|
||||
entirely: base icon and name, whatever the display mode.
|
||||
|
||||
## v1.51.1 — 2026-07-29
|
||||
|
||||
**From the v1.51.0 review**
|
||||
|
||||
- **The static card counts hidden devices in room LQI again (HP-1510-01).**
|
||||
Its visibility filter had quietly become the aggregation filter: the same
|
||||
room showed different Zigbee health on the two cards. Aggregation and
|
||||
rendering use separate lists now — hidden devices count toward signal on
|
||||
both cards, are drawn on neither, and still cast no light.
|
||||
- **A ghost shows no live numbers (HP-1510-02).** A hidden device in "Show
|
||||
hidden" suppressed the state colors but still painted its value text,
|
||||
temperature, humidity, LQI badge and state-morphed icon. All of that is
|
||||
gone: the ghost keeps only the base icon and name — enough to recognise it
|
||||
and open the dialog.
|
||||
|
||||
## v1.51.0 — 2026-07-29
|
||||
|
||||
**Hiding is an explicit flag now** (docs/FILTERING.md)
|
||||
|
||||
- **Every device dialog — virtual ones included — has a "Hide device from
|
||||
plan" checkbox.** The old on-the-fly filter survives only as the SEEDER of
|
||||
those flags: on first load by an editing client the config is materialised
|
||||
once — non-physical devices (bridges, scenes, service integrations, lamps
|
||||
folded into a light group) get the flag, and from then on the flag belongs
|
||||
to you. Unticking it is final: the seeder never revisits a device you have
|
||||
decided about. New non-physical devices hide silently; physical ones keep
|
||||
the red-dot flow.
|
||||
- **"Show all" became "Show hidden"** — a local tool of the device editor
|
||||
(nothing flips on the wall tablets), showing hidden devices as translucent
|
||||
BLUE dashed ghosts: clearly apart from a grey unavailable icon, and with no
|
||||
live-state paint at all — a ghost is configuration, not status. Click one
|
||||
to untick the box. "Remove from plan" is gone for bound devices (the
|
||||
checkbox is the way); a virtual device's Delete still deletes.
|
||||
- Hidden devices still count toward the room's Zigbee signal, but cast no
|
||||
glow and no light fill — an invisible device casts no visible light. Room
|
||||
climate is unchanged. Old configs behave exactly as before until an
|
||||
editing client materialises them.
|
||||
|
||||
**Yellow means working right now**
|
||||
|
||||
- One principle for the glowing icon: a light is shining, a socket is
|
||||
powering, a fan is spinning, media is playing, a vacuum is cleaning — or a
|
||||
radiator valve is ACTUALLY heating (hvac_action), not merely enabled for
|
||||
the winter. Previously a TRV could glow yellow because its anti-scaling
|
||||
service switch was on while the actually-heating one stayed dark: the
|
||||
primary-entity search let a vendor's config switch outrank the visible
|
||||
climate entity. Fixed — a service entity never beats the device's visible
|
||||
main function (this also fixes tap-toggle and icon morphing on such
|
||||
devices).
|
||||
- The glow pool and the icon color now ask the same question: a lit light
|
||||
yellows its icon in every fill mode, by exactly the condition that lights
|
||||
its glow spot. The README (en+ru) documents the color language.
|
||||
|
||||
**The editors, on a phone**
|
||||
|
||||
- Pinch zoom and pan gestures now work in every editor: drawing is
|
||||
click-based, so the two coexist — a moving finger pans, two fingers pinch,
|
||||
releasing after a gesture never draws a point, a clean tap still does.
|
||||
|
||||
**The room settings button**
|
||||
|
||||
- Detached from the (movable) room name: it sits at the VISUAL centre of the
|
||||
room — the centre of the largest inscribed circle with a pull toward the
|
||||
area centroid, so an elongated room centres it on both axes and an L-shaped
|
||||
one keeps it in the middle of its widest part, never down a thin limb.
|
||||
- Half its former size, sized from the device icon (70% of the icon box) and
|
||||
zooming WITH the plan instead of keeping a constant screen size.
|
||||
- The small metric rows under the room name (temperature, humidity, signal,
|
||||
lights) now show in the plan editor too, and the name renders in exactly
|
||||
the same spot in view mode and in the editor.
|
||||
|
||||
## v1.50.4 — 2026-07-29
|
||||
|
||||
**From the v1.50.3 review**
|
||||
|
||||
- **Both cards build their model with the same code now (HP-1503-01).** The
|
||||
full card carried a hand-copied twin of the shared model builder, and the
|
||||
twin missed the legacy-store fallbacks v1.50.3 added — the same broken
|
||||
store rendered fine in the static card and as a blank `viewBox="0 0 0 0"`
|
||||
in the main one. The duplicate is gone: the full card calls the shared
|
||||
builder and only swaps in the raw plan url its signing flow needs. A new
|
||||
smoke runs the audit's exact legacy vector through both models and both
|
||||
DOM trees and asserts parity.
|
||||
|
||||
## v1.50.3 — 2026-07-29
|
||||
|
||||
**From the v1.50.2 review**
|
||||
|
||||
- **A size is not a coordinate (HP-1502-01).** The ±4 bound from v1.50.2
|
||||
treated all four view_box elements and room w/h alike, so `[0, 0, 0, 0]`
|
||||
and negative sizes still passed — and a zero axis serialises into
|
||||
`viewBox="0 0 0 0"`, a blank plan on every client, with the static card
|
||||
computing `aspect-ratio: 0 / 0` on top. Sizes now get their own validator:
|
||||
strictly positive, floored at one thousandth of the canvas; coordinates may
|
||||
still be negative, because a crop origin legitimately sits past the edge.
|
||||
And since a store may already hold a broken viewport from before, both
|
||||
cards fall back to the whole canvas instead of a blank screen, and a legacy
|
||||
rectangle with a negative size is read as the same rectangle drawn from the
|
||||
other corner.
|
||||
|
||||
**Also in this release**
|
||||
|
||||
- The room settings button moved to the bottom of the room card, and the room
|
||||
name renders in exactly the same spot in view mode and in the plan editor —
|
||||
the button and the metrics no longer take part in the label's centring.
|
||||
|
||||
## v1.50.2 — 2026-07-29
|
||||
|
||||
**From the v1.50.1 review**
|
||||
|
||||
- **Geometry magnitudes are bounded on both layers (HP-1501-01).** v1.50.1
|
||||
bounded layout positions, but room rectangles, polygon vertices, view_box
|
||||
and opening coordinates still took any finite float — one schema-valid 1e100
|
||||
vertex framed the space so wide the plan was a dot, for every client, and
|
||||
the server stored it as a perfectly good configuration. The config schema
|
||||
now bounds geometry to ±4 (angles to ±360°), and the content frame applies
|
||||
the same canvas envelope to room vertices it already applied to device
|
||||
positions — so a store that already holds an absurd coordinate from before
|
||||
this door existed still renders: the point draws wherever it is, it just no
|
||||
longer commands the frame. A vertex a bit past the canvas edge keeps
|
||||
working.
|
||||
- **A no-op repair no longer eats the undo backup (HP-1501-02).** A typo'd
|
||||
space id "succeeded" with moved: 0 — and its empty result replaced the
|
||||
one-deep backup, destroying the only way back exactly when it was needed
|
||||
most: right after repairing the wrong space. Matching nothing is an error
|
||||
now (`nothing_to_repair`); nothing is written, the revision does not move,
|
||||
and the previous repair stays undoable.
|
||||
|
||||
## v1.50.1 — 2026-07-29
|
||||
|
||||
**From the v1.50.0 review**
|
||||
|
||||
- **A card below other dashboard content gets its stage back (HP-1500-02).**
|
||||
The v1.50.0 height measurement used the absolute document coordinate, so a
|
||||
tall card before this one was billed as "header" and the stage collapsed to
|
||||
zero. The card now measures only its own chrome plus a bounded allowance for
|
||||
what the dashboard keeps above it, and re-measures on window resize; the
|
||||
listener is removed on teardown.
|
||||
- **The content frame can no longer be degenerate or absurd (HP-1500-03).**
|
||||
A lone marker in an empty space produced a zero-area viewBox — a blank
|
||||
scene; a single stored coordinate like 1e100 (any finite float passed
|
||||
validation) stretched the frame until the plan was a dot, for every viewer
|
||||
of the space. A near-zero axis now opens up to a floor of canvas around the
|
||||
marker, points far outside the canvas envelope no longer command the frame
|
||||
(they still render where they are), and the server refuses layout
|
||||
coordinates outside ±4 — generous slack for an icon dragged past an edge,
|
||||
not an envelope for absurdity. A real thin room keeps its tight frame, and
|
||||
the gate sensor slightly past the edge still counts.
|
||||
- **A repair path for installs stranded by the v1.48 migration window
|
||||
(HP-1500-01).** If the old migration crashed between its two writes, the
|
||||
markers of a space are left in the old coordinates with nothing in the data
|
||||
able to prove it — and re-transforming a correct layout would corrupt it, so
|
||||
nothing automatic is safe. `houseplan/geometry/repair {space_id, aspect}` is
|
||||
the explicit answer: `dry_run` previews the exact moves, the previous
|
||||
positions ride the same store write as a one-deep backup, `undo` restores
|
||||
them, and routine drags no longer erase that backup. The v1.50.0
|
||||
`geom_pending` protocol already protects every future migration; this covers
|
||||
the installs it was too late for.
|
||||
|
||||
## v1.50.0 — 2026-07-28
|
||||
|
||||
**Owner's batch**
|
||||
|
||||
- **The default zoom counts devices as content.** They are allowed to stand
|
||||
outside every room — a gate sensor by the fence, a camera on a pole — and the
|
||||
opening view now includes them, even on a space with no rooms at all.
|
||||
- **Entering an editor no longer shifts the plan.** The stage height assumed a
|
||||
fixed 118px of header, and the editor header is taller: the scene slid down
|
||||
by the difference and its bottom went below the fold. The card measures where
|
||||
the stage actually starts and gives it the rest of the viewport.
|
||||
- **The zoom goes out as well as in.** Down to 0.4×, and zoomed out the plan
|
||||
floats centred instead of being pinned to a corner.
|
||||
|
||||
**From the v1.49.0 review**
|
||||
|
||||
- **The square-canvas migration survives a crash between its two writes
|
||||
(HP-1490-01).** Config and layout live in separate stores, written one after
|
||||
the other, and the first write deleted the very fields the second needed — a
|
||||
failure between them stranded markers in the old coordinates for good. The
|
||||
migration intent is durable now, saved before anything moves and cleared by
|
||||
the layout write itself; whichever half is missing after a crash, the next
|
||||
start finishes exactly that half, once.
|
||||
- **Parallel uploads cannot slip past the store quota together (HP-1490-02).**
|
||||
N uploads all measured the store before any of them wrote, and all passed a
|
||||
limit only one of them fit under. The measure-and-write pair is one atomic
|
||||
step under its own lock — separate from the config lock, so a slow directory
|
||||
scan does not stall saves.
|
||||
- **The editors see the whole canvas again (HP-1490-03).** The content frame
|
||||
also bounded pan, zoom and pointer maths, so after the first room there was
|
||||
nowhere left to draw the second one. Edit modes now measure from the full
|
||||
square; the view keeps its content fit, and switching modes refits instead of
|
||||
carrying a view clamped against the wrong base.
|
||||
- **Save waits for the proportions of a picked plan (HP-1490-04).** Saving
|
||||
before the image had answered used to ship the PREVIOUS file's ratio, and the
|
||||
new plan kept the old shape for good. Picking a plan clears the old ratio at
|
||||
once, and Save awaits the bounded read; if it fails, "unknown" is stored —
|
||||
a square fallback is honest, an inherited ratio is not.
|
||||
- Release hygiene from §5: package-lock.json caught up with the package
|
||||
version, and a duplicated comment in space-geometry.ts is gone.
|
||||
|
||||
## v1.49.0 — 2026-07-28
|
||||
|
||||
**The canvas is square** (see v1.48.0, released together with this one).
|
||||
|
||||
- **Zoom opens on what is drawn, not on the whole canvas.** A space without a
|
||||
background image now fits its rooms with a 5% margin, so a small plan on a big
|
||||
canvas fills the screen instead of sitting in the middle of it as a speck.
|
||||
With a background image nothing changes: the image is the plan, and cropping
|
||||
to the rooms would hide the parts nobody has outlined yet.
|
||||
- **Switching spaces by swipe, or on the kiosk carousel, slides.** The plan
|
||||
leaves the way the finger went and the next one arrives from the other side.
|
||||
Respects "reduce motion".
|
||||
- The room settings button says "Room settings" rather than just "Room", and
|
||||
lightens slightly under the cursor.
|
||||
- "Curation" is called filtering everywhere — the interface, the documentation
|
||||
and the code.
|
||||
|
||||
**From the v1.47.0 review**
|
||||
|
||||
- **A plan you have just picked can no longer be deleted from the same dialog
|
||||
(HP-1470-02).** It was not saved yet, so the server correctly considered it
|
||||
free — and the save then stored a url with no file behind it. The button is
|
||||
disabled now, and, because two clients can do the same in either order, the
|
||||
server checks every internal plan url a configuration adds against the disk
|
||||
and refuses a new reference that is already broken. A url the stored
|
||||
configuration already carries is let through — a file can vanish from outside
|
||||
Home Assistant, and refusing then would block the very edit that detaches it.
|
||||
Urls that are not ours are left alone.
|
||||
- **Uploads are bounded (HP-1470-01).** Nothing is deleted for being old — that
|
||||
cost real plans twice — so the limit sits where a decision is being made
|
||||
anyway: an upload is refused if the store would pass 256 MB or 200 plans
|
||||
(1 GB / 1000 for attachments), or if the disk would drop below 512 MB free.
|
||||
The plan list is capped at the 60 newest and its thumbnails load lazily.
|
||||
- **Picking a saved plan reads its real proportions (HP-1470-03).** The card
|
||||
waited for nothing and, when the signature for the protected url had not
|
||||
arrived yet, saved a fallback ratio — a square plan came out stretched. It now
|
||||
waits for the signature, ties the result to the dialog that asked, and the
|
||||
preview in the dialog is signed like everything else.
|
||||
|
||||
## v1.48.0 — 2026-07-28 (the canvas is always square)
|
||||
- **A space no longer has proportions of its own.** The drawing area is a square;
|
||||
a plan image keeps its own shape and is centred inside it, so a wide plan gets
|
||||
margins above and below and a tall one gets them at the sides. There is
|
||||
nothing left to choose — the canvas orientation setting for hand-drawn spaces
|
||||
is gone with it.
|
||||
- **Existing plans are migrated once, on upgrade.** Nothing about a drawing
|
||||
changes: the box is padded out to a square and every coordinate is
|
||||
re-expressed against it — rooms, doors and windows, decor, marker positions
|
||||
and the saved viewport. Angles, room proportions and relative positions are
|
||||
preserved exactly. For a tall plan the scale in centimetres per grid cell is
|
||||
adjusted along with it, because the grid is tied to the width; without that a
|
||||
wall would silently measure less than it does.
|
||||
|
||||
## v1.47.0 — 2026-07-28 (pick a plan you already uploaded)
|
||||
- **The space dialog can now show the plans stored on the server.** Detaching a
|
||||
plan keeps the image on disk — that has been the rule since v1.46.4, but until
|
||||
now the only way back was to find the original file on your computer and
|
||||
upload it again. "Already uploaded" lists what is there, with a thumbnail, the
|
||||
file size and which space uses it. One click attaches it; the aspect ratio is
|
||||
read from the image, exactly as on upload.
|
||||
- **And it is where you delete one.** A plan file is never removed automatically
|
||||
— not for being detached, not for being old — which is only a sensible policy
|
||||
if you can see what is being kept and get rid of it deliberately. The trash
|
||||
button does that, and refuses while a space still uses the plan: the answer to
|
||||
"may this go" comes from the stored configuration, not from the browser.
|
||||
- Documentation caught up with the code: several comments still described the
|
||||
age-based collection that v1.46.6 removed.
|
||||
|
||||
## v1.46.6 — 2026-07-28 (the detach promise, actually kept this time)
|
||||
- **Switching a space to "draw" no longer deletes its image.** v1.46.4 and
|
||||
v1.46.5 said it did not, and the scheduled cleanup indeed left detached plans
|
||||
alone — but the save itself deleted the file the moment the reference was
|
||||
cleared, before any of those guards were reached. The cause: a file that left
|
||||
the configuration was called "superseded", and from that difference alone
|
||||
replacing a plan, detaching one and deleting its space are indistinguishable.
|
||||
Only the first is a deletion anybody asked for. The transition is now
|
||||
classified by the space that owned the file, and the same distinction applies
|
||||
to attachments: dropping one from a device that still exists removes it,
|
||||
deleting the device keeps its manuals.
|
||||
- **A plan whose space was deleted is kept**, rather than the thirty days
|
||||
v1.46.5 promised — thirty days measured from the file's age is meaningless
|
||||
anyway, since it was usually uploaded months earlier.
|
||||
- **Nothing is deleted for being old any more**, except a per-dialog staging
|
||||
folder. The rule that aged out "rejected uploads" turned out to race a retry:
|
||||
the cleanup removed the file from a failed save while the next attempt was
|
||||
committing a reference to it. A rule that can delete a file somebody is about
|
||||
to point at is not worth the disk it reclaims. Files therefore go when an
|
||||
action says so, and otherwise stay.
|
||||
|
||||
## v1.46.5 — 2026-07-28 (audit of every automatic deletion)
|
||||
- **A detached plan is never deleted, at any age.** v1.46.4 gave it a month;
|
||||
this makes it permanent and writes the reason down where the next change will
|
||||
see it. The rule, now in docs/SCOPE.md: the component may delete a file only
|
||||
when a user action says so — replacing a plan, removing an attachment,
|
||||
deleting a device. "Nothing points at this any more" is not such an action.
|
||||
The errors are not symmetrical: wasted disk is visible, cheap and reversible;
|
||||
a deleted file is none of those.
|
||||
- **`houseplan/files/cleanup` no longer takes a folder on the client's word.**
|
||||
After a device is rebound its files are copied to the new id and the old
|
||||
folder is dropped — with `rmtree`, on whatever id the card sent. Two ways that
|
||||
ends badly: a partial copy leaves some urls still pointing into that folder
|
||||
(the migration deliberately does not rewrite those, so they were live links to
|
||||
files being deleted), and a wrong or stale id from any client would destroy a
|
||||
live device's manuals. The server now checks the stored configuration itself,
|
||||
under the config lock, and removes only files nothing references.
|
||||
- **A plan of a space that was deleted waits thirty days instead of an hour.**
|
||||
Deleting a space is deliberate, but an hour is a short window in which to
|
||||
notice it was a misclick.
|
||||
|
||||
## v1.46.4 — 2026-07-28 (data loss: detached plans were collected as garbage)
|
||||
- **A plan you detach is no longer deleted an hour later.** Switching a space to
|
||||
"draw" clears the reference and, as the editor has always said, leaves the
|
||||
image on disk so you can put it back. The collection added in v1.46.0 did not
|
||||
make that distinction: it treated "nothing points at this right now" as
|
||||
abandoned and applied a one-hour rule. On the author's own instance the
|
||||
scheduled pass then removed two floor plans that had been detached weeks
|
||||
earlier, with no way to get them back. If you have detached a plan since
|
||||
v1.46.0 and your instance restarted or ran for a day, check
|
||||
`config/houseplan/plans/` before updating anything else — and please report it
|
||||
in the Telegram chat if a file is missing.
|
||||
The rule now: **a commit still removes exactly what it replaced**, because
|
||||
that it knows for certain. Beyond that the question is whether "unreferenced"
|
||||
means "abandoned", and the answer depends on the case. A space with no plan at
|
||||
all has had one detached and may want it back — its files are never collected.
|
||||
A space that does have a plan can only be holding rejected uploads of its own,
|
||||
so those still go after an hour. Attachments outside a per-dialog staging
|
||||
folder wait a month; a staging folder, which by construction only ever holds
|
||||
an upload from a dialog that was never saved, keeps the one-hour rule.
|
||||
|
||||
## v1.46.3 — 2026-07-28 (re-check of v1.46.2: HP-1462-01)
|
||||
- **The cleanup at startup now actually cleans up.** It looked its own runtime
|
||||
data up by domain, and during startup Home Assistant does not yet consider
|
||||
the integration loaded — so the lookup came back empty and the pass quietly
|
||||
degraded to removing half-finished transfers, leaving the real work to a timer
|
||||
24 hours away. Restart more often than that and it never ran at all. It uses
|
||||
the object it was given at startup now.
|
||||
- **The test that was supposed to prove this was passing for the wrong
|
||||
reason.** It created the stray files *before* saving the configuration — and
|
||||
saving collects too, so everything was already gone by the time the restart
|
||||
happened. Rewritten to seed after the save, plus a second test that fires the
|
||||
scheduled timer on its own, and a third that runs a restart and a save at the
|
||||
same time and asserts the accepted configuration never points at a file the
|
||||
cleanup removed.
|
||||
|
||||
## v1.46.2 — 2026-07-28 (re-check of v1.46.1: HP-1461-01, -02)
|
||||
- **A file nobody ended up using is now cleaned up even if nothing is ever
|
||||
saved again (HP-1461-01).** Collection is tied to a configuration write,
|
||||
|
||||
@@ -6,6 +6,447 @@
|
||||
> **Правило проекта:** оба файла пополняются в одном коммите с самим
|
||||
> изменением — как и остальная документация (см. docs/STATUS.md).
|
||||
|
||||
## v1.53.0 — 2026-07-30
|
||||
|
||||
**Нажатие может запускать вашу автоматизацию** (ТЗ владельца)
|
||||
|
||||
- **Новое действие по нажатию: «Запустить автоматизацию/скрипт/сцену».** Мы
|
||||
не можем знать каждое экзотическое устройство, но вы знаете, что должен
|
||||
делать тап по нему: выберите любую автоматизацию, скрипт или сцену из
|
||||
списка с поиском — тап её запустит (`automation.trigger`,
|
||||
`script.turn_on` или `scene.turn_on` по типу цели, с тостом «Запущено»).
|
||||
Скрипт — идиоматичное «действие» HA, поэтому предлагаются все три
|
||||
запускаемых типа: не нужны автоматизации-пустышки без триггера.
|
||||
Сохранение без цели блокируется; удалённая позже цель подсвечивается в
|
||||
диалоге и безопасно отвечает тостом при тапе.
|
||||
- **Чекбокс «Спрашивать подтверждение»** — защита от случайных нажатий для
|
||||
любого действия, меняющего состояние: и toggle, и запуск, включая
|
||||
настенные выключатели с привязанными целями. Диалог собственный (Esc,
|
||||
клик мимо или «Отмена» — ничего не происходит), поэтому работает и на
|
||||
настенном планшете.
|
||||
- **Шторы переключаются нативно:** cover и valve добавлены в домены
|
||||
card-wide toggle — с одним осознанным исключением: гаражные ворота, двери
|
||||
и калитки (классы garage/door/gate) остаются ВНЕ дефолтного переключения —
|
||||
случайный тап не должен открыть гараж. Явный per-device toggle остаётся
|
||||
осознанным выбором владельца, а замки и охранные панели, как и всегда,
|
||||
с плана неприкосновенны.
|
||||
|
||||
## v1.52.2 — 2026-07-29
|
||||
|
||||
**По ревью v1.52.1** (рантайм не менялся — качество теста и формулировок)
|
||||
|
||||
- Проверка регресса в редакторе плана целится ИМЕННО в лампу (HP-1521-01):
|
||||
старый ассерт принимал любой жёлтый значок, и горящая розетка из той же
|
||||
фикстуры прошла бы его даже без фикса лампы. Проверено мутацией: откат
|
||||
гейта v1.52.1 валит смок.
|
||||
- Последняя формулировка «жёлтый в любом режиме» — пункт чек-листа и
|
||||
комментарий в _stateClass — приведена к фактическому контракту: состояние
|
||||
источника считается условием glow-пула, а жёлтая подложка показывается
|
||||
только там, где пятно не рисуется (HP-1521-02).
|
||||
|
||||
## v1.52.1 — 2026-07-29
|
||||
|
||||
**По ревью v1.52.0**
|
||||
|
||||
- **У горящей лампы всегда ровно один индикатор (HP-1520-01).** В редакторе
|
||||
плана glow-слой скрыт, но подавление жёлтого там всё равно действовало —
|
||||
горящая лампа не показывала ни пятна, ни подложки. Гейт подавления теперь
|
||||
равен фактической видимости слоя: где пятно не рисуется, жёлтая подложка
|
||||
возвращается.
|
||||
- **Статичная карточка уважает размер и поворот маркера (HP-1513-01).** Один
|
||||
и тот же маркер рендерился на read-only карточке базовым и неповёрнутым.
|
||||
Теперь она зеркалит --dev-scale и угол полной карточки — только геометрия,
|
||||
по-прежнему схема.
|
||||
- Документация догнала цветовой контракт v1.52.0 (HP-1520-02): ожидания
|
||||
RGB-окраски в TESTING/UX-MODES и устаревший комментарий в коде.
|
||||
|
||||
## v1.52.0 — 2026-07-29
|
||||
|
||||
**Один вид источников света, чем бы их ни переключали** (правило владельца)
|
||||
|
||||
- **Цвет лампы живёт только в её свечении.** RGB-окраска иконки, рамки и
|
||||
тени убрана: в зависимости от того, пришли ли с состоянием данные о
|
||||
цвете, одна и та же лампа попадала то в ветку «цветная иконка на тёмной
|
||||
подложке», то в «жёлтая подложка» — выключение одной лампы тапом и
|
||||
остальных выключателем выглядело по-разному. Ветки больше нет.
|
||||
- **В режиме glow индикатор — само пятно света.** Подложка источника всегда
|
||||
стандартная, горит он или нет — всё говорит свет вокруг. Розетка,
|
||||
вентилятор или чайник остаются жёлтыми и в glow: они не светят, правило —
|
||||
только для источников.
|
||||
- **В остальных режимах горящий источник — просто жёлтый**, как греющая
|
||||
термоголовка; RGB и обычные лампы одинаково.
|
||||
- Морфинг иконки (контур «лампа с лучами») остаётся во всех режимах, а цвет
|
||||
пульсации по-прежнему берёт фолбэком цвет свечения лампы.
|
||||
|
||||
## v1.51.3 — 2026-07-29
|
||||
|
||||
- **Множитель «размер значка» масштабирует и сам глиф, а не только
|
||||
подложку.** Изменение размера устройства растило подложку, пульсацию и
|
||||
бейджи значений, но иконка внутри оставалась дефолтной — большая пустая
|
||||
рамка вокруг маленького глифа (репорт пользователя). Теперь глиф считается
|
||||
от того же поустройственного размера, что и всё остальное, и держит
|
||||
пропорцию при любом множителе и зуме.
|
||||
|
||||
## v1.51.2 — 2026-07-29
|
||||
|
||||
**По ревью v1.51.1**
|
||||
|
||||
- **Авто-сетка одинакова на обеих карточках (HP-1511-01).** Полная карточка
|
||||
резервирует клетки сетки за скрытыми устройствами (их призраки держат
|
||||
место в редакторе); статичная уплотняла сетку по одним видимым — свежее
|
||||
устройство без сохранённой позиции вставало на разных местах двух
|
||||
карточек. Теперь статичная передаёт сетке полный список и по-прежнему
|
||||
рисует только видимых.
|
||||
- **Ripple-призрак сохраняет базовую иконку (HP-1511-02).** Скрытый маркер с
|
||||
отображением «пульсация» рендерился безликим неподвижным пульсом — в
|
||||
редакторе его нельзя было узнать. Призрак теперь полностью снимает
|
||||
«одежду» отображения: базовая иконка и имя, каким бы ни был режим.
|
||||
|
||||
## v1.51.1 — 2026-07-29
|
||||
|
||||
**По ревью v1.51.0**
|
||||
|
||||
- **Статичная карточка снова учитывает скрытые устройства в LQI комнаты
|
||||
(HP-1510-01).** Её фильтр видимости незаметно стал фильтром агрегации: одна
|
||||
и та же комната показывала разное Zigbee-здоровье на двух карточках. Теперь
|
||||
агрегация и отрисовка используют разные списки — скрытые устройства
|
||||
считаются в сигнале на обеих карточках, не рисуются ни на одной и
|
||||
по-прежнему не дают света.
|
||||
- **Призрак не показывает живых чисел (HP-1510-02).** Скрытое устройство в
|
||||
«Показать скрытые» подавляло цвета состояний, но по-прежнему рисовало
|
||||
значение, температуру, влажность, бейдж LQI и морф иконки. Всё это убрано:
|
||||
у призрака только базовая иконка и имя — достаточно, чтобы узнать
|
||||
устройство и открыть диалог.
|
||||
|
||||
## v1.51.0 — 2026-07-29
|
||||
|
||||
**Скрытие — теперь явная галка** (docs/FILTERING.md)
|
||||
|
||||
- **В диалоге каждого устройства — включая виртуальные — есть галка «Скрыть
|
||||
устройство с плана».** Старый фильтр «на лету» остался только сеятелем
|
||||
этих галок: при первом открытии редактором конфиг один раз
|
||||
материализуется — нефизические устройства (мосты, сцены, служебные
|
||||
интеграции, лампы, свёрнутые в световую группу) получают галку, и дальше
|
||||
она принадлежит вам. Снятая галка — навсегда: сеятель не возвращается к
|
||||
устройству, о котором вы уже решили. Новые нефизические прячутся молча;
|
||||
физические — с красной точкой, как раньше.
|
||||
- **«Показать все» стала «Показать скрытые»** — локальный инструмент
|
||||
редактора устройств (на настенных планшетах ничего не мигает): скрытые
|
||||
рисуются полупрозрачными СИНИМИ пунктирными призраками — не спутать с
|
||||
серым недоступным — и совсем без живой индикации: призрак — это
|
||||
конфигурация, а не статус. Клик — и галку можно снять. «Удалить с плана»
|
||||
у привязанных устройств убрана (галка — единственный путь); «Удалить» у
|
||||
виртуального по-прежнему удаляет.
|
||||
- Скрытые устройства учитываются в Zigbee-сигнале комнаты, но не дают ни
|
||||
пятна света, ни заливки — не видно устройства, не видно и света. Климат
|
||||
комнат без изменений. Старые конфиги ведут себя по-старому, пока их не
|
||||
материализует клиент с правом записи.
|
||||
|
||||
**Жёлтый значит «работает прямо сейчас»**
|
||||
|
||||
- Один принцип для светящегося значка: лампа светит, розетка подаёт,
|
||||
вентилятор крутится, медиа играет, пылесос убирает — или термоголовка
|
||||
РЕАЛЬНО греет (hvac_action), а не просто включена на зиму. Раньше
|
||||
термоголовка могла светиться жёлтым из-за включённой защиты от накипи, а
|
||||
реально греющая оставалась тёмной: выбор главной сущности пропускал
|
||||
служебный свитч производителя вперёд видимого climate. Исправлено —
|
||||
служебная сущность больше никогда не побеждает видимую основную функцию
|
||||
(это чинит и tap-переключение, и морфинг иконок у таких устройств).
|
||||
- Пятно света и цвет значка задают один и тот же вопрос: горящая лампа
|
||||
желтит значок в любом режиме заливки ровно тем условием, что зажигает её
|
||||
пятно. README (en+ru) описывает язык цветов.
|
||||
|
||||
**Редакторы на телефоне**
|
||||
|
||||
- Пинч-зум и панорамирование жестами работают во всех редакторах: рисование
|
||||
кликовое, так что они совместимы — палец с движением панорамирует, два
|
||||
пальца зумируют, отпускание после жеста не ставит точку, чистый тап —
|
||||
ставит.
|
||||
|
||||
**Кнопка настроек комнаты**
|
||||
|
||||
- Отвязана от (передвигаемого) названия: стоит в ВИЗУАЛЬНОМ центре комнаты —
|
||||
центр наибольшей вписанной окружности с тяготением к центроиду площади,
|
||||
поэтому вытянутая комната центрирует её по обеим осям, а Г-образная
|
||||
держит в середине широкой части и не утаскивает в узкую.
|
||||
- Вдвое меньше прежней, размер от иконки устройства (70% бокса) и зумится
|
||||
ВМЕСТЕ с планом, а не держит постоянный экранный размер.
|
||||
- Мелкие подписи под названием комнаты (температура, влажность, сигнал,
|
||||
свет) теперь видны и в редакторе плана, а название рендерится ровно в том
|
||||
же месте в просмотре и в редакторе.
|
||||
|
||||
## v1.50.4 — 2026-07-29
|
||||
|
||||
**По ревью v1.50.3**
|
||||
|
||||
- **Обе карточки строят модель одним кодом (HP-1503-01).** Полная карточка
|
||||
носила рукописную копию общего построителя модели, и копия не получила
|
||||
фолбэки для legacy-store из v1.50.3 — один и тот же битый store в
|
||||
статической карточке рендерился починенным, а в основной — пустым
|
||||
`viewBox="0 0 0 0"`. Дубликат удалён: полная карточка вызывает общий
|
||||
построитель и лишь подменяет сырой url плана, который нужен её подписи.
|
||||
Новый смок прогоняет точный вектор аудита через обе модели и оба
|
||||
DOM-дерева и проверяет паритет.
|
||||
|
||||
## v1.50.3 — 2026-07-29
|
||||
|
||||
**По ревью v1.50.2**
|
||||
|
||||
- **Размер — не координата (HP-1502-01).** Граница ±4 из v1.50.2 мерила все
|
||||
четыре элемента view_box и w/h комнат одинаково, поэтому `[0, 0, 0, 0]` и
|
||||
отрицательные размеры всё ещё проходили — а нулевая ось сериализуется в
|
||||
`viewBox="0 0 0 0"`: пустой план у всех клиентов, и `aspect-ratio: 0 / 0` у
|
||||
статичной карточки сверху. У размеров теперь свой validator: строго
|
||||
положительные, пол — одна тысячная холста; координаты по-прежнему могут
|
||||
быть отрицательными — начало кропа законно выходит за край. А поскольку в
|
||||
сторе битый viewport может уже лежать, обе карточки падают на полный холст
|
||||
вместо пустого экрана, и legacy-прямоугольник с отрицательным размером
|
||||
читается как тот же прямоугольник, нарисованный из другого угла.
|
||||
|
||||
**Также в этом релизе**
|
||||
|
||||
- Кнопка настроек комнаты переехала в самый низ карточки комнаты, а имя
|
||||
комнаты отображается ровно в одном и том же месте в просмотре и в
|
||||
редакторе плана — кнопка и метрики больше не участвуют в центрировании.
|
||||
|
||||
## v1.50.2 — 2026-07-29
|
||||
|
||||
**По ревью v1.50.1**
|
||||
|
||||
- **Величины геометрии ограничены на обоих слоях (HP-1501-01).** v1.50.1
|
||||
ограничила позиции устройств, но прямоугольники комнат, вершины полигонов,
|
||||
view_box и координаты проёмов всё ещё принимали любое конечное число — одна
|
||||
проходящая схему вершина 1e100 растягивала кадр так, что план становился
|
||||
точкой у всех клиентов, и сервер хранил это как вполне корректную
|
||||
конфигурацию. Теперь схема конфига ограничивает геометрию ±4 (углы ±360°),
|
||||
а рамка содержимого применяет к вершинам комнат тот же конверт холста, что
|
||||
уже применяла к позициям устройств — конфиг, где абсурдная координата уже
|
||||
лежит с прежних времён, всё равно рендерится: точка рисуется, где стоит,
|
||||
просто кадром больше не командует. Вершина чуть за краём холста работает
|
||||
как раньше.
|
||||
- **Пустой repair больше не съедает бэкап (HP-1501-02).** Опечатка в space_id
|
||||
«успешно» отвечала moved: 0 — и её пустой результат заменял бэкап глубиной
|
||||
один, уничтожая единственный путь назад ровно тогда, когда он нужнее всего:
|
||||
сразу после починки не того пространства. Теперь «нечего чинить» — ошибка
|
||||
(`nothing_to_repair`): ничего не пишется, ревизия не растёт, предыдущий
|
||||
repair по-прежнему отменяем.
|
||||
|
||||
## v1.50.1 — 2026-07-29
|
||||
|
||||
**По ревью v1.50.0**
|
||||
|
||||
- **Карточка ниже другого контента дашборда снова получает сцену
|
||||
(HP-1500-02).** Замер высоты в v1.50.0 брал абсолютную координату документа:
|
||||
высокая карточка перед этой записывалась в «шапку», и сцена схлопывалась в
|
||||
ноль. Теперь меряется только собственная обвязка плюс ограниченная поправка
|
||||
на то, что дашборд держит сверху; перемер — по resize окна, слушатель
|
||||
снимается при демонтаже.
|
||||
- **Рамка содержимого больше не бывает вырожденной или абсурдной
|
||||
(HP-1500-03).** Одинокий значок в пустом пространстве давал viewBox нулевой
|
||||
площади — пустую сцену; одна сохранённая координата вида 1e100 (любое
|
||||
конечное число проходило проверку) растягивала рамку так, что план
|
||||
становился точкой — у всех зрителей пространства. Почти нулевая ось теперь
|
||||
раскрывается до минимального кадра вокруг значка, точки далеко за холстом
|
||||
рамкой не командуют (рисуются, где стоят), а сервер отклоняет координаты вне
|
||||
±4 — щедрый запас для значка, утащенного за край, но не для абсурда. Узкая
|
||||
комната сохраняет тесный кадр, датчик калитки чуть за краём по-прежнему
|
||||
учитывается.
|
||||
- **Путь восстановления для установок, застрявших в окне миграции v1.48
|
||||
(HP-1500-01).** Если старая миграция упала между двумя записями, значки
|
||||
пространства остаются в старых координатах, и по данным это недоказуемо — а
|
||||
повторное преобразование правильных координат их испортит, поэтому ничего
|
||||
автоматического тут быть не может. Явный ответ —
|
||||
`houseplan/geometry/repair {space_id, aspect}`: `dry_run` показывает точные
|
||||
перемещения, прежние позиции уезжают той же записью хранилища как резервная
|
||||
копия глубиной один, `undo` их возвращает, и обычные перетаскивания эту
|
||||
копию больше не затирают. Протокол `geom_pending` из v1.50.0 уже защищает
|
||||
все будущие миграции; это — для тех, кому он опоздал.
|
||||
|
||||
## v1.50.0 — 2026-07-28
|
||||
|
||||
**Задачи владельца**
|
||||
|
||||
- **Масштаб по умолчанию считает устройства содержимым.** Им можно стоять вне
|
||||
комнат — датчик калитки у забора, камера на столбе — и стартовый вид теперь
|
||||
включает их, даже в пространстве совсем без комнат.
|
||||
- **Вход в редактор больше не сдвигает план.** Высота сцены считалась как
|
||||
«экран минус 118px шапки», а шапка редактора выше: сцена уезжала вниз на
|
||||
разницу, низ пропадал за краём. Карточка измеряет, где сцена начинается на
|
||||
самом деле, и отдаёт ей остаток экрана.
|
||||
- **Масштаб теперь и отдаляется.** До 0.4×; в отдалении план висит по центру,
|
||||
а не прилипает к углу.
|
||||
|
||||
**По ревью v1.49.0**
|
||||
|
||||
- **Миграция на квадратный холст переживает сбой между двумя записями
|
||||
(HP-1490-01).** Конфиг и позиции живут в разных хранилищах, пишутся по
|
||||
очереди, и первая запись удаляла именно те поля, которые нужны второй — сбой
|
||||
между ними навсегда оставлял значки в старых координатах. Теперь намерение
|
||||
миграции сохраняется до того, как что-либо меняется, и снимается той же
|
||||
записью, что сохраняет позиции: какая половина не успела — ту следующий
|
||||
запуск и доделает, ровно один раз.
|
||||
- **Параллельные загрузки не проскакивают квоту вместе (HP-1490-02).** N
|
||||
загрузок мерили хранилище до того, как любая из них записала файл, и все
|
||||
проходили предел, под который помещалась одна. Замер и запись — один
|
||||
атомарный шаг под отдельным замком; отдельным — чтобы медленный обход папки
|
||||
не тормозил сохранения конфига.
|
||||
- **Редакторы снова видят весь холст (HP-1490-03).** Рамка содержимого
|
||||
ограничивала и панорамирование, и координаты указателя, так что после первой
|
||||
комнаты рисовать вторую было негде. Режимы редактирования меряют от полного
|
||||
квадрата; просмотр остаётся по содержимому, а смена режима пересчитывает
|
||||
вид, вместо того чтобы тащить его прижатым не к той основе.
|
||||
- **Сохранение ждёт пропорции выбранного плана (HP-1490-04).** Save до ответа
|
||||
картинки записывал пропорции ПРЕДЫДУЩЕГО файла, и новый план навсегда
|
||||
оставался в чужой форме. Выбор плана сразу стирает старое значение, Save
|
||||
дожидается ограниченного по времени чтения; не дождался — записывается
|
||||
«неизвестно»: честный квадрат лучше унаследованной формы.
|
||||
- Гигиена релиза из §5: package-lock.json догнал версию пакета, задвоенный
|
||||
комментарий в space-geometry.ts убран.
|
||||
|
||||
## v1.49.0 — 2026-07-28
|
||||
|
||||
**Холст стал квадратным** (см. v1.48.0, выпущена вместе с этой).
|
||||
|
||||
- **Масштаб открывается по нарисованному, а не по всему холсту.** Пространство
|
||||
без подложки теперь вписывается по границам своих комнат с полями 5%: маленький
|
||||
план на большом холсте заполняет экран, а не сидит посередине точкой. С
|
||||
подложкой ничего не меняется — картинка и есть план, и обрезать её по комнатам
|
||||
значило бы спрятать то, что ещё не обведено.
|
||||
- **Переключение пространств свайпом и в киоске стало с анимацией.** План
|
||||
уезжает туда, куда пошёл палец, следующий приходит с другой стороны.
|
||||
Уважает системную настройку «уменьшить движение».
|
||||
- Кнопка настроек комнаты подписана «Настройки комнаты», а не просто «Комната»,
|
||||
и слегка светлеет под курсором.
|
||||
- Слово «курирование» заменено на «фильтрацию» — в интерфейсе, документации и
|
||||
коде.
|
||||
|
||||
**По ревью v1.47.0**
|
||||
|
||||
- **Только что выбранный план больше нельзя удалить из того же диалога
|
||||
(HP-1470-02).** Он ещё не сохранён, поэтому сервер справедливо считал его
|
||||
свободным — а сохранение потом записывало ссылку, за которой нет файла.
|
||||
Кнопка заблокирована, и, поскольку два клиента могут сделать это в любом
|
||||
порядке, сервер сверяет с диском каждую внутреннюю ссылку, которую
|
||||
конфигурация добавляет, и отказывает, если файла нет. Ссылку, уже записанную
|
||||
в конфигурации, он пропускает: файл может исчезнуть и мимо Home Assistant, а
|
||||
отказ заблокировал бы ровно ту правку, которая его отцепляет. Чужие ссылки не
|
||||
трогаются.
|
||||
- **Загрузки ограничены (HP-1470-01).** По возрасту не удаляется ничего — это
|
||||
дважды стоило настоящих планов, — поэтому предел стоит там, где решение и так
|
||||
принимается: загрузка отклоняется, если хранилище перевалит за 256 МБ или 200
|
||||
планов (1 ГБ и 1000 для вложений) либо если на диске останется меньше 512 МБ.
|
||||
Список планов отдаётся по 60 самых свежих, миниатюры грузятся лениво.
|
||||
- **Выбор сохранённого плана читает его настоящие пропорции (HP-1470-03).**
|
||||
Карточка ничего не ждала и, если подпись для защищённой ссылки ещё не пришла,
|
||||
записывала пропорции «по умолчанию» — квадратный план получался растянутым.
|
||||
Теперь она дожидается подписи, привязывает результат к тому диалогу, который
|
||||
спрашивал, а превью в диалоге подписывается, как и всё остальное.
|
||||
|
||||
## v1.48.0 — 2026-07-28 (холст всегда квадратный)
|
||||
- **У пространства больше нет собственных пропорций.** Область рисования —
|
||||
квадрат, а картинка плана сохраняет свою форму и вписывается в него по
|
||||
центру: у широкого плана появляются поля сверху и снизу, у вытянутого — по
|
||||
бокам. Выбирать нечего, поэтому настройка ориентации холста для пространств
|
||||
без картинки убрана.
|
||||
- **Существующие планы переносятся один раз, при обновлении.** В самом рисунке
|
||||
ничего не меняется: коробка дополняется до квадрата, и все координаты
|
||||
пересчитываются относительно неё — комнаты, двери и окна, декор, позиции
|
||||
значков и сохранённая область просмотра. Углы, пропорции комнат и взаимное
|
||||
расположение сохраняются точно. Для вытянутых планов заодно пересчитывается
|
||||
масштаб в сантиметрах на клетку: сетка привязана к ширине, и без этого стены
|
||||
молча стали бы короче.
|
||||
|
||||
## v1.47.0 — 2026-07-28 (выбор из уже загруженных планов)
|
||||
- **Диалог пространства показывает планы, сохранённые на сервере.** Отцепление
|
||||
плана оставляет картинку на диске — так с v1.46.4, но вернуть её можно было
|
||||
только найдя исходный файл у себя и загрузив заново. «Уже загруженные»
|
||||
показывают, что есть: миниатюра, размер файла и то, какое пространство его
|
||||
использует. Клик прикрепляет, пропорции читаются из самой картинки — так же,
|
||||
как при загрузке.
|
||||
- **Там же план и удаляется.** Файл плана никогда не удаляется автоматически —
|
||||
ни за отцепление, ни за возраст, — и это разумная политика ровно до тех пор,
|
||||
пока видно, что именно хранится, и есть способ убрать это осознанно. Кнопка
|
||||
корзины делает это и отказывает, пока план используется пространством: ответ
|
||||
на вопрос «можно ли удалить» даёт сохранённая конфигурация, а не браузер.
|
||||
- Документация догнала код: несколько комментариев всё ещё описывали удаление по
|
||||
возрасту, убранное в v1.46.6.
|
||||
|
||||
## v1.46.6 — 2026-07-28 (обещание про отцепление, теперь выполненное)
|
||||
- **Переключение пространства в «нарисовать» больше не удаляет его картинку.**
|
||||
v1.46.4 и v1.46.5 утверждали, что не удаляет, и плановая уборка действительно
|
||||
отцеплённые планы не трогала — но само сохранение удаляло файл в тот момент,
|
||||
когда снималась ссылка, ещё до всех этих проверок. Причина: файл, покинувший
|
||||
конфигурацию, считался «заменённым», а по одной этой разнице замену плана,
|
||||
отцепление и удаление пространства различить невозможно. Удалением, о котором
|
||||
просили, является только первое. Теперь переход классифицируется по
|
||||
пространству-владельцу, и та же разница применяется к вложениям: убрали файл у
|
||||
существующего устройства — он удаляется, удалили устройство — его инструкции
|
||||
остаются.
|
||||
- **План удалённого пространства сохраняется**, а не тридцать дней, как обещала
|
||||
v1.46.5: тридцать дней по возрасту файла всё равно бессмысленны — обычно он
|
||||
загружен месяцы назад.
|
||||
- **По возрасту больше не удаляется ничего**, кроме промежуточной папки диалога.
|
||||
Правило, которое вычищало «отвергнутые загрузки», оказалось в гонке с
|
||||
повторной попыткой: уборка удаляла файл неудавшегося сохранения ровно тогда,
|
||||
когда следующая попытка коммитила ссылку на него. Правило, способное удалить
|
||||
файл, на который кто-то вот-вот сошлётся, не стоит освобождаемого места.
|
||||
Файлы уходят по действию, в остальных случаях остаются.
|
||||
|
||||
## v1.46.5 — 2026-07-28 (ревизия всех автоматических удалений)
|
||||
- **Отцеплённый план не удаляется никогда, ни в каком возрасте.** В v1.46.4 ему
|
||||
давался месяц; теперь это навсегда, и причина записана там, где её увидит
|
||||
следующая правка. Правило, оно же в docs/SCOPE.md: компонент вправе удалить
|
||||
файл только тогда, когда об этом говорит действие пользователя — замена
|
||||
плана, удаление вложения, удаление устройства. «На это больше никто не
|
||||
ссылается» таким действием не является. Ошибки несимметричны: занятое зря
|
||||
место видно, стоит копейки и обратимо; удалённый файл — ничего из этого.
|
||||
- **`houseplan/files/cleanup` больше не сносит папку по слову клиента.** После
|
||||
перепривязки устройства файлы копируются под новый id, а старая папка
|
||||
удалялась — через `rmtree`, по тому id, который прислала карточка. Два плохих
|
||||
исхода: при частичном копировании часть ссылок продолжает указывать внутрь
|
||||
этой папки (миграция намеренно их не переписывает — то есть это были живые
|
||||
ссылки на удаляемые файлы), а неверный или устаревший id от любого клиента
|
||||
уничтожил бы инструкции существующего устройства. Теперь сервер сам сверяется
|
||||
с сохранённой конфигурацией, под её блокировкой, и удаляет только то, на что
|
||||
никто не ссылается.
|
||||
- **План удалённого пространства ждёт тридцать дней вместо часа.** Удаление
|
||||
пространства осознанно, но час — короткое окно, чтобы заметить промах.
|
||||
|
||||
## v1.46.4 — 2026-07-28 (потеря данных: отцеплённые планы убирались как мусор)
|
||||
- **Отцеплённый план больше не удаляется через час.** Переключение пространства
|
||||
в режим «нарисовать» снимает ссылку и, как редактор всегда и говорил,
|
||||
оставляет картинку на диске, чтобы её можно было вернуть. Уборка, добавленная
|
||||
в v1.46.0, этой разницы не делала: считала «на файл сейчас никто не
|
||||
ссылается» синонимом «файл брошен» и применяла часовое правило. На установке
|
||||
автора плановый проход в итоге удалил два плана этажей, отцеплённых
|
||||
несколькими неделями раньше, — восстановить их было нечем. Если вы отцепляли
|
||||
план после v1.46.0 и инстанс перезапускался или проработал сутки — загляните в
|
||||
`config/houseplan/plans/` и напишите в Telegram-чат, если файла нет.
|
||||
Правило теперь такое: **коммит по-прежнему удаляет ровно то, что заменил**, —
|
||||
это он знает наверняка. Дальше вопрос в том, означает ли «непривязан»
|
||||
«брошен», и ответ зависит от случая. У пространства, у которого плана нет
|
||||
вовсе, его отцепили — и, возможно, вернут: его файлы не удаляются никогда. У
|
||||
пространства, у которого план есть, лишние файлы могут быть только его же
|
||||
отвергнутыми загрузками — они по-прежнему уходят через час. Вложения вне
|
||||
промежуточной папки диалога ждут месяц; сама промежуточная папка, где по
|
||||
построению лежит только загрузка из несохранённого диалога, сохраняет часовое
|
||||
правило.
|
||||
|
||||
## v1.46.3 — 2026-07-28 (перепроверка v1.46.2: HP-1462-01)
|
||||
- **Уборка при старте действительно убирает.** Она искала свои же runtime-данные
|
||||
по домену, а во время запуска Home Assistant ещё не считает интеграцию
|
||||
загруженной — поэтому поиск возвращал пустоту, и проход тихо вырождался в
|
||||
удаление незавершённых передач, оставляя настоящую работу таймеру через
|
||||
24 часа. При перезапусках чаще, чем раз в сутки, она не выполнялась вообще.
|
||||
Теперь используется объект, который у неё и так был на руках.
|
||||
- **Тест, который должен был это доказать, проходил по неверной причине.** Он
|
||||
создавал лишние файлы *до* сохранения конфигурации, а сохранение тоже
|
||||
собирает мусор — так что к моменту перезапуска убирать было уже нечего.
|
||||
Переписан: файлы создаются после сохранения; добавлен второй тест, который
|
||||
дёргает плановый таймер отдельно, и третий, который запускает перезапуск и
|
||||
сохранение одновременно и проверяет, что принятая конфигурация никогда не
|
||||
ссылается на удалённый уборкой файл.
|
||||
|
||||
## v1.46.2 — 2026-07-28 (перепроверка v1.46.1: HP-1461-01, -02)
|
||||
- **Файл, который в итоге никому не понадобился, убирается, даже если больше
|
||||
ничего не сохраняют (HP-1461-01).** Сборка привязана к записи конфигурации —
|
||||
|
||||
+7
-2
@@ -49,8 +49,13 @@ cp dist/houseplan-card.js custom_components/houseplan/frontend/
|
||||
|
||||
## Deployment to the dacha (ha.jbstudio.pro)
|
||||
|
||||
- SSH: port **323**, root, key `ha_jb` (the user uploads it to the chat; in the sandbox /tmp/ha_jb, chmod 600).
|
||||
- JS: `scp -P 323 -i /tmp/ha_jb dist/houseplan-card.js root@ha.jbstudio.pro:/config/custom_components/houseplan/frontend/`
|
||||
- SSH: port **22222**, root, key `ha_jb` (lives in the user folder `houseplan/.secrets/ha_jb`,
|
||||
outside git; copy into the sandbox with chmod 600 — only ask the user if it is gone).
|
||||
- **The HA config root is `/mnt/data/supervisor/homeassistant`** — in this SSH
|
||||
environment `/config` does not exist; a deploy aimed at `/config/...` fails
|
||||
with "No such file or directory".
|
||||
- JS: `scp -P 22222 -i <key> dist/houseplan-card.js root@ha.jbstudio.pro:/mnt/data/supervisor/homeassistant/custom_components/houseplan/frontend/`
|
||||
- Cache busting: `sed` the `?v=` version in `.storage/lovelace_resources`, then restart HA.
|
||||
- **The `frontend/` subfolder is not optional.** `__init__.py` registers
|
||||
`Path(__file__).parent / "frontend" / "houseplan-card.js"` as the static path.
|
||||
A copy dropped next to `__init__.py` (…/houseplan/houseplan-card.js) is served
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# Filtering: the explicit "hide from plan" flag
|
||||
|
||||
Agreed with the owner 2026-07-29. This document is the source of truth for the
|
||||
mechanism; the code follows it.
|
||||
|
||||
## Principle
|
||||
|
||||
Whether a device is on the plan is an EXPLICIT, per-device fact: the
|
||||
"Hide from plan" checkbox, stored as `marker.hidden`. The old on-the-fly
|
||||
filtering algorithm survives only as the SEEDER of those flags — it decides
|
||||
the initial value once, and the user owns the flag from then on.
|
||||
|
||||
## Data model
|
||||
|
||||
- `marker.hidden: true` — hidden from the plan. For an auto device without a
|
||||
marker, hiding creates a stub marker (this mechanism predates this spec).
|
||||
- `marker.hidden: false` (marker present) — explicitly VISIBLE: the seeder
|
||||
never touches a device that has any marker, so unhiding must KEEP the stub
|
||||
marker. That is the re-seed protection.
|
||||
- No marker — never evaluated by the seeder yet, or a plain physical device.
|
||||
- `settings.filter_seeded: true` — this config has been materialised.
|
||||
- `settings.show_all` — removed (deleted during materialisation). The old
|
||||
toggle was shared config state; the new one is a local editor tool.
|
||||
|
||||
## Seeding
|
||||
|
||||
"Non-physical" = the old filter rules: excluded integration domains, model
|
||||
"Group", scene-like models, bridges, myheat children, and individual lamp
|
||||
devices in an area covered by a light group (when group folding is on).
|
||||
|
||||
The seeder runs on the editing client (write permission required) whenever
|
||||
devices rebuild, and creates `hidden: true` stub markers for non-physical
|
||||
devices in BOUND areas that have NO marker. It is idempotent: marked devices
|
||||
are never revisited. It fires on:
|
||||
|
||||
1. first load of a config without `filter_seeded` (materialises the current
|
||||
behaviour; nothing changes visually, the flags become real and editable);
|
||||
2. an area newly bound to the plan;
|
||||
3. a new device appearing in a bound area — non-physical ones are hidden
|
||||
silently (no red dot); physical ones keep the red-dot flow.
|
||||
|
||||
Until a config is seeded (`filter_seeded` absent), `buildDevices` applies the
|
||||
LEGACY runtime filter, so a read-only client on an old config sees exactly
|
||||
the old behaviour until an editing client materialises it.
|
||||
|
||||
## Behaviour
|
||||
|
||||
- Hidden devices ARE built (flagged `hidden`), but not rendered in any mode,
|
||||
except the device editor with "Show hidden devices" on — there they render
|
||||
ghosted (translucent, dashed) and clicking opens the dialog to untick.
|
||||
- "Show hidden devices" (rename of "Show all") is LOCAL, ephemeral state of
|
||||
the current tab.
|
||||
- Room LQI counts hidden devices (owner's decision).
|
||||
- Light fill and glow do NOT count hidden devices — an invisible device casts
|
||||
no visible light (owner's decision). Room climate is registry-wide and
|
||||
unaffected, as before.
|
||||
- The checkbox appears in the dialog of EVERY device kind, virtual included.
|
||||
- "Remove from plan" disappears for auto/entity devices (the checkbox is the
|
||||
one way to hide); a virtual device's "Delete" remains a real deletion.
|
||||
- Duplicate names are still numbered, light groups still fold — those are
|
||||
aggregation, not hiding.
|
||||
+14
-12
@@ -35,12 +35,12 @@ Track progress in `custom_components/houseplan/quality_scale.yaml` (done/exempt
|
||||
- [ ] `test-coverage` ≥95% backend; frontend: extract remaining pure logic (view math,
|
||||
marker resolution) into `logic.ts`/`devices.ts` and cover with node:test.
|
||||
- [x] `diagnostics.py`: config + layout dump with `async_redact_data` (redact names/links/PDF paths).
|
||||
- [ ] `reconfiguration-flow` + richer **options flow**: admin_only, curation defaults
|
||||
- [ ] `reconfiguration-flow` + richer **options flow**: admin_only, filtering defaults
|
||||
(exclude domains — UI editable, replacing the hardcoded EXCLUDED_DOMAINS fallback),
|
||||
LQI thresholds, group_lights default.
|
||||
- [ ] `repair-issues`: broken plan file references, orphaned layout entries, storage
|
||||
migration failures → Repairs UI instead of silent logs.
|
||||
- [ ] `system_health.py`: config rev, spaces/markers count, storage sizes.
|
||||
- [x] `repair-issues`: broken plan references in Repairs (repairs.py, re-checked on
|
||||
every config save); geometry/repair WS command for stranded migrations (v1.50.1).
|
||||
- [x] `system_health.py` (v1.12.0).
|
||||
- [ ] `exception-translations` + `icon-translations` where applicable.
|
||||
- [ ] Frontend resource registration: adopt the community-consensus embedded-card pattern
|
||||
end-to-end (we already do StaticPathConfig + storage-mode resource + `?v=` busting;
|
||||
@@ -48,26 +48,28 @@ Track progress in `custom_components/houseplan/quality_scale.yaml` (done/exempt
|
||||
|
||||
## Phase 9 — Universality & flexibility (product depth)
|
||||
|
||||
- [ ] **Areas/floors registry integration**: import HA floors as spaces, suggest area
|
||||
- [x] **Areas/floors registry integration**: floors-import wizard (v1.13.0); further: suggest area
|
||||
bindings from the registry, sync names (HA is moving this way — native Areas/Home
|
||||
dashboard; riding the registry is our moat).
|
||||
- [ ] **Curation without hardcode**: icon rules (`iconFor`) become data — user-editable
|
||||
- [x] **Filtering without hardcode**: icon rules became data (v1.13.0); the runtime
|
||||
filter itself became explicit per-device hide flags in v1.51.0 (docs/FILTERING.md);
|
||||
icon rules are user-editable
|
||||
mapping (regex/domain/device_class → mdi icon) stored in config, shipping EN+RU
|
||||
defaults; drop dacha-specific patterns from code.
|
||||
- [ ] **Click actions** per device/domain: toggle / more-info / navigate / custom service
|
||||
call (configurable, like standard card `tap_action`).
|
||||
- [ ] **Theming**: respect light themes (currently dark-leaning), use HA theme variables
|
||||
- [x] **Click actions** per device (v1.13.0, simplified v1.38.1): card / more-info /
|
||||
toggle, with the lock/alarm safety model.
|
||||
- [x] **Theming**: light-theme pass done in v1.13.0; HA theme variables
|
||||
everywhere, optional per-space background color.
|
||||
- [ ] Multi-instance question: keep single-instance (one house) but support **multiple
|
||||
cards** with different default spaces (already works) — document as a decision.
|
||||
- [ ] Plan formats: keep SVG/PNG/JPG/WebP; add max dimensions guidance; optional
|
||||
auto-downscale on upload.
|
||||
- [ ] More locales: extract i18n dictionaries to JSON so contributors can add languages
|
||||
without touching TS.
|
||||
- [x] More locales: i18n dictionaries are JSON since v1.13.0 (src/i18n/*.json).
|
||||
|
||||
## Phase 10 — Community & distribution
|
||||
|
||||
- [ ] hacs/default PR #8995 through moderation (expect drafting for fixes).
|
||||
- [ ] hacs/default PR **#9004** through moderation (#8995 was bot-closed for a
|
||||
non-template body; #9004 is queued with the label since 2026-07-22).
|
||||
- [ ] Demo GIF/video for README (the single biggest driver of adoption for dashboard cards).
|
||||
- [ ] Forum post in the Floorplan category + Reddit r/homeassistant showcase once
|
||||
the demo assets exist.
|
||||
|
||||
+13
-1
@@ -34,7 +34,7 @@ Editors are admin-only tools and must never leak interactions into View
|
||||
| J1 | "Show the whole home and what's happening right now" — live spatial overview: device states, room fills (light/temp/LQI), values, multi-floor tabs | **Closed** |
|
||||
| J2 | "Something is wrong — show me *where*" — leak/smoke/gas pulse, open doors/windows, unlocked locks, red dot on devices HA added silently | **Closed** |
|
||||
| J3 | "Let me act on the obvious right from the plan" — tap-to-toggle for safe domains, info cards, guarded lock action | **Closed** |
|
||||
| J4 | "From zero to a working plan in one evening, no Inkscape/YAML" — image/PDF/draw, floors-import wizard, room polygons bound to areas, curated auto-placement, editable icon rules | **Closed**; onboarding polish is *partial* (no registry-driven room suggestions) |
|
||||
| J4 | "From zero to a working plan in one evening, no Inkscape/YAML" — image/PDF/draw, floors-import wizard, room polygons bound to areas, filtered auto-placement, editable icon rules | **Closed**; onboarding polish is *partial* (no registry-driven room suggestions) |
|
||||
| J5 | "Room climate at a glance" — per-room temperature/humidity, comfort-range fills, room-card metrics | **Closed** |
|
||||
| J6 | "Keep the plan true as the home evolves" — new-device flag, two editors, drag/resize, merge/split, multi-client live sync, optimistic locking | **Closed** |
|
||||
| J7 | "Is my Zigbee mesh healthy *here*?" — LQI badges, per-room average, LQI fill | **Closed** (kept deliberately: cheap, spatial by nature, no in-plan competitor) |
|
||||
@@ -66,6 +66,18 @@ refuse locks or be added to this paragraph.
|
||||
- Plan-level "security glance": one badge for "all locked / N open" (J2).
|
||||
- Threshold colouring for room-card metrics (J5).
|
||||
|
||||
## Standing rule: never delete a user's file on an inference
|
||||
|
||||
Fixed with the owner on 2026-07-28, after automatic collection removed two
|
||||
detached floor plans. The component may delete a file only when the user's
|
||||
action says so — replacing a plan, removing an attachment, deleting a device.
|
||||
"Nothing points at this any more" is not such an action: detaching a plan is one
|
||||
click and reversible, and the editor tells the user the file stays.
|
||||
|
||||
The asymmetry is the whole argument. Wasted disk is visible, cheap and
|
||||
reversible; a deleted file is none of those. Where the evidence is weak, keep
|
||||
the file — and if a future version wants to reclaim that space, it asks.
|
||||
|
||||
## Out of scope — never build, point users to the right tool
|
||||
|
||||
- Automations, scenes, scripts, notifications → HA core.
|
||||
|
||||
+30
-9
@@ -11,16 +11,16 @@
|
||||
> (versions, publication, infrastructure), DEVELOPMENT.md for new gotchas,
|
||||
> ARCHITECTURE.md for design changes, ROADMAP.md when plans move.
|
||||
|
||||
## Snapshot (2026-07-28)
|
||||
## Snapshot (2026-07-29)
|
||||
|
||||
| Item | State |
|
||||
|---|---|
|
||||
| Version | **v1.46.2** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
|
||||
| Version | **v1.53.0** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
|
||||
| Workflow | Since 2026-07-22: minor changes go to branch **`dev`** (build + smokes → deploy home → commit → push, NO release); releases are batched on the owner's command (merge dev→main, one tag, one release with a summary changelog, CI checked on dev beforehand) |
|
||||
| GitHub | https://github.com/Matysh/houseplan-card — **`main` carries every published release, the latest tag is the current version above**; `dev` is where work lands and is merged into `main` at release time (so `dev` is normally equal to or ahead of `main`, never behind). Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
|
||||
| CI | validate.yml (hacs + hassfest + frontend + backend) green; release.yml attaches the bundle on release publish |
|
||||
| HACS | Custom repository works. **Inclusion PR: hacs/default#9004** — open, valid, labeled; ~864 older open PRs but merge rate ≈180/mo; realistic ETA 1–3 months (checked 2026-07-24) |
|
||||
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.46.2** via direct copy (HACS custom repo also installed) |
|
||||
| HACS | Custom repository works. **Inclusion PR: hacs/default#9004** — open, valid, labeled, mergeable clean, never drafted. Queue: 1212 open, 835 older than ours. Merge rate COLLAPSED: 75 in July but almost all in the first decade, 0 in the last week (checked 2026-07-29) — maintainers process in rare bursts; ETA unknowable, months at best. Nothing actionable on our side |
|
||||
| Home instance | ha.jbstudio.pro (SSH port **22222**, key `ha_jb`; HA config root is `/mnt/data/supervisor/homeassistant` — `/config` does NOT exist in this SSH environment), deployed **v1.53.0** via direct copy (HACS custom repo also installed) |
|
||||
| Localization | UI en/ru (src/i18n/*.json), everything user-visible localized incl. kiosk popover |
|
||||
| Tests | Four layers: frontend unit (`npm test`, node:test over `test-build/`), pure backend (`pytest tests_backend`, runs anywhere), HA-harness backend (same folder, CI only — needs py3.13 + pytest-homeassistant-custom-component), and browser smokes (`demo/smoke_*.mjs`, headless chromium). **Counts are not written down here** — they went stale within two releases while the version line beside them was kept current, which reads as less coverage than exists (review R5-2). Run `npm run inventory` for the current numbers, or read them off the last CI run |
|
||||
| Community | **Telegram chat: https://t.me/ha_houseplan** (created 2026-07-27) — the primary user-facing support channel; GitHub issues stay for bugs/features. Link it from any new release notes and posts |
|
||||
@@ -48,6 +48,24 @@
|
||||
- **Dialog UX**: binding radios + entities checkbox + search dropdown
|
||||
(v1.38.0); tap actions simplified to Device card / more-info / Toggle,
|
||||
right-click → more-info (v1.38.1); Esc closes every dialog (v1.30.4).
|
||||
- **Room settings, tier 3** (v1.42.0): per-room fill/temp-source/label sizes;
|
||||
the settings button sits at the room's VISUAL centre (inscribed circle +
|
||||
centroid pull), icon-derived size, zooms with the plan (v1.51.0).
|
||||
- **Files & plans** (v1.44–v1.50): signed content urls with sandbox CSP,
|
||||
copy-on-write plan files, "already uploaded" picker + explicit delete
|
||||
(v1.47.0), store quotas instead of any age-based deletion (v1.49.0),
|
||||
nothing is ever deleted on an inference (docs/SCOPE.md rule).
|
||||
- **Square canvas** (v1.48.0) with a crash-safe two-store migration
|
||||
(geom_pending, v1.50.0) and an explicit geometry/repair command (v1.50.1);
|
||||
content-fit default zoom with devices as content, zoom out to 0.4×,
|
||||
measured stage height (v1.49–v1.50.2).
|
||||
- **Explicit hide flags** (v1.51.0, docs/FILTERING.md): per-device
|
||||
"Hide from plan" checkbox seeded once from the old filter; local
|
||||
"Show hidden" ghosts; hidden counts toward room LQI on both cards, casts
|
||||
no light (v1.51.1).
|
||||
- **Yellow = working right now** (v1.51.0): climate by hvac_action, service
|
||||
switches can no longer become primary, glow pool and icon share one
|
||||
condition. Editor gestures on touch (pinch/pan) landed the same release.
|
||||
|
||||
## Recent milestones (details in CHANGELOG.md)
|
||||
|
||||
@@ -129,8 +147,9 @@
|
||||
|
||||
1. **hacs/default PR #9004** — accepted by the bot into the review queue ('New default
|
||||
repository' label). Minor issues ⇒ the bot drafts the PR (fix and re-ready).
|
||||
2. GitHub PAT `houseplan-card-publish` (repo+workflow) expires ~2026-07-12; in sandbox
|
||||
`~/.git-credentials`. Revoke after the HACS queue clears, or re-issue when needed.
|
||||
2. GitHub auth: fine-grained PAT (Contents R/W, issued 2026-07-23) in the sandbox
|
||||
`~/.git-credentials`; pushes go over SSH with the `ha_jb` key. The old classic PAT
|
||||
expired and is gone.
|
||||
3. Privacy: legacy real-house plan sources (`assets/`) removed from the tree in
|
||||
v1.13.3, but they persist in git history and old release archives; 8 README
|
||||
screenshots in docs/images are still from the real house (replacement with
|
||||
@@ -150,12 +169,14 @@
|
||||
2. Restore the repo: `git clone <user-folder>/houseplan-card.git.bundle hpcN` in `/tmp`
|
||||
(files from *previous* sandbox sessions in `/tmp` belong to `nobody` and are unreadable —
|
||||
always clone into a fresh directory; `npm ci` again).
|
||||
3. Deployment needs the `ha_jb` SSH key — ask the user to upload it (uploads are readable
|
||||
only in the session they were uploaded in).
|
||||
3. Deployment needs the `ha_jb` SSH key — it lives in the user folder at
|
||||
`houseplan/.secrets/ha_jb` (outside git) and often survives in the sandbox home
|
||||
`~/.ssh/ha_jb`; copy with chmod 600. Only ask the user if both are gone.
|
||||
4. Build only in `/tmp` (never on the mount), `npm run build` (starts with `tsc --noEmit`),
|
||||
md5-verify after every deploy, restart HA via
|
||||
`nohup ha core restart >/dev/null 2>&1 </dev/null &` (otherwise the SSH session hangs).
|
||||
5. GitHub pushes need a PAT (create via the user's Chrome: settings/tokens, repo+workflow scope).
|
||||
5. GitHub pushes: SSH remote with the `ha_jb` key; API releases with the fine-grained
|
||||
PAT from `~/.git-credentials` (see the watchlist).
|
||||
|
||||
## Product scope
|
||||
|
||||
|
||||
+161
-11
@@ -66,7 +66,7 @@
|
||||
than a quarter of the plan outside the viewBox [auto: smoke_decor]
|
||||
|
||||
- [ ] Room climate counts hidden sensors (v1.44.5): a thermometer that is NOT
|
||||
placed on the plan (hidden by curation or by the user) still feeds the
|
||||
placed on the plan (hidden by filtering or by the user) still feeds the
|
||||
room card, the tooltip and the temperature fill; fridges/TRVs still do
|
||||
not; an explicit per-room source still wins [auto: unit devices.test]
|
||||
- [ ] Room tooltip wording (v1.44.5): hovering a room shows its name (plus
|
||||
@@ -195,7 +195,7 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
||||
## Devices on the plan ★
|
||||
|
||||
- [ ] Auto devices appear only in rooms bound to their area [manual]
|
||||
- [ ] Curation hides bridges/groups/scenes/excluded integrations; 👁 "show all" reveals [manual]
|
||||
- [ ] Filtering hides bridges/groups/scenes/excluded integrations; 👁 "show all" reveals [manual]
|
||||
- [ ] Duplicate "name|area" numbered ("Lamp", "Lamp 2") [manual]
|
||||
- [ ] Light groups fold their single lamps; `group_lights=false` unfolds [manual]
|
||||
- [ ] Drag anywhere (no edit mode), snaps to grid, persists after reload, per space
|
||||
@@ -206,8 +206,10 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
||||
lock locked↔unlocked, bulb on; custom icons and unavailable states never morph [manual]
|
||||
- [ ] display "Value instead of an icon": the marker shows the measurement (°/%/unit)
|
||||
as its body, small badges hidden; non-numeric fallback keeps the icon [manual]
|
||||
- [ ] RGB lights (v1.27.0): an on light with a color tints its icon/glow and the ripple
|
||||
(explicit ripple color still wins); off/white lights unchanged [manual]
|
||||
- [ ] RGB lights (v1.27.0, contract changed in v1.52.0): a lamp's colour lives
|
||||
in its glow spot and the ripple fallback ONLY — the icon/badge/border get
|
||||
no RGB tint; explicit ripple color still wins; off lights unchanged
|
||||
[auto: smoke_light_badges + smoke_rgb_alarm]
|
||||
- [ ] Alarm pulse (v1.27.0): leak/smoke/gas/CO/siren in 'on' pulse a red ring over any
|
||||
display mode; clears on 'off'; unavailable never alarms [manual]; reduced-motion static
|
||||
- [ ] Render cost (v1.43.1, audit L1): geometry (space model, open pairs) is
|
||||
@@ -239,11 +241,158 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
||||
on the plan after a reload. Same for each tap action and each fill mode
|
||||
[auto: backend test_every_display_mode_the_editor_offers_is_accepted and
|
||||
neighbours, test_a_marker_showing_its_value_can_be_saved]
|
||||
- [ ] Nothing accumulates on an idle instance (v1.46.2, HP-1461-01): attach a
|
||||
file, cancel the dialog, and do not save anything else — an hour later (or
|
||||
after a restart) the file is gone, while every file the configuration
|
||||
still references is untouched
|
||||
[auto: backend test_scheduled_sweep_collects_what_no_commit_will]
|
||||
- [ ] Room settings button (dev): detached from the (movable) name label —
|
||||
always at the room's geometric centre, one button-height below it; sized
|
||||
at 70% of a device icon and zooming WITH the plan; the small metric rows
|
||||
under the room name now show in the plan editor too
|
||||
[auto: smoke_room_cards gearDetached/plainInPlan]
|
||||
- [ ] One indicator always (v1.52.1, HP-1520-01): in a glow space the plan
|
||||
editor (no glow layer) shows the lit lamp yellow again; the devices
|
||||
editor draws the layer and keeps the badge standard
|
||||
[auto: smoke_light_badges]
|
||||
- [ ] Size/angle parity (v1.52.1, HP-1513-01): a marker with size 3 / angle 37
|
||||
scales x3 and rotates on BOTH cards [auto: smoke_size_angle_parity]
|
||||
- [ ] Tap runs an automation (dev, owner's spec 2026-07-29): the tap-action
|
||||
list has "Run automation/script/scene" with a searchable picker; saving
|
||||
without a target is refused; the confirm checkbox guards toggle AND run
|
||||
(our dialog, Esc/cancel = no call); automation.trigger / script.turn_on /
|
||||
scene.turn_on per domain; a deleted target toasts and calls nothing;
|
||||
covers/valves joined the card-wide toggle EXCEPT garage/door/gate
|
||||
device classes (explicit per-device toggle still works for them)
|
||||
[auto: smoke_tap_run + unit resolveTapAction/runServiceFor + backend
|
||||
test_run_target_is_bounded_to_runnable_domains]
|
||||
- [ ] Light-source badges (v1.52.0): in glow fill a lit lamp's badge stays
|
||||
standard (the spot is the indicator) and a lit socket stays yellow; in
|
||||
other fills a lit lamp is plain yellow with no RGB tint; morphing and
|
||||
the ripple colour fallback survive [auto: smoke_light_badges +
|
||||
smoke_rgb_alarm]
|
||||
- [ ] Icon size multiplier scales the glyph (dev): set a marker's size to 3 —
|
||||
the icon inside grows with the badge instead of staying default
|
||||
[auto: smoke_icon_scale]
|
||||
- [ ] Auto-grid parity (v1.51.2, HP-1511-01): with an empty layout, a visible
|
||||
device among hidden ones sits at the same spot on both cards
|
||||
[auto: smoke_hidden_flag autoGridParity]
|
||||
- [ ] Ripple ghost (v1.51.2, HP-1511-02): a hidden ripple-display marker shows
|
||||
its base icon, no pulse [auto: smoke_hidden_flag rippleGhost*]
|
||||
- [ ] Hidden LQI parity (v1.51.1, HP-1510-01): a room whose only Zigbee
|
||||
devices are hidden paints the same lqi fill on the full and the static
|
||||
card [auto: smoke_hidden_flag lqiParity]
|
||||
- [ ] Ghost shows no numbers (v1.51.1, HP-1510-02): a hidden value-display
|
||||
device renders as a plain ghost — no value/temp/hum/LQI, no icon morph
|
||||
[auto: smoke_hidden_flag ghostHidesValue]
|
||||
- [ ] Hide-from-plan flag (dev, docs/FILTERING.md): every device dialog has
|
||||
the checkbox, incl. virtual; hidden devices vanish from every mode and
|
||||
the count, still count toward room LQI, cast no glow/light fill; the
|
||||
device editor's "Show hidden" (local, per tab) shows them as BLUE
|
||||
dashed ghosts — distinct from a grey unavailable icon — with NO live
|
||||
state paint (no yellow, no alarm, no ripple);
|
||||
unticking keeps a hidden:false marker (re-seed protection); an old
|
||||
config materialises on first load by an editing client and legacy
|
||||
clients keep the old behaviour until then
|
||||
[auto: smoke_hidden_flag + unit seedHiddenBindings/seeded/legacy]
|
||||
- [ ] Yellow means working (dev): a TRV whose hvac_action is heating glows
|
||||
yellow; one that is merely enabled (idle) or has a service switch on
|
||||
(anti-scaling, child lock) stays dark; a lit light turns its state on by
|
||||
the same condition that lights the glow pool — and shows the yellow
|
||||
badge only where the glow spot is NOT drawn: with the glow layer
|
||||
visible the spot is the one indicator (v1.52.0)
|
||||
[auto: smoke_yellow_principle + smoke_light_badges]
|
||||
- [ ] Editor gestures on touch (dev): in the plan editor on a phone, pinch
|
||||
zooms and a moving finger pans; releasing after a gesture does not draw
|
||||
a point, a clean tap still does [auto: smoke_editor_gestures]
|
||||
- [ ] Legacy geometry parity (v1.50.4, HP-1503-01): a store with a zero
|
||||
viewport and a negative rect renders identically sane in BOTH cards —
|
||||
full canvas fallback, normalised rectangle [auto: smoke_legacy_geometry]
|
||||
- [ ] Sizes are positive (v1.50.3, HP-1502-01): view_box or room w/h of zero
|
||||
or below is refused; a store that already holds one opens on the full
|
||||
canvas, not a blank screen [auto: test_sizes_are_not_coordinates + unit
|
||||
safeViewBox fallback]
|
||||
- [ ] Room card layout (v1.50.3): the settings button is the bottom row of the
|
||||
card and the room name sits in the same spot in view and plan modes
|
||||
[manual; verified by vb-coordinate measurement]
|
||||
- [ ] Geometry bounds (v1.50.2, HP-1501-01): a config with a 1e100 room
|
||||
vertex is refused by the server; one already stored still renders with a
|
||||
sane frame [auto: test_geometry_magnitudes_are_bounded + unit
|
||||
contentBounds legacy case]
|
||||
- [ ] No-op repair (v1.50.2, HP-1501-02): geometry/repair with a typo'd space
|
||||
id errors, moves no revision and keeps the previous backup undoable
|
||||
[auto: test_a_noop_repair_does_not_eat_the_backup]
|
||||
- [ ] Card below other dashboard content (v1.50.1, HP-1500-02): place the card
|
||||
after a tall card in a normal dashboard — the plan still gets most of the
|
||||
viewport instead of a zero-height stage [auto: smoke_zoom_out]
|
||||
- [ ] Frame never degenerate (v1.50.1, HP-1500-03): a space with one lone
|
||||
marker opens with canvas around it, not an empty scene; an absurd stored
|
||||
coordinate neither hides the plan nor is accepted by the server
|
||||
[auto: unit contentBounds + backend test_layout_coordinates_are_bounded]
|
||||
- [ ] Stranded migration repair (v1.50.1, HP-1500-01): geometry/repair with
|
||||
dry_run previews, applies with a backup, undo restores; wrong space is
|
||||
recoverable [auto: test_geometry_repair_is_explicit_previewable_and_undoable]
|
||||
- [ ] Editors see the whole canvas (v1.50.0, HP-1490-03): a hand-drawn space
|
||||
with one small room opens content-fit in View; switching to the plan
|
||||
editor shows the full square with room to draw a second room far away;
|
||||
back to View restores the content fit [auto: smoke_audit_1490]
|
||||
- [ ] Save waits for a picked plan's proportions (v1.50.0, HP-1490-04): pick a
|
||||
saved plan and hit Save before the thumbnail loads — the stored aspect is
|
||||
the real one, never the previous file's [auto: smoke_audit_1490]
|
||||
- [ ] Zoom goes below the fit (v1.50.0): minus past 100% floats the plan
|
||||
centred, floor at 0.4x; entering an editor keeps the stage inside the
|
||||
viewport [auto: smoke_zoom_out]
|
||||
- [ ] Migration crash recovery (v1.50.0, HP-1490-01): kill HA between the two
|
||||
store writes of the square migration — the next start finishes the layout
|
||||
half from the saved intent
|
||||
[auto: test_square_migration_finishes_after_a_crash_between_the_writes]
|
||||
- [ ] Parallel upload quota (v1.50.0, HP-1490-02): two simultaneous uploads
|
||||
with one slot left — exactly one succeeds
|
||||
[auto: test_parallel_uploads_cannot_slip_past_the_quota_together]
|
||||
- [ ] Zoom opens on the content (v1.49.0): a space with no background and one
|
||||
small room opens with that room filling the screen, with a small margin.
|
||||
With a background it still fits the whole image
|
||||
[auto: unit: contentBounds]
|
||||
- [ ] Deleting a picked plan is refused (v1.49.0, HP-1470-02): pick a saved
|
||||
plan, reopen the list — its delete button is disabled. Ask the server to
|
||||
store a plan url whose file is gone: `missing_plan`, and the revision does
|
||||
not move [auto: smoke_saved_plans + backend
|
||||
test_config_set_refuses_a_plan_that_no_longer_exists]
|
||||
- [ ] Uploads are bounded (v1.49.0, HP-1470-01): past the store quota an upload
|
||||
is refused with a clear error and the disk does not grow; the plan list
|
||||
returns the newest 60 with a total
|
||||
[auto: unit: test_check_quota_counts_the_whole_store_not_one_request,
|
||||
backend test_uploads_are_bounded_by_a_store_quota]
|
||||
- [ ] Square canvas migration (v1.48.0): after the upgrade every existing plan
|
||||
looks exactly as before, just with margins where the canvas was extended.
|
||||
Measure a wall in the plan editor — the length in cm is unchanged. Marker
|
||||
positions, doors, decor and the saved zoom are all where they were
|
||||
[auto: unit: test_a_wide_plan_gains_margins_above_and_below and neighbours,
|
||||
test_migration_preserves_real_lengths_and_shapes]
|
||||
- [ ] A plan image is centred (v1.48.0): a wide image sits in the middle with
|
||||
empty bands above and below, a tall one with bands at the sides, and it is
|
||||
never stretched [auto: unit: fitInSquare + smoke_space_settings]
|
||||
- [ ] Re-attaching a detached plan (v1.47.0): detach a plan, save, RELOAD THE
|
||||
PAGE, open space settings → "Already uploaded" → the image is listed with
|
||||
its size and no "in use" note → attach it → it renders. The one a space
|
||||
uses shows that space and cannot be deleted; a free one can, with a
|
||||
confirm, and disappears from the list
|
||||
[auto: smoke_saved_plans + backend test_stored_plans_can_be_listed_and_deleted_on_request]
|
||||
- [ ] Detaching a plan keeps the file (v1.46.6): switch a space to "draw" and
|
||||
SAVE — the image is still in `config/houseplan/plans/` right afterwards,
|
||||
and after a restart, and can be re-attached. Deleting the space keeps it
|
||||
too. Replacing a plan still removes the one it replaced, immediately.
|
||||
Check straight after the save: the earlier bug deleted the file at that
|
||||
moment, while every scheduled-pass test passed
|
||||
[auto: unit: test_plan_collection_matrix, test_attachment_collection_matrix,
|
||||
backend test_detaching_a_plan_keeps_the_file]
|
||||
- [ ] Rebinding a device does not eat its manuals (v1.46.5): attach two files to
|
||||
a device, rebind it to another HA device — both are readable afterwards.
|
||||
If a copy failed, the file it failed on is still there rather than deleted
|
||||
with the folder [auto: backend test_files_cleanup_keeps_referenced_files]
|
||||
- [ ] Nothing accumulates on an idle instance (v1.46.2/v1.46.3, HP-1461-01,
|
||||
HP-1462-01): attach a file, cancel the dialog, and do not save anything
|
||||
else — the file is gone after a restart AND after the daily pass, while
|
||||
every file the configuration still references is untouched. Seed the
|
||||
strays AFTER the last save, or `config/set` collects them and the check
|
||||
proves nothing
|
||||
[auto: backend test_startup_sweep_collects_what_no_commit_will,
|
||||
test_daily_sweep_callback_collects_too, test_sweep_and_a_config_write_do_not_race]
|
||||
- [ ] A drag wins over a concurrent remote move (v1.46.2, HP-1461-02): drag an
|
||||
icon and, while the save is still in flight, have another window move a
|
||||
different icon — your icon stays where you put it and the other one
|
||||
@@ -435,8 +584,9 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
||||
same domain still win over hidden ones [manual: click hallway lamps]
|
||||
- [ ] Marker controls (v1.36.0): a marker with "Controls light sources" and
|
||||
tap action Toggle flips all bound lights/switches at once (any on → all
|
||||
off, all off → all on, one service call); the icon and its RGB tint
|
||||
mirror the targets, not the marker's own entity; without explicit Toggle
|
||||
off, all off → all on, one service call); the icon state (yellow badge)
|
||||
mirrors the targets, not the marker's own entity — the RGB tint is gone
|
||||
since v1.52.0, target colours reach only the glow/ripple; without explicit Toggle
|
||||
the click opens info as usual; the info card lists targets with states;
|
||||
locks/other domains are filtered out of controls [auto: smoke_controls]
|
||||
- [ ] Glow fill (v1.35.0): fill mode "Light sources" — every room painted with
|
||||
|
||||
+8
-4
@@ -59,8 +59,10 @@ Header in View: space tabs, device count, zoom cluster. Nothing else.
|
||||
- Icon dragging (ONLY here). Click on a device opens the **edit dialog directly**
|
||||
(binding, name, icon, size/angle, display badge/ripple + colors, tap override,
|
||||
model/link/description/PDFs, room).
|
||||
- + add device/entity/virtual, hide device, ↺ reset layout, 👁 show-all (curation
|
||||
tool), ⬡ icon rules.
|
||||
- + add device/entity/virtual, the "Hide device from plan" checkbox (since
|
||||
v1.51.0 the one hiding mechanism, docs/FILTERING.md), ↺ reset layout,
|
||||
👁 "Show hidden" (local editor tool; replaced the shared show-all toggle),
|
||||
⬡ icon rules.
|
||||
|
||||
## Deprecations decided
|
||||
|
||||
@@ -74,7 +76,9 @@ Header in View: space tabs, device count, zoom cluster. Nothing else.
|
||||
|
||||
1. State-reflecting icons (open/closed door variants etc., like core HA).
|
||||
2. `display: value` — show the measurement instead of an icon.
|
||||
3. Light color in the icon/ripple (RGB lights).
|
||||
3. Light color in the icon/ripple (RGB lights). *(Shipped in v1.27.0;
|
||||
superseded in v1.52.0: the colour lives in the glow spot and the ripple
|
||||
fallback only, the icon tint was removed by the owner's rule.)*
|
||||
4. Alarm visual (leak/smoke/doorbell): red pulse overlay.
|
||||
5. Rooms as sub-areas without an HA area + manual device placement by room id.
|
||||
6. Backlog (not planned): music notes for players, directional TV ripples.
|
||||
@@ -85,7 +89,7 @@ Header in View: space tabs, device count, zoom cluster. Nothing else.
|
||||
edit interactions/buttons (biggest UX win, smallest surface).
|
||||
- **It.2 — Plan tab:** move markup tools + space dialogs + labels drag + openings
|
||||
editing under Plan; colored frame indicator.
|
||||
- **It.3 — Devices tab:** drag + direct-edit click + curation tools under Devices.
|
||||
- **It.3 — Devices tab:** drag + direct-edit click + filtering tools under Devices.
|
||||
- **It.4+:** follow-up features 1–5 above, each its own release.
|
||||
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.45.4",
|
||||
"version": "1.53.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "houseplan-card",
|
||||
"version": "1.45.4",
|
||||
"version": "1.53.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lit": "^3.1.3",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.46.2",
|
||||
"version": "1.53.0",
|
||||
"description": "Interactive house plan Lovelace card for Home Assistant",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
+94
-22
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Building the device list from HA registries: curation, light groups,
|
||||
* Building the device list from HA registries: filtering, light groups,
|
||||
* markers (overrides/virtual). No Lit/DOM — only the hass object.
|
||||
*/
|
||||
import { iconFor, iconFromDeviceClasses, DOMAIN_PRIORITY, FALLBACK_ICON, type CompiledIconRule, EXCLUDED_DOMAINS } from './rules';
|
||||
@@ -14,6 +14,8 @@ export interface BuildCtx {
|
||||
markers: Marker[];
|
||||
settings: ServerConfig['settings'];
|
||||
excluded: Set<string>;
|
||||
/** LEGACY only: honoured while the config has no settings.filter_seeded.
|
||||
* Seeded configs hide by explicit marker flags (docs/FILTERING.md). */
|
||||
showAll: boolean;
|
||||
firstSpaceId: string;
|
||||
/** Localized display strings for generated device names. */
|
||||
@@ -56,14 +58,22 @@ export function primaryEntity(hass: any, entIds: string[], icon: string): string
|
||||
const all = entIds
|
||||
.map((eid) => ({ eid, reg: hass.entities[eid], st: hass.states[eid] }))
|
||||
.filter((e) => e.reg);
|
||||
// Tiers: visible primary entities first, but a HIDDEN light still beats a
|
||||
// visible config switch. Real case: individual lamps folded into a light
|
||||
// group get hidden in the registry — the device's main function is still
|
||||
// the lamp, and tap-toggle must flip IT, not the do-not-disturb switch.
|
||||
// Tiers, in order: functional and visible; functional but hidden (lamps
|
||||
// folded into a light group get hidden in the registry — the device's main
|
||||
// function is still the lamp, and tap-toggle must flip IT, not the
|
||||
// do-not-disturb switch); config/diagnostic but visible; everything.
|
||||
//
|
||||
// The TIER loop is the OUTER one. It used to be inner, which made the
|
||||
// domain priority stronger than visibility — and `switch` outranks
|
||||
// `climate`, so a TRV's primary was whatever service switch the vendor
|
||||
// ships (child lock, anti-scaling): the icon glowed yellow because scale
|
||||
// protection was on, while the head that was actually HEATING stayed dark
|
||||
// (owner's install, verified live 2026-07-29). A service entity must never
|
||||
// beat the device's visible main function.
|
||||
const tiers = [
|
||||
all.filter((e) => !e.reg.hidden && !e.reg.entity_category),
|
||||
all.filter((e) => !e.reg.hidden),
|
||||
all.filter((e) => !e.reg.entity_category),
|
||||
all.filter((e) => !e.reg.hidden),
|
||||
all,
|
||||
];
|
||||
if (icon === 'mdi:thermometer' || icon === 'mdi:air-filter') {
|
||||
@@ -72,16 +82,35 @@ export function primaryEntity(hass: any, entIds: string[], icon: string): string
|
||||
if (t) return t.eid;
|
||||
}
|
||||
}
|
||||
for (const dom of DOMAIN_PRIORITY) {
|
||||
for (const tier of tiers) {
|
||||
for (const tier of tiers) {
|
||||
for (const dom of DOMAIN_PRIORITY) {
|
||||
const found = tier.find((e) => e.eid.split('.')[0] === dom);
|
||||
if (found) return found.eid;
|
||||
}
|
||||
}
|
||||
// no known domain anywhere — first entity of the best non-empty tier
|
||||
for (const tier of tiers) if (tier.length) return tier[0].eid;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The lit light entity of a device, or null — ONE truth for "this thing is
|
||||
* shining": the glow-mode pool and the yellow icon both ask here, so the
|
||||
* light pool and the icon can never disagree (owner's principle, 2026-07-29).
|
||||
* Normally that is a lit `light.*`; with the "is a light source" flag (a smart
|
||||
* switch driving dumb fixtures) any lit entity counts — the switch itself, or
|
||||
* the lights it controls when they are bound.
|
||||
*/
|
||||
export function litLightEntity(
|
||||
hass: any, d: { entities: string[]; marker?: { is_light?: boolean | null; controls?: string[] | null } | null },
|
||||
): string | null {
|
||||
const forced = d.marker?.is_light === true;
|
||||
const pool = forced
|
||||
? [...(d.marker?.controls || []), ...d.entities]
|
||||
: d.entities.filter((e) => e.startsWith('light.'));
|
||||
return pool.find((e) => hass.states[e]?.state === 'on') || null;
|
||||
}
|
||||
|
||||
/** Average zigbee LQI across the device's entities (*_linkquality/*_lqi sensors or an attribute). */
|
||||
export function lqiFor(hass: any, entIds: string[]): number | null {
|
||||
const vals: number[] = [];
|
||||
@@ -175,6 +204,7 @@ export function resolveIcon(hass: any, name: string, model: string | undefined,
|
||||
|
||||
function applyMarker(item: DevItem, m: Marker): void {
|
||||
item.marker = m;
|
||||
if (m.hidden) item.hidden = true;
|
||||
if (m.name) item.name = m.name;
|
||||
if (m.icon) item.icon = m.icon;
|
||||
if (m.model != null) item.model = m.model;
|
||||
@@ -184,7 +214,44 @@ function applyMarker(item: DevItem, m: Marker): void {
|
||||
item.tapAction = m.tap_action ?? null;
|
||||
}
|
||||
|
||||
/** Curation + light groups + markers (metadata/rebinding) + virtual ones. A hybrid. */
|
||||
/**
|
||||
* The SEEDER (docs/FILTERING.md): bindings of non-physical devices in bound
|
||||
* areas that have no marker yet. The editing client turns each into a
|
||||
* `hidden: true` stub marker — after that the flag belongs to the user, and a
|
||||
* marker of ANY kind (even `hidden: false`) makes the device invisible to
|
||||
* this function forever. Idempotent by construction.
|
||||
*/
|
||||
export function seedHiddenBindings(ctx: Omit<BuildCtx, 'showAll' | 'loc'>): string[] {
|
||||
const { hass: h, areaToSpace, markers, settings, excluded, iconRules } = ctx;
|
||||
const groupLights = settings.group_lights !== false;
|
||||
const groups = lightGroups(h, groupLights);
|
||||
const groupedAreas = new Set(groups.map((g) => g.area));
|
||||
const entsBy = entitiesByDevice(h);
|
||||
const marked = new Set(markers.map((m) => m.binding));
|
||||
const out: string[] = [];
|
||||
for (const dev of Object.values<any>(h.devices)) {
|
||||
const area = dev.area_id;
|
||||
if (!area || !areaToSpace[area]) continue;
|
||||
if (dev.entry_type === 'service') continue;
|
||||
if (marked.has('device:' + dev.id)) continue;
|
||||
const entIds = entsBy[dev.id] || [];
|
||||
const dom = domainOfDevice(h, dev, entIds);
|
||||
let nonPhysical =
|
||||
excluded.has(dom)
|
||||
|| dev.model === 'Group'
|
||||
|| /scene/i.test(dev.model || '')
|
||||
|| /bridge/i.test((dev.model || '') + (dev.name || ''))
|
||||
|| (dom === 'myheat' && !!dev.via_device_id);
|
||||
if (!nonPhysical && groupLights && groupedAreas.has(area)) {
|
||||
const name = (dev.name_by_user || dev.name || '').trim();
|
||||
if (resolveIcon(h, name, dev.model, entIds, iconRules) === 'mdi:lightbulb') nonPhysical = true;
|
||||
}
|
||||
if (nonPhysical) out.push('device:' + dev.id);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Filtering + light groups + markers (metadata/rebinding) + virtual ones. A hybrid. */
|
||||
export function buildDevices(ctx: BuildCtx): DevItem[] {
|
||||
const { hass: h, areaToSpace, markers, settings, excluded, showAll, firstSpaceId, loc, iconRules } = ctx;
|
||||
const groupLights = settings.group_lights !== false;
|
||||
@@ -207,11 +274,13 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
|
||||
if (dev.entry_type === 'service') continue;
|
||||
if (claimed.has('device:' + dev.id)) continue; // a marker will take over below
|
||||
const marker = markerFor('device', dev.id);
|
||||
if (marker && marker.hidden) continue;
|
||||
if (marker && marker.hidden && !settings.filter_seeded) continue; // legacy: dropped entirely
|
||||
const entIds = entsBy[dev.id] || [];
|
||||
const dom = domainOfDevice(h, dev, entIds);
|
||||
// curation (can be turned off with the “show all” toggle)
|
||||
if (!showAll) {
|
||||
// LEGACY runtime filter: only while the config is not yet materialised
|
||||
// (docs/FILTERING.md). A seeded config hides by explicit marker flags.
|
||||
const legacy = !settings.filter_seeded;
|
||||
if (legacy && !showAll) {
|
||||
if (excluded.has(dom)) continue;
|
||||
if (dev.model === 'Group') continue;
|
||||
if (/scene/i.test(dev.model || '')) continue;
|
||||
@@ -222,7 +291,7 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
|
||||
const key = name + '|' + area;
|
||||
let icon = resolveIcon(h, name, dev.model, entIds, iconRules);
|
||||
if (entIds.some((e) => e.startsWith('lock.'))) icon = 'mdi:lock';
|
||||
if (!showAll && groupLights && icon === 'mdi:lightbulb' && groupedAreas.has(area)) continue;
|
||||
if (legacy && !showAll && groupLights && icon === 'mdi:lightbulb' && groupedAreas.has(area)) continue;
|
||||
// duplicates by “name|zone” are numbered rather than hidden
|
||||
seen[key] = (seen[key] || 0) + 1;
|
||||
const dispName = seen[key] > 1 ? name + ' ' + seen[key] : name;
|
||||
@@ -265,7 +334,10 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
|
||||
|
||||
// 3) explicit markers (rebinding/metadata/virtual)
|
||||
for (const m of markers) {
|
||||
if (m.hidden) continue;
|
||||
// Hidden is a FLAG now, not an absence: the device is built (room LQI
|
||||
// still counts it) and the renderer decides. Legacy configs keep the old
|
||||
// "hidden = gone" until they are seeded (docs/FILTERING.md).
|
||||
if (m.hidden && !settings.filter_seeded) continue;
|
||||
const [kind, ref] = m.binding.split(':');
|
||||
if (kind === 'device') {
|
||||
const dev = h.devices[ref];
|
||||
@@ -343,10 +415,10 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
|
||||
* Light situation of an area: 'on' if any light entity of the area's devices is on,
|
||||
* 'off' if lights exist but none is on, 'none' when the area has no lights at all.
|
||||
*/
|
||||
export function areaLights(hass: any, devices: { area: string; entities: string[] }[], area: string): 'on' | 'off' | 'none' {
|
||||
export function areaLights(hass: any, devices: { area: string; entities: string[]; hidden?: boolean }[], area: string): 'on' | 'off' | 'none' {
|
||||
let seen = false;
|
||||
for (const d of devices) {
|
||||
if (d.area !== area) continue;
|
||||
if (d.area !== area || d.hidden) continue; // an invisible device casts no visible light
|
||||
for (const eid of d.entities) {
|
||||
if (!eid.startsWith('light.')) continue;
|
||||
seen = true;
|
||||
@@ -391,7 +463,7 @@ export function areaHum(
|
||||
const vals: number[] = [];
|
||||
for (const dv of devices) {
|
||||
if (dv.area !== area) continue;
|
||||
// same curation idea as areaTemp: climate sensors only, not fridges/plugs
|
||||
// same filtering idea as areaTemp: climate sensors only, not fridges/plugs
|
||||
if (dv.icon !== 'mdi:thermometer' && dv.icon !== 'mdi:air-filter' && dv.icon !== 'mdi:water-percent') continue;
|
||||
const h = humFor(hass, dv.entities);
|
||||
if (h != null) vals.push(h);
|
||||
@@ -416,11 +488,11 @@ const NON_AIR_RE = new RegExp(
|
||||
|
||||
/**
|
||||
* Room climate from EVERY sensor of the area — including devices that are not
|
||||
* placed on the plan (hidden by curation or by the user). The old helpers read
|
||||
* placed on the plan (hidden by filtering or by the user). The old helpers read
|
||||
* the visible-icon list, so hiding a thermometer silently removed it from the
|
||||
* room card (field report, 2026-07-27).
|
||||
*
|
||||
* Curation is kept: only devices the card itself recognises as thermometers /
|
||||
* Filtering is kept: only devices the card itself recognises as thermometers /
|
||||
* air monitors count, so fridges, TRVs and chip-temperature plugs stay out.
|
||||
* The AUTO icon is used on purpose — a custom marker icon must not change what
|
||||
* a device measures.
|
||||
@@ -451,7 +523,7 @@ export function areaClimateMap(
|
||||
// 90 C and a virtual better_thermostat duplicating the real sensor (field
|
||||
// question, 2026-07-27). Three guards, cheapest first:
|
||||
if (reg.entity_category) continue; // diagnostic/config readings
|
||||
if (EXCLUDED_DOMAINS.has(reg.platform)) continue; // curated-out integrations
|
||||
if (EXCLUDED_DOMAINS.has(reg.platform)) continue; // filtered-out integrations
|
||||
if (NON_AIR_RE.test(eid)) continue; // water/chip/flow/target/...
|
||||
let groups = byArea.get(area);
|
||||
if (!groups) { groups = new Map(); byArea.set(area, groups); }
|
||||
@@ -503,13 +575,13 @@ export function areaClimate(
|
||||
/** How many of the area's lights are on: {on, total}, or null without lights. */
|
||||
export function areaLightStats(
|
||||
hass: any,
|
||||
devices: { area: string; entities: string[] }[],
|
||||
devices: { area: string; entities: string[]; hidden?: boolean }[],
|
||||
area: string,
|
||||
): { on: number; total: number } | null {
|
||||
const seen = new Set<string>();
|
||||
let on = 0;
|
||||
for (const dv of devices) {
|
||||
if (dv.area !== area) continue;
|
||||
if (dv.area !== area || dv.hidden) continue; // hidden lights are not part of the picture
|
||||
for (const eid of dv.entities) {
|
||||
if (!eid.startsWith('light.') || seen.has(eid)) continue;
|
||||
seen.add(eid);
|
||||
|
||||
+604
-137
File diff suppressed because it is too large
Load Diff
+32
-5
@@ -21,7 +21,7 @@
|
||||
"title.zoom_out": "Zoom out",
|
||||
"title.zoom_reset": "Reset zoom",
|
||||
"title.add_device": "Add a device to the plan",
|
||||
"title.show_all": "Show all area devices (no curation)",
|
||||
"title.show_all": "Show hidden devices (ghosted, this tab only)",
|
||||
"title.markup": "Room markup: grid, lines, outlines",
|
||||
"title.configure_space": "Configure space",
|
||||
"title.add_space": "Add space",
|
||||
@@ -244,7 +244,7 @@
|
||||
"opening.lock_pending": "Working…",
|
||||
"title.close_editor": "Close editor (back to view)",
|
||||
"devbar.add": "Add",
|
||||
"devbar.show_all": "Show all",
|
||||
"devbar.show_all": "Show hidden",
|
||||
"devbar.rules": "Icon rules",
|
||||
"space.roomcard_section": "Room card shows:",
|
||||
"space.label_temp": "Temperature",
|
||||
@@ -323,10 +323,37 @@
|
||||
"room.label_scale": "Metrics size",
|
||||
"preview.room_name": "Living room",
|
||||
"toast.cfg_reload_failed": "Could not reload the plan from the server: {err}",
|
||||
"room.settings_short": "Room",
|
||||
"room.settings_short": "Room settings",
|
||||
"room.unnamed": "Unnamed room",
|
||||
"marker.is_light": "This device is a light source",
|
||||
"marker.is_light_tip": "Makes the icon glow in the “Light sources” fill even without a light entity — for a smart switch driving ordinary fixtures. The glow follows the switch (or the lights bound above).",
|
||||
"confirm.unlock": "Unlock “{name}”?",
|
||||
"toast.files_migrate_failed": "Attachments could not be moved to the new binding, links keep pointing at the old files: {err}"
|
||||
}
|
||||
"toast.files_migrate_failed": "Attachments could not be moved to the new binding, links keep pointing at the old files: {err}",
|
||||
"space.pick_saved": "Already uploaded",
|
||||
"space.pick_saved_hint": "Plans stored on the server, including ones you detached earlier",
|
||||
"space.no_saved": "No plans stored on the server yet.",
|
||||
"space.loading": "Loading…",
|
||||
"space.used_by": "in use: {list}",
|
||||
"space.in_use": "A space still uses this plan — detach it first",
|
||||
"btn.use": "Use",
|
||||
"confirm.delete_plan": "Delete the plan file \"{name}\" from the server? This cannot be undone.",
|
||||
"toast.plans_list_failed": "Could not list the stored plans: {err}",
|
||||
"toast.plan_delete_failed": "Could not delete the plan: {err}",
|
||||
"marker.hide": "Hide device from plan",
|
||||
"marker.hide_tip": "The device is not drawn on the plan but still counts toward the room signal. To see it: the \"Show hidden\" button in the device editor.",
|
||||
"tap.run": "Run automation/script/scene",
|
||||
"marker.run_target_label": "What to run",
|
||||
"marker.run_search_ph": "Search: automation, script or scene…",
|
||||
"marker.run_target_gone": "Target {id} not found — pick again",
|
||||
"marker.tap_confirm": "Ask for confirmation",
|
||||
"marker.tap_confirm_tip": "Show a confirmation dialog before acting — a guard against accidental taps.",
|
||||
"run.automation": "automation",
|
||||
"run.script": "script",
|
||||
"run.scene": "scene",
|
||||
"confirm.tap_run": "Run \"{name}\"?",
|
||||
"confirm.tap_toggle": "Toggle \"{name}\"?",
|
||||
"toast.run_started": "Started: {name}",
|
||||
"toast.run_target_missing": "Run target not found — check the device settings",
|
||||
"toast.run_target_required": "Pick an automation, script or scene",
|
||||
"btn.run": "Run"
|
||||
}
|
||||
+32
-5
@@ -21,7 +21,7 @@
|
||||
"title.zoom_out": "Отдалить",
|
||||
"title.zoom_reset": "Сбросить масштаб",
|
||||
"title.add_device": "Добавить устройство на план",
|
||||
"title.show_all": "Показывать все устройства зоны (без курирования)",
|
||||
"title.show_all": "Показать скрытые устройства (полупрозрачными, только в этой вкладке)",
|
||||
"title.markup": "Разметка комнат: сетка, линии, контуры",
|
||||
"title.configure_space": "Настроить пространство",
|
||||
"title.add_space": "Добавить пространство",
|
||||
@@ -244,7 +244,7 @@
|
||||
"opening.lock_pending": "Выполняется…",
|
||||
"title.close_editor": "Закрыть редактор (вернуться к просмотру)",
|
||||
"devbar.add": "Добавить",
|
||||
"devbar.show_all": "Показать все",
|
||||
"devbar.show_all": "Показать скрытые",
|
||||
"devbar.rules": "Правила иконок",
|
||||
"space.roomcard_section": "В карточке комнаты:",
|
||||
"space.label_temp": "Температура",
|
||||
@@ -323,10 +323,37 @@
|
||||
"room.label_scale": "Размер подписей",
|
||||
"preview.room_name": "Гостиная",
|
||||
"toast.cfg_reload_failed": "Не удалось перечитать план с сервера: {err}",
|
||||
"room.settings_short": "Комната",
|
||||
"room.settings_short": "Настройки комнаты",
|
||||
"room.unnamed": "Комната без имени",
|
||||
"marker.is_light": "Это устройство — источник света",
|
||||
"marker.is_light_tip": "Даёт ореол в заливке «Свет по источникам» даже без light-сущности — для умного выключателя с обычными светильниками. Ореол следует за выключателем (или за привязанными выше лампами).",
|
||||
"confirm.unlock": "Открыть замок «{name}»?",
|
||||
"toast.files_migrate_failed": "Не удалось перенести вложения к новой привязке, ссылки остались на старые файлы: {err}"
|
||||
}
|
||||
"toast.files_migrate_failed": "Не удалось перенести вложения к новой привязке, ссылки остались на старые файлы: {err}",
|
||||
"space.pick_saved": "Уже загруженные",
|
||||
"space.pick_saved_hint": "Планы, сохранённые на сервере, включая отцеплённые ранее",
|
||||
"space.no_saved": "На сервере пока нет сохранённых планов.",
|
||||
"space.loading": "Загрузка…",
|
||||
"space.used_by": "используется: {list}",
|
||||
"space.in_use": "План используется пространством — сначала отцепите его",
|
||||
"btn.use": "Выбрать",
|
||||
"confirm.delete_plan": "Удалить файл плана «{name}» с сервера? Действие необратимо.",
|
||||
"toast.plans_list_failed": "Не удалось получить список планов: {err}",
|
||||
"toast.plan_delete_failed": "Не удалось удалить план: {err}",
|
||||
"marker.hide": "Скрыть устройство с плана",
|
||||
"marker.hide_tip": "Устройство не отображается на плане, но участвует в расчёте сигнала комнаты. Показать: кнопка «Показать скрытые» в редакторе устройств.",
|
||||
"tap.run": "Запустить автоматизацию/скрипт/сцену",
|
||||
"marker.run_target_label": "Что запускать",
|
||||
"marker.run_search_ph": "Поиск: автоматизация, скрипт или сцена…",
|
||||
"marker.run_target_gone": "Цель {id} не найдена — выберите заново",
|
||||
"marker.tap_confirm": "Спрашивать подтверждение",
|
||||
"marker.tap_confirm_tip": "Перед выполнением показать диалог подтверждения — защита от случайных нажатий.",
|
||||
"run.automation": "автоматизация",
|
||||
"run.script": "скрипт",
|
||||
"run.scene": "сцена",
|
||||
"confirm.tap_run": "Запустить «{name}»?",
|
||||
"confirm.tap_toggle": "Переключить «{name}»?",
|
||||
"toast.run_started": "Запущено: {name}",
|
||||
"toast.run_target_missing": "Цель запуска не найдена — проверьте настройки устройства",
|
||||
"toast.run_target_required": "Выберите автоматизацию, скрипт или сцену",
|
||||
"btn.run": "Выполнить"
|
||||
}
|
||||
+98
-4
@@ -238,6 +238,74 @@ export function segmentsProperlyCross(
|
||||
* triangle centroid of consecutive vertices — the first point that passes
|
||||
* pointStrictlyInside wins.
|
||||
*/
|
||||
/**
|
||||
* The visual centre of a polygon: the centre of the largest inscribed circle
|
||||
* (pole of inaccessibility). `interiorPoint` only promises "inside", and for
|
||||
* an L-shaped room it lands near the seam — the owner's kitchen-living room
|
||||
* got its settings button visibly off-centre (2026-07-29). Grid search with
|
||||
* one refinement pass; exact enough for a button, cheap enough to cache.
|
||||
*/
|
||||
export function poleOfInaccessibility(poly: number[][], steps = 24): number[] {
|
||||
const xs = poly.map((p) => p[0]);
|
||||
const ys = poly.map((p) => p[1]);
|
||||
const minX = Math.min(...xs), maxX = Math.max(...xs);
|
||||
const minY = Math.min(...ys), maxY = Math.max(...ys);
|
||||
const span = Math.max(maxX - minX, maxY - minY) || 1;
|
||||
// Area centroid (shoelace-weighted). Clearance alone has a PLATEAU on any
|
||||
// elongated room — every point of the long midline fits the same circle —
|
||||
// and a plain argmax took the first plateau point: left of centre on the
|
||||
// owner's kitchen, above centre in the sauna. A soft pull toward the
|
||||
// centroid breaks the tie along the plateau without ever dragging the
|
||||
// point into a thinner limb (clearance differences dominate the score).
|
||||
let a2 = 0, cx = 0, cy = 0;
|
||||
for (let i = 0; i < poly.length; i++) {
|
||||
const p = poly[i], q = poly[(i + 1) % poly.length];
|
||||
const cross = p[0] * q[1] - q[0] * p[1];
|
||||
a2 += cross;
|
||||
cx += (p[0] + q[0]) * cross;
|
||||
cy += (p[1] + q[1]) * cross;
|
||||
}
|
||||
const centroid = Math.abs(a2) > 1e-9
|
||||
? [cx / (3 * a2), cy / (3 * a2)]
|
||||
: [(minX + maxX) / 2, (minY + maxY) / 2];
|
||||
const clearance = (x: number, y: number): number => {
|
||||
if (!pointInPolygon([x, y], poly)) return -Infinity;
|
||||
let d = Infinity;
|
||||
for (let i = 0; i < poly.length; i++) {
|
||||
const a = poly[i], b = poly[(i + 1) % poly.length];
|
||||
d = Math.min(d, distToSegment([x, y], [a[0], a[1], b[0], b[1]]));
|
||||
}
|
||||
return d;
|
||||
};
|
||||
const score = (x: number, y: number): number => {
|
||||
const d = clearance(x, y);
|
||||
if (d === -Infinity) return d;
|
||||
return d - 0.08 * Math.hypot(x - centroid[0], y - centroid[1]) - 0.0001 * span;
|
||||
};
|
||||
let best: number[] | null = null;
|
||||
let bestS = -Infinity;
|
||||
for (let i = 1; i < steps; i++) {
|
||||
for (let j = 1; j < steps; j++) {
|
||||
const x = minX + ((maxX - minX) * i) / steps;
|
||||
const y = minY + ((maxY - minY) * j) / steps;
|
||||
const sc = score(x, y);
|
||||
if (sc > bestS) { bestS = sc; best = [x, y]; }
|
||||
}
|
||||
}
|
||||
if (best) {
|
||||
const [bx, by] = best;
|
||||
const cw = (maxX - minX) / steps, ch = (maxY - minY) / steps;
|
||||
for (let i = -4; i <= 4; i++) {
|
||||
for (let j = -4; j <= 4; j++) {
|
||||
const x = bx + (cw * i) / 4, y = by + (ch * j) / 4;
|
||||
const sc = score(x, y);
|
||||
if (sc > bestS) { bestS = sc; best = [x, y]; }
|
||||
}
|
||||
}
|
||||
}
|
||||
return best || interiorPoint(poly) || poly[0];
|
||||
}
|
||||
|
||||
export function interiorPoint(poly: number[][], eps = 1e-6): number[] | null {
|
||||
if (!poly || poly.length < 3) return null;
|
||||
const n = poly.length;
|
||||
@@ -528,7 +596,7 @@ export function safeUrl(url: string | null | undefined): string | null {
|
||||
|
||||
// ---------------- tap actions ----------------
|
||||
|
||||
export type TapAction = 'info' | 'more-info' | 'toggle';
|
||||
export type TapAction = 'info' | 'more-info' | 'toggle' | 'run';
|
||||
|
||||
/** Domains a card-wide `tap_action: toggle` may toggle (accidental-tap safe). */
|
||||
/**
|
||||
@@ -547,12 +615,12 @@ export type TapAction = 'info' | 'more-info' | 'toggle';
|
||||
* Adding an option here and forgetting the schema now fails the test suite.
|
||||
*/
|
||||
export const DISPLAY_MODES = ['badge', 'ripple', 'icon_ripple', 'value'] as const;
|
||||
export const TAP_ACTIONS = ['info', 'more-info', 'toggle'] as const;
|
||||
export const TAP_ACTIONS = ['info', 'more-info', 'toggle', 'run'] as const;
|
||||
/** Space-level fill: 'glow' is a whole-space light model, not a per-room one. */
|
||||
export const SPACE_FILL_MODES = ['none', 'lqi', 'light', 'temp', 'glow'] as const;
|
||||
export const ROOM_FILL_MODES = ['none', 'lqi', 'light', 'temp'] as const;
|
||||
|
||||
export const TOGGLE_SAFE_DOMAINS = new Set(['light', 'switch', 'fan', 'humidifier']);
|
||||
export const TOGGLE_SAFE_DOMAINS = new Set(['light', 'switch', 'fan', 'humidifier', 'cover', 'valve']);
|
||||
|
||||
/**
|
||||
* Domains that must NEVER toggle from a plan tap, even with an explicit
|
||||
@@ -569,10 +637,16 @@ export const TOGGLE_FORBIDDEN_DOMAINS = new Set(['lock', 'alarm_control_panel'])
|
||||
* TOGGLE_SAFE_DOMAINS; an explicit per-device toggle affects any domain
|
||||
* except TOGGLE_FORBIDDEN_DOMAINS. Everything else falls back to 'info'.
|
||||
*/
|
||||
/** Cover classes that stay OUT of the card-wide toggle: an accidental tap
|
||||
* opening the garage or the driveway gate is a security matter, like locks.
|
||||
* An explicit per-device toggle remains the owner's conscious choice. */
|
||||
export const COVER_GUARDED_CLASSES = new Set(['garage', 'door', 'gate']);
|
||||
|
||||
export function resolveTapAction(
|
||||
explicit: string | null | undefined,
|
||||
cardDefault: string | null | undefined,
|
||||
domain: string | null | undefined,
|
||||
deviceClass?: string | null,
|
||||
): TapAction {
|
||||
// Pure light sources (the device's PRIMARY function is a lamp: bulbs,
|
||||
// chandeliers, night lights, light groups) toggle by default — no explicit
|
||||
@@ -580,10 +654,30 @@ export function resolveTapAction(
|
||||
// backlight) have a non-light primary and keep the info default.
|
||||
const want = explicit || cardDefault || (domain === 'light' ? 'toggle' : 'info');
|
||||
if (want === 'more-info') return 'more-info';
|
||||
// 'run' is EXPLICIT-only by construction: it needs a per-marker target, so
|
||||
// it can never arrive as a card-wide default
|
||||
if (want === 'run') return explicit === 'run' ? 'run' : 'info';
|
||||
if (want !== 'toggle') return 'info';
|
||||
if (!domain || TOGGLE_FORBIDDEN_DOMAINS.has(domain)) return 'info';
|
||||
if (explicit === 'toggle') return 'toggle';
|
||||
return TOGGLE_SAFE_DOMAINS.has(domain) ? 'toggle' : 'info';
|
||||
if (!TOGGLE_SAFE_DOMAINS.has(domain)) return 'info';
|
||||
// covers joined the safe set for curtains and blinds (owner, 2026-07-29) —
|
||||
// but the garage door is a cover too, and it stays shut on a default tap
|
||||
if (domain === 'cover' && COVER_GUARDED_CLASSES.has(String(deviceClass || ''))) return 'info';
|
||||
return 'toggle';
|
||||
}
|
||||
|
||||
/** Domains a tap may RUN (owner's spec 2026-07-29): the runnable units of
|
||||
* HA. An automation is triggered, a script and a scene are turned on. */
|
||||
export const RUN_TARGET_DOMAINS = ['automation', 'script', 'scene'] as const;
|
||||
|
||||
/** Service to start a runnable target, or null when the id is not runnable. */
|
||||
export function runServiceFor(target: string | null | undefined): { domain: string; service: string } | null {
|
||||
const dom = String(target || '').split('.')[0];
|
||||
if (dom === 'automation') return { domain: 'automation', service: 'trigger' };
|
||||
if (dom === 'script') return { domain: 'script', service: 'turn_on' };
|
||||
if (dom === 'scene') return { domain: 'scene', service: 'turn_on' };
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------- floors import ----------------
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Device curation and icon rules.
|
||||
* Device filtering and icon rules.
|
||||
*
|
||||
* Icon rules are DATA, not code: the built-in defaults below can be overridden
|
||||
* per instance via `config.settings.icon_rules` (edited in the card UI).
|
||||
@@ -8,7 +8,7 @@
|
||||
* skipped silently at compile time (and flagged in the rules editor).
|
||||
*/
|
||||
|
||||
/** Integration domains whose devices are hidden by default (curation). */
|
||||
/** Integration domains whose devices are hidden by default (filtering). */
|
||||
export const EXCLUDED_DOMAINS = new Set([
|
||||
'hacs', 'sun', 'backup', 'hassio', 'met', 'telegram_bot', 'mobile_app',
|
||||
'systemmonitor', 'better_thermostat', 'adaptive_lighting', 'yandex_pogoda',
|
||||
|
||||
+109
-11
@@ -6,17 +6,59 @@
|
||||
import { declump, contentUrl } from './logic';
|
||||
import type { ServerConfig, SpaceModel, RoomCfg, DevItem } from './types';
|
||||
|
||||
export const NORM_W = 1000; // width of the render space for normalized configs
|
||||
export const NORM_W = 1000; // side of the render space — the canvas is square
|
||||
|
||||
/**
|
||||
* Where a plan image sits inside the square canvas (v1.48.0).
|
||||
*
|
||||
* The canvas has no proportions of its own any more; the image keeps its own
|
||||
* and is centred, so a wide plan gets margins above and below and a tall one
|
||||
* gets them at the sides. `ratio` is the image's width/height; without it we
|
||||
* assume square, which is only ever a brief guess before the file loads.
|
||||
*/
|
||||
export function fitInSquare(ratio: number | null | undefined, side: number) {
|
||||
const r = Number(ratio);
|
||||
const a = Number.isFinite(r) && r > 0 ? r : 1;
|
||||
const w = a >= 1 ? side : side * a;
|
||||
const h = a >= 1 ? side / a : side;
|
||||
return { x: (side - w) / 2, y: (side - h) / 2, w, h };
|
||||
}
|
||||
|
||||
export type Pt = { x: number; y: number };
|
||||
export type Layout = Record<string, { s?: string; x: number; y: number } | undefined>;
|
||||
|
||||
/** Build render-space models (NORM_W × NORM_W/aspect) from a server config. */
|
||||
/** Build render-space models (NORM_W × NORM_W) from a server config. */
|
||||
/** A stored view_box the render can trust: 4 finite numbers, positive sizes.
|
||||
* The server refuses anything else NOW (HP-1502-01), but a store may already
|
||||
* hold [0,0,0,0] or negative sizes from before — and a zero axis serialises
|
||||
* into viewBox="0 0 0 0", which draws nothing on every client. Bad input
|
||||
* falls back to the whole canvas rather than to a blank screen. */
|
||||
function safeViewBox(vb: any): [number, number, number, number] {
|
||||
if (
|
||||
Array.isArray(vb) && vb.length === 4 && vb.every((n: any) => Number.isFinite(n))
|
||||
&& vb[2] > 1e-6 && vb[3] > 1e-6
|
||||
) return vb as [number, number, number, number];
|
||||
return [0, 0, 1, 1];
|
||||
}
|
||||
|
||||
/** Legacy rectangle rooms, normalised: a negative size is the same rectangle
|
||||
* drawn from the other corner; the maths downstream assumes w/h >= 0. */
|
||||
function normRect(r: any): { x?: number; y?: number; w?: number; h?: number } {
|
||||
if (r.x == null || r.y == null) return { x: r.x, y: r.y, w: r.w, h: r.h };
|
||||
const w = Number(r.w) || 0, h = Number(r.h) || 0;
|
||||
return {
|
||||
x: w < 0 ? r.x + w : r.x,
|
||||
y: h < 0 ? r.y + h : r.y,
|
||||
w: Math.abs(w),
|
||||
h: Math.abs(h),
|
||||
};
|
||||
}
|
||||
|
||||
export function spaceModels(cfg: ServerConfig | null): SpaceModel[] {
|
||||
if (!cfg || !Array.isArray(cfg.spaces)) return [];
|
||||
return cfg.spaces.map((s: any) => {
|
||||
const H = NORM_W / s.aspect;
|
||||
const scale = (r: any): RoomCfg => ({
|
||||
const H = NORM_W; // square canvas
|
||||
const scale = (raw: any): RoomCfg => { const r = { ...raw, ...normRect(raw) }; return {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
area: r.area ?? null,
|
||||
@@ -30,17 +72,75 @@ export function spaceModels(cfg: ServerConfig | null): SpaceModel[] {
|
||||
w: r.w != null ? r.w * NORM_W : undefined,
|
||||
h: r.h != null ? r.h * H : undefined,
|
||||
poly: r.poly ? r.poly.map((p: number[]) => [p[0] * NORM_W, p[1] * H]) : undefined,
|
||||
});
|
||||
}; };
|
||||
const vb = safeViewBox(s.view_box);
|
||||
return {
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
vb: [s.view_box[0] * NORM_W, s.view_box[1] * H, s.view_box[2] * NORM_W, s.view_box[3] * H],
|
||||
bg: s.plan_url ? { href: contentUrl(s.plan_url), x: 0, y: 0, w: NORM_W, h: H } : null,
|
||||
vb: [vb[0] * NORM_W, vb[1] * H, vb[2] * NORM_W, vb[3] * H],
|
||||
bg: s.plan_url ? { href: contentUrl(s.plan_url), ...fitInSquare(s.plan_aspect, NORM_W) } : null,
|
||||
rooms: (s.rooms || []).map(scale),
|
||||
} as SpaceModel;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* What the plan actually occupies, padded by `pad` of the larger side.
|
||||
*
|
||||
* The canvas is a square big enough for any house, so a small hand-drawn plan
|
||||
* used to open as a speck in the middle of it. Zooming to the CONTENT instead
|
||||
* of the canvas is what people expect — but only when there is no background
|
||||
* image: with one, the image is the plan, and cropping to the rooms would hide
|
||||
* the parts of it nobody has outlined yet.
|
||||
*
|
||||
* Returns null when there is nothing drawn, so the caller keeps the full canvas.
|
||||
*/
|
||||
export function contentBounds(
|
||||
space: SpaceModel, pad = 0.05, extra?: ReadonlyArray<readonly [number, number]>,
|
||||
): { x: number; y: number; w: number; h: number } | null {
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
// Only points within a bounded envelope around the canvas command the
|
||||
// opening view. The server bounds what it ACCEPTS now (±4 normalised,
|
||||
// HP-1501-01), but a store may already hold an absurd coordinate from
|
||||
// before that door existed — and one 1e100 vertex framed the space so wide
|
||||
// the plan was a dot for every client. An out-of-envelope point is still
|
||||
// rendered wherever it is; it just does not decide the frame. This applies
|
||||
// to ROOM GEOMETRY and device positions alike (HP-1500-03, HP-1501-01).
|
||||
const lo = -NORM_W * 0.25, hi = NORM_W * 1.25;
|
||||
const add = (x: number, y: number) => {
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) return;
|
||||
if (x < lo || x > hi || y < lo || y > hi) return;
|
||||
if (x < minX) minX = x;
|
||||
if (y < minY) minY = y;
|
||||
if (x > maxX) maxX = x;
|
||||
if (y > maxY) maxY = y;
|
||||
};
|
||||
for (const r of space.rooms || []) {
|
||||
if (r.poly) for (const p of r.poly) add(p[0], p[1]);
|
||||
else if (r.x != null && r.y != null) {
|
||||
add(r.x, r.y);
|
||||
add(r.x + (r.w || 0), r.y + (r.h || 0));
|
||||
}
|
||||
}
|
||||
// things that live outside any room still count as content — a gate sensor
|
||||
// by the fence, a camera on a pole (the card passes device positions here)
|
||||
for (const p of extra || []) add(p[0], p[1]);
|
||||
if (minX > maxX || minY > maxY) return null;
|
||||
// A single marker (or a collinear row of them) has no area, and an SVG
|
||||
// viewBox with a zero axis draws nothing at all (HP-1500-03). An axis with
|
||||
// essentially no span — nothing there but icons — opens up to a floor:
|
||||
// enough canvas around a lone marker to see where it stands. A REAL thin
|
||||
// shape (a 100-unit corridor) keeps its tight frame; only the degenerate
|
||||
// case is padded, so the threshold sits at about an icon's size.
|
||||
const FLOOR = NORM_W * 0.2;
|
||||
const DEGENERATE = NORM_W * 0.03;
|
||||
if (maxX - minX < DEGENERATE) { const c = (minX + maxX) / 2; minX = c - FLOOR / 2; maxX = c + FLOOR / 2; }
|
||||
if (maxY - minY < DEGENERATE) { const c = (minY + maxY) / 2; minY = c - FLOOR / 2; maxY = c + FLOOR / 2; }
|
||||
const m = Math.max(maxX - minX, maxY - minY) * pad;
|
||||
const x = minX - m, y = minY - m;
|
||||
return { x, y, w: (maxX - minX) + m * 2, h: (maxY - minY) + m * 2 };
|
||||
}
|
||||
|
||||
/** Bounding rectangle of a room (rect or polygon) in render units. */
|
||||
export function roomBounds(r: RoomCfg): { x: number; y: number; w: number; h: number } {
|
||||
if (r.poly && r.poly.length) {
|
||||
@@ -91,8 +191,7 @@ export function defaultPositions(devs: DevItem[], model: SpaceModel, iconPct: nu
|
||||
export function markerPos(d: DevItem, layout: Layout, cfg: ServerConfig, defPos: Record<string, Pt>, model: SpaceModel): Pt {
|
||||
const saved = layout[d.id];
|
||||
if (saved && saved.s === d.space) {
|
||||
const aspect = cfg.spaces.find((x: any) => x.id === d.space)?.aspect || 1;
|
||||
return { x: saved.x * NORM_W, y: saved.y * (NORM_W / aspect) };
|
||||
return { x: saved.x * NORM_W, y: saved.y * NORM_W };
|
||||
}
|
||||
if (defPos[d.id]) return defPos[d.id];
|
||||
const vb = model.vb;
|
||||
@@ -103,8 +202,7 @@ export function markerPos(d: DevItem, layout: Layout, cfg: ServerConfig, defPos:
|
||||
export function labelPos(r: RoomCfg, spaceId: string, layout: Layout, cfg: ServerConfig): Pt {
|
||||
const saved = layout['rl_' + (r.id || '')];
|
||||
if (saved && saved.s === spaceId) {
|
||||
const aspect = cfg.spaces.find((x: any) => x.id === spaceId)?.aspect || 1;
|
||||
return { x: saved.x * NORM_W, y: saved.y * (NORM_W / aspect) };
|
||||
return { x: saved.x * NORM_W, y: saved.y * NORM_W };
|
||||
}
|
||||
const c = roomCenter(r);
|
||||
return { x: c[0], y: c[1] };
|
||||
|
||||
+25
-7
@@ -65,8 +65,19 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
|
||||
loc,
|
||||
iconRules,
|
||||
});
|
||||
const devs = all.filter((d) => d.space === o.spaceId);
|
||||
const defPos = defaultPositions(devs, space, iconPct);
|
||||
// Two lists, two jobs (HP-1510-01): AGGREGATION sees every device of the
|
||||
// space — hidden ones still count toward room LQI, same as the full card —
|
||||
// while RENDERING sees only the visible ones (there is no editor here, so
|
||||
// hidden devices are never drawn). Filtering one list for both jobs made
|
||||
// the same room show different Zigbee health on the two cards.
|
||||
const spaceDevs = all.filter((d) => d.space === o.spaceId);
|
||||
const devs = spaceDevs.filter((d) => !d.hidden);
|
||||
// The auto grid is computed over the FULL roster, hidden included — the
|
||||
// full card reserves grid cells for hidden devices (their ghosts keep a
|
||||
// place in the device editor), so the static card must too, or the same
|
||||
// visible marker with no saved position lands in different spots on the
|
||||
// two cards (HP-1511-01). Rendering still draws `devs` only.
|
||||
const defPos = defaultPositions(spaceDevs, space, iconPct);
|
||||
|
||||
const roomShapes = space.rooms
|
||||
.filter((r) => r.area || disp.showBorders)
|
||||
@@ -82,9 +93,9 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
|
||||
const fillC = r.area
|
||||
? roomFillStyle(
|
||||
fill,
|
||||
fill === 'lqi' ? areaLqi(o.hass, devs, r.area) : null,
|
||||
fill === 'light' ? areaLights(o.hass, devs, r.area) : 'none',
|
||||
fill === 'temp' ? areaTemp(o.hass, devs, r.area) : null,
|
||||
fill === 'lqi' ? areaLqi(o.hass, spaceDevs, r.area) : null,
|
||||
fill === 'light' ? areaLights(o.hass, spaceDevs, r.area) : 'none',
|
||||
fill === 'temp' ? areaTemp(o.hass, spaceDevs, r.area) : null,
|
||||
disp.tempMin,
|
||||
disp.tempMax,
|
||||
fillColorsOf(o.cfg?.settings),
|
||||
@@ -110,8 +121,15 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
|
||||
const p = markerPos(d, o.layout, o.cfg, defPos, space);
|
||||
const left = ((p.x - vb[0]) / vb[2]) * 100;
|
||||
const top = ((p.y - vb[1]) / vb[3]) * 100;
|
||||
return html`<div class="dev ${d.virtual ? 'virtual' : ''}" style="left:${left}%;top:${top}%">
|
||||
<ha-icon icon="${d.icon}"></ha-icon>
|
||||
// per-marker size and rotation, exactly as on the full card (HP-1513-01):
|
||||
// the same stored marker must look the same on both cards. Geometry only —
|
||||
// no live-state dressing here, the static card stays a schematic.
|
||||
const scale = Number(d.marker?.size) > 0 ? Number(d.marker!.size) : 1;
|
||||
const angle = Number(d.marker?.angle) || 0;
|
||||
const st = [`left:${left}%`, `top:${top}%`];
|
||||
if (scale !== 1) st.push(`--dev-scale:${scale}`);
|
||||
return html`<div class="dev ${d.virtual ? 'virtual' : ''}" style="${st.join(';')}">
|
||||
<ha-icon icon="${d.icon}" style=${angle ? `transform:rotate(${angle}deg)` : nothing}></ha-icon>
|
||||
</div>`;
|
||||
});
|
||||
|
||||
|
||||
+97
-14
@@ -383,27 +383,57 @@ export const cardStyles = css`
|
||||
gap: 0.25em;
|
||||
font-size: calc(1em * var(--rl-name, 1));
|
||||
}
|
||||
/* Switching spaces by swipe or on the kiosk carousel: the plan flies out
|
||||
the way the finger went and the next one arrives from the other side. */
|
||||
@keyframes hp-slide-left {
|
||||
0% { transform: translateX(22%); opacity: 0; }
|
||||
100% { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
@keyframes hp-slide-right {
|
||||
0% { transform: translateX(-22%); opacity: 0; }
|
||||
100% { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
.zoomwrap.slide-left { animation: hp-slide-left 0.26s cubic-bezier(0.22, 0.61, 0.36, 1); }
|
||||
.zoomwrap.slide-right { animation: hp-slide-right 0.26s cubic-bezier(0.22, 0.61, 0.36, 1); }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.zoomwrap.slide-left, .zoomwrap.slide-right { animation: none; }
|
||||
}
|
||||
/* The name is the anchor: the label box is centred on the room point, so
|
||||
anything that takes part in its layout SHIFTS THE NAME. The gear button
|
||||
and the metrics hang below as absolutes — the name renders in exactly
|
||||
the same place in view mode and in the plan editor (owner's request),
|
||||
and the button sits at the very bottom of the card. */
|
||||
/* Standalone, centred on the room, sized from the device icon: icon-size
|
||||
already rescales with the view, so the button zooms WITH the plan
|
||||
instead of keeping a constant screen size (owner's spec). */
|
||||
.rlgearbtn {
|
||||
--gear-h: calc(var(--icon-size, 2.5cqw) * 0.77); /* owner: half the previous size */
|
||||
position: absolute;
|
||||
/* dead-centred on the room, both axes (owner's spec) */
|
||||
transform: translate(-50%, -50%);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-bottom: 3px;
|
||||
padding: 3px 8px;
|
||||
gap: 0.35em;
|
||||
height: var(--gear-h);
|
||||
padding: 0 calc(var(--gear-h) * 0.38);
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: var(--hp-accent);
|
||||
color: var(--text-primary-color, #fff);
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
font-size: calc(var(--gear-h) * 0.42);
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
opacity: 0.92;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
|
||||
z-index: 2;
|
||||
}
|
||||
.rlgearbtn:hover { opacity: 1; }
|
||||
.rlgearbtn ha-icon { --mdc-icon-size: 14px; display: inline-flex; }
|
||||
.rlgearbtn { transition: opacity 0.15s, filter 0.15s; }
|
||||
.rlgearbtn:hover { opacity: 1; filter: brightness(1.18); }
|
||||
.rlgearbtn ha-icon { --mdc-icon-size: calc(var(--gear-h) * 0.55); display: inline-flex; }
|
||||
.rlgear {
|
||||
--mdc-icon-size: 0.9em;
|
||||
display: inline-flex;
|
||||
@@ -424,6 +454,11 @@ export const cardStyles = css`
|
||||
}
|
||||
.stage.mode-view .rlgo:hover { opacity: 1; }
|
||||
.roomlabel .rlmetrics {
|
||||
position: absolute; /* below the name, outside the centring math */
|
||||
top: calc(100% + 0.15em);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55em;
|
||||
@@ -841,13 +876,9 @@ export const cardStyles = css`
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* RGB lights: the bulb takes the light's actual color */
|
||||
.dev.rgb ha-icon { color: var(--light-color); }
|
||||
.dev.rgb.on {
|
||||
box-shadow: 0 0 10px var(--light-color);
|
||||
border-color: var(--light-color);
|
||||
background: var(--hp-bg);
|
||||
color: var(--light-color);
|
||||
}
|
||||
/* v1.52.0: the RGB tint of the icon/border is gone — a lamp's colour
|
||||
lives ONLY in its glow spot (owner's rule). The ripple-color fallback
|
||||
keeps using the light colour; that is set inline via --ripple-color. */
|
||||
/* alarms pulse red over everything */
|
||||
.dev.alarm::after {
|
||||
content: '';
|
||||
@@ -904,7 +935,11 @@ export const cardStyles = css`
|
||||
z-index: 2;
|
||||
}
|
||||
.dev ha-icon {
|
||||
--mdc-icon-size: calc(var(--icon-size, 2.5cqw) * 0.62);
|
||||
/* from --dev-size, NOT --icon-size: the per-device size multiplier must
|
||||
scale the GLYPH with its badge. Pinned to the base size, "make this
|
||||
icon bigger" grew an empty box around a default-size glyph (user
|
||||
report via the owner, 2026-07-29). */
|
||||
--mdc-icon-size: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.62);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -934,6 +969,20 @@ export const cardStyles = css`
|
||||
.dev.virtual {
|
||||
border-style: dashed;
|
||||
}
|
||||
/* "hide from plan" flag, shown only in the device editor with the
|
||||
"show hidden devices" toggle on (docs/FILTERING.md). BLUE, so a hidden
|
||||
device cannot be mistaken for an unavailable one (translucent dark) —
|
||||
and no live-state paint at all: a ghost is configuration, not status
|
||||
(owner's request). */
|
||||
.dev.ghost {
|
||||
opacity: 0.6;
|
||||
border-style: dashed;
|
||||
border-color: var(--hp-accent);
|
||||
background: rgba(62, 166, 255, 0.22); /* fallback for old WebViews */
|
||||
background: color-mix(in srgb, var(--hp-accent) 30%, var(--card-background-color, #1c2530));
|
||||
color: var(--hp-accent);
|
||||
box-shadow: none;
|
||||
}
|
||||
.dev.sel {
|
||||
border-color: #ffc14d;
|
||||
box-shadow: 0 0 0 3px rgba(255, 193, 77, 0.35);
|
||||
@@ -1062,6 +1111,40 @@ export const cardStyles = css`
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
/* the "already uploaded" picker: a plan is never deleted for being
|
||||
unreferenced, so it has to be findable again */
|
||||
.savedplans {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
margin: 6px 0 2px;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--hp-line);
|
||||
border-radius: 8px;
|
||||
background: var(--hp-bg2, rgba(255, 255, 255, 0.03));
|
||||
}
|
||||
.savedplan {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.savedplan.cur { outline: 1px solid var(--hp-accent); border-radius: 6px; }
|
||||
.savedplan img {
|
||||
width: 56px;
|
||||
height: 40px;
|
||||
object-fit: contain;
|
||||
border: 1px solid var(--hp-line);
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
flex: none;
|
||||
}
|
||||
.savedmeta { display: flex; flex-direction: column; min-width: 0; flex: 1; }
|
||||
.savedmeta b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.savedmeta .muted { font-size: 11px; }
|
||||
.savedplan .btn.danger ha-icon { color: #f25a4a; }
|
||||
.savedplan .btn[disabled] { opacity: 0.4; pointer-events: none; }
|
||||
.planprev {
|
||||
max-width: 120px;
|
||||
max-height: 70px;
|
||||
|
||||
+10
-1
@@ -51,6 +51,10 @@ export interface Marker {
|
||||
description?: string | null;
|
||||
pdfs?: PdfRef[];
|
||||
tap_action?: string | null; // per-device override: 'info' | 'more-info' | 'toggle'
|
||||
/** 'run' target: automation./script./scene. entity id. */
|
||||
tap_target?: string | null;
|
||||
/** Ask before toggle/run — accidental-tap guard (owner's spec). */
|
||||
tap_confirm?: boolean | null;
|
||||
room_id?: string | null; // manual placement into a room WITHOUT an HA area (sub-area rooms)
|
||||
display?: 'badge' | 'ripple' | 'icon_ripple' | 'value' | null; // how the device is drawn
|
||||
ripple_color?: string | null;
|
||||
@@ -90,7 +94,9 @@ export interface ServerConfig {
|
||||
settings: {
|
||||
exclude_integrations?: string[];
|
||||
group_lights?: boolean;
|
||||
show_all?: boolean;
|
||||
show_all?: boolean; // legacy: removed when the config is materialised
|
||||
/** The filter flags are materialised into markers (docs/FILTERING.md). */
|
||||
filter_seeded?: boolean;
|
||||
icon_rules?: { pattern: string; icon: string }[];
|
||||
};
|
||||
}
|
||||
@@ -101,6 +107,9 @@ export interface DevItem {
|
||||
model: string;
|
||||
area: string;
|
||||
space: string;
|
||||
/** "Hide from plan": built (so room LQI still counts it) but not rendered,
|
||||
* except ghosted in the device editor with "show hidden" on. */
|
||||
hidden?: boolean;
|
||||
icon: string;
|
||||
entities: string[];
|
||||
primary?: string;
|
||||
|
||||
+103
-1
@@ -1,6 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildDevices, lightGroups, primaryEntity, lqiFor, tempFor, humFor, areaLights, areaTemp, areaHum, areaLightStats, sourceValue , areaClimate, areaClimateMap } from '../test-build/devices.js';
|
||||
import { buildDevices, lightGroups, primaryEntity, lqiFor, tempFor, humFor, areaLights, areaTemp, areaHum, areaLightStats, sourceValue , areaClimate, areaClimateMap, litLightEntity, seedHiddenBindings } from '../test-build/devices.js';
|
||||
import { compileIconRules, iconFor } from '../test-build/rules.js';
|
||||
|
||||
/** Minimal fake hass around the pieces buildDevices reads. */
|
||||
@@ -483,3 +483,105 @@ test('areaClimateMap: температура и влажность живут в
|
||||
};
|
||||
assert.deepEqual(areaClimateMap(hass).get('hall'), { temp: 21.4, hum: 48 });
|
||||
});
|
||||
|
||||
|
||||
test('primaryEntity: a service switch never beats the visible main function (TRV)', () => {
|
||||
// the owner's radiator heads, verified live: switch outranks climate in the
|
||||
// domain list, and the old inner-tier loop let a CONFIG switch (anti
|
||||
// scaling, child lock) become primary — the icon glowed yellow because
|
||||
// scale protection was on, while the head actually heating stayed dark
|
||||
const hass = {
|
||||
entities: {
|
||||
'climate.trv': {},
|
||||
'sensor.trv_temp': {},
|
||||
'switch.trv_anti_scaling': { entity_category: 'config' },
|
||||
'switch.trv_child_lock': { entity_category: 'config' },
|
||||
},
|
||||
states: {},
|
||||
};
|
||||
assert.equal(
|
||||
primaryEntity(hass, ['switch.trv_anti_scaling', 'switch.trv_child_lock', 'climate.trv', 'sensor.trv_temp'], 'mdi:thermostat'),
|
||||
'climate.trv',
|
||||
);
|
||||
// but a device whose only functional entity IS a switch keeps it
|
||||
const plug = { entities: { 'switch.plug': {}, 'sensor.plug_power': {} }, states: {} };
|
||||
assert.equal(primaryEntity(plug, ['sensor.plug_power', 'switch.plug'], 'mdi:power-socket'), 'switch.plug');
|
||||
});
|
||||
|
||||
test('litLightEntity: one truth for "this thing is shining"', () => {
|
||||
const hass = { states: {
|
||||
'light.a': { state: 'off' }, 'light.b': { state: 'on' },
|
||||
'switch.wall': { state: 'on' }, 'sensor.x': { state: '5' },
|
||||
} };
|
||||
// a lit light.* anywhere in the device
|
||||
assert.equal(litLightEntity(hass, { entities: ['light.a', 'light.b'] }), 'light.b');
|
||||
assert.equal(litLightEntity(hass, { entities: ['light.a'] }), null);
|
||||
// without the flag a lit switch is NOT a light
|
||||
assert.equal(litLightEntity(hass, { entities: ['switch.wall'] }), null);
|
||||
// with "is a light source" any lit entity counts, controls first
|
||||
assert.equal(
|
||||
litLightEntity(hass, { entities: ['sensor.x'], marker: { is_light: true, controls: ['switch.wall'] } }),
|
||||
'switch.wall',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
// ---------- explicit hide-from-plan flags (docs/FILTERING.md) ----------
|
||||
|
||||
test('seedHiddenBindings: non-physical devices in bound areas, unmarked only', () => {
|
||||
const h = mkHass({ devices: {
|
||||
lamp: dev('lamp', 'Lamp', 'Bulb', 'living'),
|
||||
bridge: dev('bridge', 'Z2M Bridge', 'Bridge', 'living'),
|
||||
grp: dev('grp', 'All lights', 'Group', 'living'),
|
||||
scn: dev('scn', 'Evening', 'Scene switch', 'living'),
|
||||
faraway: dev('faraway', 'Bridge 2', 'Bridge', 'garage'), // unbound area
|
||||
}});
|
||||
const ctx = { hass: h, areaToSpace: { living: 'f1' }, markers: [], settings: {},
|
||||
excluded: new Set(['hacs']), firstSpaceId: 'f1' };
|
||||
assert.deepEqual(seedHiddenBindings(ctx).sort(), ['device:bridge', 'device:grp', 'device:scn']);
|
||||
// a marker of ANY kind — even hidden: false — is the user's decision
|
||||
const marked = { ...ctx, markers: [
|
||||
{ id: 'x', binding: 'device:bridge', hidden: false },
|
||||
{ id: 'y', binding: 'device:grp', hidden: true },
|
||||
] };
|
||||
assert.deepEqual(seedHiddenBindings(marked), ['device:scn'], 'marked devices are never revisited');
|
||||
});
|
||||
|
||||
test('seeded config: hidden is a flag, not an absence', () => {
|
||||
const h = mkHass({ devices: {
|
||||
lamp: dev('lamp', 'Lamp', 'Bulb', 'living'),
|
||||
plug: dev('plug', 'Plug', 'Socket', 'living'),
|
||||
}});
|
||||
const res = buildDevices(baseCtx(h, {
|
||||
settings: { filter_seeded: true },
|
||||
markers: [{ id: 'hplug', binding: 'device:plug', hidden: true }],
|
||||
}));
|
||||
const plug = res.find((d) => d.bindingRef === 'plug');
|
||||
assert.ok(plug, 'the hidden device is BUILT — room LQI still counts it');
|
||||
assert.equal(plug.hidden, true, 'and flagged for the renderer');
|
||||
assert.ok(!res.find((d) => d.id === 'lamp').hidden);
|
||||
});
|
||||
|
||||
test('legacy config (no filter_seeded): the runtime filter still applies', () => {
|
||||
const h = mkHass({ devices: {
|
||||
lamp: dev('lamp', 'Lamp', 'Bulb', 'living'),
|
||||
bridge: dev('bridge', 'Hub', 'Bridge', 'living'),
|
||||
}});
|
||||
const res = buildDevices(baseCtx(h));
|
||||
assert.deepEqual(res.map((d) => d.id), ['lamp'], 'a read-only client on an old config sees the old behaviour');
|
||||
// and a hidden marker still removes the device entirely, as before
|
||||
const res2 = buildDevices(baseCtx(h, { markers: [{ id: 'x', binding: 'device:lamp', hidden: true }] }));
|
||||
assert.deepEqual(res2.map((d) => d.id), []);
|
||||
});
|
||||
|
||||
test('hidden lights cast no visible light, but count toward LQI', () => {
|
||||
const h = mkHass({
|
||||
devices: {},
|
||||
states: { 'light.a': { state: 'on', attributes: {} } },
|
||||
});
|
||||
const devices = [
|
||||
{ area: 'living', entities: ['light.a'], hidden: true },
|
||||
];
|
||||
assert.equal(areaLights(h, devices, 'living'), 'none', 'an invisible device casts no visible light');
|
||||
assert.equal(areaLightStats(h, devices, 'living'), null);
|
||||
});
|
||||
|
||||
+53
-4
@@ -15,8 +15,7 @@ import {
|
||||
contentUrl, chunk, referencedContentUrls, MAX_SIGN_PATHS,
|
||||
interiorPoint,
|
||||
segmentCm, formatLength, roomEdges, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap,
|
||||
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices,
|
||||
} from '../test-build/logic.js';
|
||||
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, poleOfInaccessibility, runServiceFor, TOGGLE_SAFE_DOMAINS } from '../test-build/logic.js';
|
||||
import {
|
||||
iconFor, compileIconRules, isValidPattern, iconFromDeviceClasses,
|
||||
} from '../test-build/rules.js';
|
||||
@@ -191,8 +190,13 @@ test('tap action: defaults — info everywhere except pure lights (v1.39.0)', ()
|
||||
test('tap action: card-wide toggle only touches safe domains', () => {
|
||||
assert.equal(resolveTapAction(null, 'toggle', 'light'), 'toggle');
|
||||
assert.equal(resolveTapAction(null, 'toggle', 'switch'), 'toggle');
|
||||
assert.equal(resolveTapAction(null, 'toggle', 'cover'), 'info'); // garage stays shut
|
||||
assert.equal(resolveTapAction(null, 'toggle', 'valve'), 'info');
|
||||
// covers/valves joined the safe set for curtains (owner, 2026-07-29)…
|
||||
assert.equal(resolveTapAction(null, 'toggle', 'cover', 'curtain'), 'toggle');
|
||||
assert.equal(resolveTapAction(null, 'toggle', 'valve'), 'toggle');
|
||||
// …but the garage door is a cover too, and it STAYS SHUT on a default tap
|
||||
assert.equal(resolveTapAction(null, 'toggle', 'cover', 'garage'), 'info');
|
||||
assert.equal(resolveTapAction(null, 'toggle', 'cover', 'gate'), 'info');
|
||||
assert.equal(resolveTapAction('toggle', null, 'cover', 'garage'), 'toggle', 'explicit stays a conscious choice');
|
||||
assert.equal(resolveTapAction(null, 'toggle', 'sensor'), 'info');
|
||||
});
|
||||
|
||||
@@ -910,3 +914,48 @@ test('chunk / referencedContentUrls: signing batches and cache pruning (review R
|
||||
assert.equal(referencedContentUrls(null).size, 0);
|
||||
assert.equal(referencedContentUrls({}).size, 0);
|
||||
});
|
||||
|
||||
|
||||
test('poleOfInaccessibility: the VISUAL centre, not just any interior point', () => {
|
||||
// a square: the pole is the centre
|
||||
const sq = [[0, 0], [10, 0], [10, 10], [0, 10]];
|
||||
const p = poleOfInaccessibility(sq);
|
||||
assert.ok(Math.abs(p[0] - 5) < 0.6 && Math.abs(p[1] - 5) < 0.6);
|
||||
|
||||
// an ELONGATED rectangle: clearance is a plateau along the midline, and a
|
||||
// plain argmax used to take its first point — left of centre on the
|
||||
// owner's kitchen, above centre in the sauna. The centroid pull centres it.
|
||||
const wide = [[0, 0], [30, 0], [30, 10], [0, 10]];
|
||||
const w = poleOfInaccessibility(wide);
|
||||
assert.ok(Math.abs(w[0] - 15) < 1.2 && Math.abs(w[1] - 5) < 1.2, 'centred both axes: ' + w);
|
||||
const tall = [[0, 0], [8, 0], [8, 28], [0, 28]];
|
||||
const tl = poleOfInaccessibility(tall);
|
||||
assert.ok(Math.abs(tl[0] - 4) < 1 && Math.abs(tl[1] - 14) < 1.2, 'sauna case, centred vertically: ' + tl);
|
||||
|
||||
// the owner's kitchen-living room shape: a THICK top slab with a narrower
|
||||
// column hanging off the right side. The button belongs near the middle of
|
||||
// the slab — pulled toward the area centroid, never down the thin column.
|
||||
const L = [[0, 0], [20, 0], [20, 16], [16, 16], [16, 8], [0, 8]];
|
||||
const q = poleOfInaccessibility(L);
|
||||
assert.ok(pointInPolygon(q, L), 'inside, always');
|
||||
assert.ok(q[1] < 8, 'in the thick slab (y < 8), not down the column: ' + q);
|
||||
assert.ok(Math.abs(q[0] - 11.3) < 2.5, 'near the centroid x within the slab: ' + q);
|
||||
});
|
||||
|
||||
|
||||
test('tap action run: explicit-only, with runnable targets (owner spec 2026-07-29)', () => {
|
||||
// 'run' resolves only as an explicit per-marker action
|
||||
assert.equal(resolveTapAction('run', undefined, 'switch'), 'run');
|
||||
assert.equal(resolveTapAction(null, 'run', 'switch'), 'info', 'never a card-wide default');
|
||||
// runnable domains map to their services; anything else is not runnable
|
||||
assert.deepEqual(runServiceFor('automation.morning'), { domain: 'automation', service: 'trigger' });
|
||||
assert.deepEqual(runServiceFor('script.curtains'), { domain: 'script', service: 'turn_on' });
|
||||
assert.deepEqual(runServiceFor('scene.movie'), { domain: 'scene', service: 'turn_on' });
|
||||
assert.equal(runServiceFor('light.lamp'), null);
|
||||
assert.equal(runServiceFor(''), null);
|
||||
// covers and valves joined the card-wide toggle domains
|
||||
assert.ok(TOGGLE_SAFE_DOMAINS.has('cover') && TOGGLE_SAFE_DOMAINS.has('valve'));
|
||||
assert.equal(resolveTapAction(null, 'toggle', 'cover', 'shutter'), 'toggle');
|
||||
// locks and alarms stay forbidden, run or not
|
||||
assert.equal(resolveTapAction('toggle', undefined, 'lock'), 'info');
|
||||
});
|
||||
|
||||
+125
-12
@@ -1,34 +1,47 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
NORM_W, spaceModels, roomBounds, roomCenter, defaultPositions, markerPos, labelPos,
|
||||
NORM_W, spaceModels, roomBounds, roomCenter, defaultPositions, markerPos, labelPos, fitInSquare, contentBounds,
|
||||
} from '../test-build/space-geometry.js';
|
||||
|
||||
const cfg = {
|
||||
spaces: [{
|
||||
id: 'f1', title: '1st', aspect: 2, plan_url: '/plans/f1.svg', view_box: [0, 0, 1, 1],
|
||||
id: 'f1', title: '1st', plan_aspect: 2, plan_url: '/plans/f1.svg', view_box: [0, 0, 1, 1],
|
||||
rooms: [{ id: 'r1', name: 'Room', area: 'a1', poly: [[0.1, 0.1], [0.5, 0.1], [0.5, 0.5], [0.1, 0.5]] }],
|
||||
}, {
|
||||
id: 'yard', title: 'Yard', aspect: 1, view_box: [0, 0, 1, 1], rooms: [],
|
||||
id: 'yard', title: 'Yard', view_box: [0, 0, 1, 1], rooms: [],
|
||||
}],
|
||||
markers: [], settings: {},
|
||||
};
|
||||
|
||||
test('spaceModels: scales vb/rooms by NORM_W and H=NORM_W/aspect; bg only with plan_url', () => {
|
||||
test('spaceModels: the canvas is square; the image is centred by its own ratio', () => {
|
||||
const m = spaceModels(cfg);
|
||||
assert.equal(m.length, 2);
|
||||
const f1 = m[0];
|
||||
assert.deepEqual(f1.vb, [0, 0, 1000, 500]); // aspect 2 → H 500
|
||||
assert.deepEqual(f1.vb, [0, 0, 1000, 1000]);
|
||||
assert.equal(f1.bg.href, '/plans/f1.svg');
|
||||
assert.deepEqual(f1.rooms[0].poly, [[100, 50], [500, 50], [500, 250], [100, 250]]);
|
||||
// a plan twice as wide as it is tall: full width, half height, margins above
|
||||
// and below — the canvas has no proportions of its own any more (v1.48.0)
|
||||
assert.deepEqual(f1.bg, { href: '/plans/f1.svg', x: 0, y: 250, w: 1000, h: 500 });
|
||||
assert.deepEqual(f1.rooms[0].poly, [[100, 100], [500, 100], [500, 500], [100, 500]]);
|
||||
assert.equal(m[1].bg, null); // no plan_url
|
||||
assert.equal(spaceModels(null).length, 0);
|
||||
});
|
||||
|
||||
test('fitInSquare: wide gets top/bottom margins, tall gets side margins', () => {
|
||||
assert.deepEqual(fitInSquare(2, 1000), { x: 0, y: 250, w: 1000, h: 500 });
|
||||
assert.deepEqual(fitInSquare(0.5, 1000), { x: 250, y: 0, w: 500, h: 1000 });
|
||||
assert.deepEqual(fitInSquare(1, 1000), { x: 0, y: 0, w: 1000, h: 1000 });
|
||||
// unknown ratio (image not loaded yet, old config): assume square
|
||||
for (const bad of [null, undefined, 0, -3, NaN, 'x']) {
|
||||
assert.deepEqual(fitInSquare(bad, 1000), { x: 0, y: 0, w: 1000, h: 1000 });
|
||||
}
|
||||
});
|
||||
|
||||
test('roomBounds + roomCenter for a polygon', () => {
|
||||
const r = spaceModels(cfg)[0].rooms[0];
|
||||
assert.deepEqual(roomBounds(r), { x: 100, y: 50, w: 400, h: 200 });
|
||||
assert.deepEqual(roomCenter(r), [300, 150]);
|
||||
assert.deepEqual(roomBounds(r), { x: 100, y: 100, w: 400, h: 400 });
|
||||
assert.deepEqual(roomCenter(r), [300, 300]);
|
||||
});
|
||||
|
||||
test('markerPos: saved layout → default grid → space centre', () => {
|
||||
@@ -37,7 +50,7 @@ test('markerPos: saved layout → default grid → space centre', () => {
|
||||
// saved layout (normalized) → render units
|
||||
assert.deepEqual(
|
||||
markerPos(dev, { d1: { s: 'f1', x: 0.2, y: 0.3 } }, cfg, {}, model),
|
||||
{ x: 200, y: 150 }, // 0.2*1000, 0.3*(1000/2)
|
||||
{ x: 200, y: 300 }, // 0.2*1000, 0.3*1000
|
||||
);
|
||||
// default grid position (inside the room)
|
||||
const defPos = defaultPositions([dev], model, 2.5);
|
||||
@@ -46,14 +59,14 @@ test('markerPos: saved layout → default grid → space centre', () => {
|
||||
const b = roomBounds(model.rooms[0]);
|
||||
assert.ok(defPos.d1.x >= b.x && defPos.d1.x <= b.x + b.w && defPos.d1.y >= b.y && defPos.d1.y <= b.y + b.h);
|
||||
// no layout, no defPos → space centre
|
||||
assert.deepEqual(markerPos(dev, {}, cfg, {}, model), { x: 500, y: 250 });
|
||||
assert.deepEqual(markerPos(dev, {}, cfg, {}, model), { x: 500, y: 500 });
|
||||
});
|
||||
|
||||
test('labelPos: saved rl_<id> → render units; else room centre', () => {
|
||||
const model = spaceModels(cfg)[0];
|
||||
const r = model.rooms[0];
|
||||
assert.deepEqual(labelPos(r, 'f1', { rl_r1: { s: 'f1', x: 0.3, y: 0.4 } }, cfg), { x: 300, y: 200 });
|
||||
assert.deepEqual(labelPos(r, 'f1', {}, cfg), { x: 300, y: 150 }); // room centre
|
||||
assert.deepEqual(labelPos(r, 'f1', { rl_r1: { s: 'f1', x: 0.3, y: 0.4 } }, cfg), { x: 300, y: 400 });
|
||||
assert.deepEqual(labelPos(r, 'f1', {}, cfg), { x: 300, y: 300 }); // room centre
|
||||
});
|
||||
|
||||
test('defaultPositions: several devices in one room are spread (declumped, distinct)', () => {
|
||||
@@ -70,3 +83,103 @@ test('defaultPositions: several devices in one room are spread (declumped, disti
|
||||
});
|
||||
|
||||
test('NORM_W is 1000', () => assert.equal(NORM_W, 1000));
|
||||
|
||||
test('contentBounds: fits what is drawn, with a 5% margin', () => {
|
||||
const one = spaceModels({ spaces: [{
|
||||
id: 's', view_box: [0, 0, 1, 1],
|
||||
rooms: [{ id: 'r', poly: [[0.4, 0.4], [0.6, 0.4], [0.6, 0.6], [0.4, 0.6]] }],
|
||||
}], markers: [] })[0];
|
||||
// 200x200 render units in the middle of a 1000x1000 canvas, +5% of 200 each side
|
||||
assert.deepEqual(contentBounds(one), { x: 390, y: 390, w: 220, h: 220 });
|
||||
|
||||
// rectangles count too, and the margin follows the LARGER side
|
||||
const rect = spaceModels({ spaces: [{
|
||||
id: 's', view_box: [0, 0, 1, 1],
|
||||
rooms: [{ id: 'r', x: 0.1, y: 0.4, w: 0.6, h: 0.1 }],
|
||||
}], markers: [] })[0];
|
||||
const b = contentBounds(rect);
|
||||
assert.equal(Math.round(b.w), 660); // 600 + 2 * 5% of 600
|
||||
assert.equal(Math.round(b.h), 160); // 100 + the same absolute margin
|
||||
assert.equal(Math.round(b.x), 70);
|
||||
|
||||
// nothing drawn → the caller keeps the whole canvas
|
||||
const empty = spaceModels({ spaces: [{ id: 's', view_box: [0, 0, 1, 1], rooms: [] }], markers: [] })[0];
|
||||
assert.equal(contentBounds(empty), null);
|
||||
});
|
||||
|
||||
test('contentBounds: devices outside every room stretch the frame', () => {
|
||||
const one = spaceModels({ spaces: [{
|
||||
id: 's', view_box: [0, 0, 1, 1],
|
||||
rooms: [{ id: 'r', poly: [[0.4, 0.4], [0.6, 0.4], [0.6, 0.6], [0.4, 0.6]] }],
|
||||
}], markers: [] })[0];
|
||||
// a gate sensor far to the right of the room
|
||||
const b = contentBounds(one, 0.05, [[900, 500]]);
|
||||
// span 400..900 wide, 400..600 tall; margin 5% of the larger side (500)
|
||||
assert.deepEqual(b, { x: 375, y: 375, w: 550, h: 250 });
|
||||
// devices alone are content enough — an empty yard with two cameras
|
||||
const empty = spaceModels({ spaces: [{ id: 's', view_box: [0, 0, 1, 1], rooms: [] }], markers: [] })[0];
|
||||
const only = contentBounds(empty, 0.05, [[100, 100], [300, 200]]);
|
||||
assert.equal(Math.round(only.w), 220);
|
||||
// and junk coordinates are ignored, not spread across the canvas
|
||||
assert.deepEqual(contentBounds(one, 0.05, [[NaN, 5]]), contentBounds(one));
|
||||
});
|
||||
|
||||
test('contentBounds: never degenerate, never unbounded (HP-1500-03)', () => {
|
||||
const empty = spaceModels({ spaces: [{ id: 's', view_box: [0, 0, 1, 1], rooms: [] }], markers: [] })[0];
|
||||
// a single marker has no area — the frame gets a floor instead of a 0x0 viewBox
|
||||
const one = contentBounds(empty, 0.05, [[500, 500]]);
|
||||
assert.ok(one.w >= 200 && one.h >= 200, 'a lone marker still frames some canvas');
|
||||
assert.ok(Math.abs(one.x + one.w / 2 - 500) < 1, 'centred on the marker');
|
||||
// a collinear row: the flat axis gets the floor, the long one keeps its span
|
||||
const row = contentBounds(empty, 0.05, [[100, 500], [900, 500]]);
|
||||
assert.ok(row.h >= 200, 'the flat axis is opened up');
|
||||
assert.ok(row.w > 800, 'the long axis is untouched');
|
||||
// an absurd stored coordinate does not command the frame...
|
||||
const room = spaceModels({ spaces: [{
|
||||
id: 's', view_box: [0, 0, 1, 1],
|
||||
rooms: [{ id: 'r', poly: [[0.4, 0.4], [0.6, 0.4], [0.6, 0.6], [0.4, 0.6]] }],
|
||||
}], markers: [] })[0];
|
||||
assert.deepEqual(contentBounds(room, 0.05, [[1e100, 500]]), contentBounds(room));
|
||||
assert.deepEqual(contentBounds(room, 0.05, [[-1e100, -1e100]]), contentBounds(room));
|
||||
// ...but a device a bit past the canvas edge still counts (the gate sensor)
|
||||
const near = contentBounds(room, 0.05, [[1100, 500]]);
|
||||
assert.ok(near.x + near.w > 1050, 'slightly outside the canvas still stretches the frame');
|
||||
|
||||
// HP-1501-01: the same envelope guards ROOM GEOMETRY — the server refuses
|
||||
// absurd vertices now, but a store may hold one from before that door
|
||||
// existed, and a legacy 1e100 vertex must not frame the plan into a dot
|
||||
const legacy = spaceModels({ spaces: [{
|
||||
id: 's', view_box: [0, 0, 1, 1],
|
||||
rooms: [
|
||||
{ id: 'ok', poly: [[0.4, 0.4], [0.6, 0.4], [0.6, 0.6], [0.4, 0.6]] },
|
||||
{ id: 'bad', poly: [[0, 0], [1e100, 0], [1, 1]] },
|
||||
],
|
||||
}], markers: [] })[0];
|
||||
const lb = contentBounds(legacy);
|
||||
assert.ok(lb.w < 1500 && lb.h < 1500, 'the absurd vertex does not command the frame');
|
||||
assert.ok(lb.x <= 400 && lb.x + lb.w >= 600, 'the sane room is still inside it');
|
||||
// a space where EVERY point is absurd falls back to the whole canvas
|
||||
const allBad = spaceModels({ spaces: [{
|
||||
id: 's', view_box: [0, 0, 1, 1],
|
||||
rooms: [{ id: 'b', poly: [[1e100, 1e100], [2e100, 1e100], [2e100, 2e100]] }],
|
||||
}], markers: [] })[0];
|
||||
assert.equal(contentBounds(allBad), null, 'the caller keeps the full canvas');
|
||||
});
|
||||
|
||||
test('spaceModels: a stored broken view_box falls back to the canvas (HP-1502-01)', () => {
|
||||
// the server refuses these now, but a store may already hold one
|
||||
for (const vb of [[0, 0, 0, 0], [0, 0, -1, -2], [0, 0, 1], null, [0, 0, NaN, 1]]) {
|
||||
const m = spaceModels({ spaces: [{ id: 's', view_box: vb, rooms: [] }], markers: [] })[0];
|
||||
assert.deepEqual(m.vb, [0, 0, 1000, 1000], JSON.stringify(vb) + ' falls back');
|
||||
}
|
||||
// a legitimate crop viewport is preserved
|
||||
const crop = spaceModels({ spaces: [{ id: 's', view_box: [0.1, 0.2, 0.5, 0.4], rooms: [] }], markers: [] })[0];
|
||||
assert.deepEqual(crop.vb, [100, 200, 500, 400]);
|
||||
// a legacy rect with a negative size is the same rectangle from the other corner
|
||||
const m = spaceModels({ spaces: [{
|
||||
id: 's', view_box: [0, 0, 1, 1],
|
||||
rooms: [{ id: 'r', x: 0.6, y: 0.7, w: -0.2, h: -0.3 }],
|
||||
}], markers: [] })[0];
|
||||
const r = m.rooms[0];
|
||||
assert.deepEqual([r.x, r.y, r.w, r.h].map(Math.round), [400, 400, 200, 300]);
|
||||
});
|
||||
|
||||
@@ -34,3 +34,73 @@ async def test_unload(hass: HomeAssistant) -> None:
|
||||
assert await hass.config_entries.async_unload(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert entry.state.value == "not_loaded"
|
||||
|
||||
|
||||
async def test_square_migration_finishes_after_a_crash_between_the_writes(
|
||||
hass: HomeAssistant, hass_storage, monkeypatch
|
||||
) -> None:
|
||||
"""HP-1490-01, end to end on the real stores.
|
||||
|
||||
The layout write is made to fail once, AFTER the config write succeeded —
|
||||
the exact boundary that used to strand the layout in the old coordinates
|
||||
forever, because the config write had already deleted the `aspect` fields
|
||||
the layout half needed. The durable intent must finish the job on the next
|
||||
setup.
|
||||
"""
|
||||
from custom_components.houseplan.store import HouseplanStore
|
||||
|
||||
hass_storage["houseplan.config"] = {
|
||||
"version": 1, "data": {
|
||||
"config": {"spaces": [{"id": "f1", "aspect": 2.0, "rooms": []}],
|
||||
"markers": [], "settings": {}},
|
||||
"rev": 3,
|
||||
},
|
||||
}
|
||||
hass_storage["houseplan.layout"] = {
|
||||
"version": 1, "data": {"layout": {"m": {"s": "f1", "x": 0.1, "y": 0.1}}, "rev": 7},
|
||||
}
|
||||
|
||||
real_save = HouseplanStore.async_save
|
||||
state = {"layout_saves": 0}
|
||||
|
||||
async def failing_save(self, data):
|
||||
if self.key == "houseplan.layout" and "geom_pending" not in data:
|
||||
state["layout_saves"] += 1
|
||||
if state["layout_saves"] == 1:
|
||||
raise OSError("disk full at the worst possible moment")
|
||||
await real_save(self, data)
|
||||
|
||||
monkeypatch.setattr(HouseplanStore, "async_save", failing_save)
|
||||
|
||||
entry = MockConfigEntry(domain=DOMAIN, title="House Plan", data={}, options={})
|
||||
entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# the crash boundary: config migrated, layout not, intent saved
|
||||
cfg = hass_storage["houseplan.config"]["data"]["config"]
|
||||
assert "aspect" not in cfg["spaces"][0], "the config half committed"
|
||||
lay = hass_storage["houseplan.layout"]["data"]
|
||||
assert lay["layout"]["m"]["y"] == 0.1, "the layout half did NOT commit"
|
||||
assert lay.get("geom_pending") == {"f1": 2.0}, "but the intent is durable"
|
||||
|
||||
# next start: the store write works again
|
||||
monkeypatch.setattr(HouseplanStore, "async_save", real_save)
|
||||
if entry.state.value == "loaded":
|
||||
await hass.config_entries.async_unload(entry.entry_id)
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
lay = hass_storage["houseplan.layout"]["data"]
|
||||
assert lay["layout"]["m"] == {"s": "f1", "x": 0.1, "y": 0.3}, (
|
||||
"the saved intent finished the layout half"
|
||||
)
|
||||
assert "geom_pending" not in lay, "and left with the layout write"
|
||||
cfg = hass_storage["houseplan.config"]["data"]["config"]
|
||||
assert cfg["spaces"][0]["view_box"] == [0.0, 0.0, 1.0, 1.0]
|
||||
|
||||
# a third start changes nothing — both triggers are gone
|
||||
before = repr(hass_storage["houseplan.layout"]) + repr(hass_storage["houseplan.config"])
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert repr(hass_storage["houseplan.layout"]) + repr(hass_storage["houseplan.config"]) == before
|
||||
|
||||
@@ -176,10 +176,12 @@ async def test_files_migrate_copies_and_reports_mapping(
|
||||
assert src_kept, "migrate must COPY, not move (review CR-2)"
|
||||
assert other == b"OTHER" and copied == b"SOURCE"
|
||||
|
||||
# cleanup runs only after the config is safely committed
|
||||
# cleanup runs only after the config is safely committed, and since v1.46.5
|
||||
# reports how many files it removed rather than a bare boolean — it now also
|
||||
# keeps anything the stored configuration still references
|
||||
await client.send_json_auto_id({"type": "houseplan/files/cleanup", "marker_id": "old1"})
|
||||
resp2 = await client.receive_json()
|
||||
assert resp2["success"] and resp2["result"]["removed"] is True
|
||||
assert resp2["success"] and resp2["result"]["removed"] >= 1 and resp2["result"]["kept"] == 0
|
||||
assert not await hass.async_add_executor_job(lambda: os.path.isdir(src))
|
||||
|
||||
|
||||
@@ -340,34 +342,41 @@ async def test_commit_does_not_collect_another_client_s_uncommitted_upload(
|
||||
assert not (plans / pa).exists()
|
||||
|
||||
|
||||
async def test_abandoned_uploads_are_collected_once_old(
|
||||
async def test_a_rejected_upload_is_kept_not_aged_out(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""A rejected upload must not accumulate forever — but only age may free it."""
|
||||
"""v1.46.6: age is never a reason to delete a plan file.
|
||||
|
||||
It used to be, for "a file of a space that has a plan and never was one" —
|
||||
an upload whose save had failed. That raced the retry: the sweep removed the
|
||||
file while the next save was committing a reference to it. Keeping it costs
|
||||
a few megabytes nobody can lose.
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from custom_components.houseplan.const import PLANS_DIR, PLAN_ORPHAN_TTL_S
|
||||
from custom_components.houseplan.const import PLANS_DIR, SCHEDULED_GRACE_S
|
||||
from custom_components.houseplan.store import get_data
|
||||
|
||||
await _setup(hass)
|
||||
client = await hass_ws_client(hass)
|
||||
plans = Path(hass.config.path(PLANS_DIR))
|
||||
plans.mkdir(parents=True, exist_ok=True)
|
||||
for stale in plans.glob("r3.*"):
|
||||
stale.unlink()
|
||||
plans = hass.config.path(PLANS_DIR)
|
||||
|
||||
url0, p0 = await _upload(client, "r3", b"zero")
|
||||
rev = (await _save(client, await _cfg([{"id": "r3", "plan_url": url0}]), 0))["result"]["rev"]
|
||||
|
||||
_url, orphan = await _upload(client, "r3", b"abandoned")
|
||||
old = time.time() - PLAN_ORPHAN_TTL_S - 60
|
||||
os.utime(plans / orphan, (old, old))
|
||||
_url, orphan = await _upload(client, "r3", b"never saved")
|
||||
old = time.time() - SCHEDULED_GRACE_S * 12
|
||||
os.utime(os.path.join(plans, orphan), (old, old))
|
||||
|
||||
ok = await _save(client, await _cfg([{"id": "r3", "plan_url": url0}]), rev)
|
||||
assert ok["success"]
|
||||
assert not (plans / orphan).exists(), "an aged, unreferenced upload is collected"
|
||||
assert (plans / p0).is_file(), "the referenced plan is never touched"
|
||||
# a commit, and the scheduled pass, and any amount of age: it stays
|
||||
assert (await _save(client, await _cfg([{"id": "r3", "plan_url": url0}]), rev))["success"]
|
||||
data = get_data(hass)
|
||||
await data.sweep()
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert await hass.async_add_executor_job(os.path.isfile, os.path.join(plans, orphan))
|
||||
assert await hass.async_add_executor_job(os.path.isfile, os.path.join(plans, p0))
|
||||
|
||||
|
||||
async def test_collection_ignores_files_that_are_not_plans(
|
||||
@@ -609,7 +618,8 @@ async def test_layout_keeps_its_revision_and_announces_changes(
|
||||
assert not bad["success"] and bad["error"]["code"] == "conflict"
|
||||
|
||||
await hass.async_block_till_done()
|
||||
assert [e["rev"] for e in events] == [1, 2, 3]
|
||||
# the bus does not promise ordering between separately fired events
|
||||
assert sorted(e["rev"] for e in events) == [1, 2, 3]
|
||||
|
||||
|
||||
async def test_uploaded_svg_is_sandboxed_and_a_pdf_is_not(
|
||||
@@ -774,8 +784,22 @@ async def test_repair_issue_goes_when_its_space_does(
|
||||
client = await hass_ws_client(hass)
|
||||
registry = ir.async_get(hass)
|
||||
|
||||
gone = "/api/houseplan/content/plans/_/nosuchfile.png"
|
||||
rev = (await _save(client, await _cfg([{"id": "r7", "plan_url": gone}]), 0))["result"]["rev"]
|
||||
# A reference can only go bad AFTER it is stored — config/set refuses a new
|
||||
# one that is already broken (HP-1470-02). So: attach a real plan, then let
|
||||
# the file disappear the way it does in life, from outside Home Assistant.
|
||||
import os
|
||||
|
||||
from custom_components.houseplan.const import PLANS_DIR
|
||||
|
||||
url, name = await _upload(client, "r7", b"PLAN")
|
||||
rev = (await _save(client, await _cfg([{"id": "r7", "plan_url": url}]), 0))["result"]["rev"]
|
||||
await hass.async_block_till_done()
|
||||
assert registry.async_get_issue(HP_DOMAIN, "broken_plan_r7") is None, "the file is there"
|
||||
|
||||
await hass.async_add_executor_job(
|
||||
os.remove, os.path.join(hass.config.path(PLANS_DIR), name)
|
||||
)
|
||||
rev = (await _save(client, await _cfg([{"id": "r7", "plan_url": url}]), rev))["result"]["rev"]
|
||||
await hass.async_block_till_done()
|
||||
assert registry.async_get_issue(HP_DOMAIN, "broken_plan_r7") is not None
|
||||
|
||||
@@ -855,61 +879,541 @@ async def test_cancelling_an_upload_takes_its_temporary_with_it(
|
||||
assert entry
|
||||
|
||||
|
||||
async def test_scheduled_sweep_collects_what_no_commit_will(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""HP-1461-01: a commit collects what it superseded — but only when it runs.
|
||||
|
||||
Cancel a dialog after the file uploaded, or lose the connection right after,
|
||||
and nothing references the file and no future write notices it. The daily
|
||||
sweep used to remove only streaming temporaries, so the promise that a
|
||||
cancelled attachment goes after an hour did not hold on an instance nobody
|
||||
edits.
|
||||
"""
|
||||
async def _seed_aged(hass, names) -> None:
|
||||
"""Create the given files and backdate them past the orphan TTL."""
|
||||
import os
|
||||
import time
|
||||
|
||||
from custom_components.houseplan.const import FILES_DIR, PLANS_DIR, PLAN_ORPHAN_TTL_S
|
||||
from custom_components.houseplan.const import SCHEDULED_GRACE_S
|
||||
|
||||
await _setup(hass)
|
||||
client = await hass_ws_client(hass)
|
||||
files = hass.config.path(FILES_DIR)
|
||||
plans = hass.config.path(PLANS_DIR)
|
||||
old = time.time() - PLAN_ORPHAN_TTL_S - 60
|
||||
old = time.time() - SCHEDULED_GRACE_S - 60 # past every grace
|
||||
|
||||
def _seed() -> None:
|
||||
os.makedirs(os.path.join(files, "m5"), exist_ok=True)
|
||||
os.makedirs(os.path.join(files, "up_cancelled"), exist_ok=True)
|
||||
os.makedirs(plans, exist_ok=True)
|
||||
for path in (
|
||||
os.path.join(files, "m5", "kept.pdf"),
|
||||
os.path.join(files, "up_cancelled", "manual.pdf"),
|
||||
os.path.join(plans, "s5.tok.png"),
|
||||
os.path.join(plans, "s5.orphan.png"),
|
||||
):
|
||||
def _do() -> None:
|
||||
for path in names:
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "wb") as fh:
|
||||
fh.write(b"x")
|
||||
os.utime(path, (old, old))
|
||||
|
||||
await hass.async_add_executor_job(_seed)
|
||||
await hass.async_add_executor_job(_do)
|
||||
|
||||
|
||||
def _paths(hass):
|
||||
import os
|
||||
|
||||
from custom_components.houseplan.const import FILES_DIR, PLANS_DIR
|
||||
|
||||
files = hass.config.path(FILES_DIR)
|
||||
plans = hass.config.path(PLANS_DIR)
|
||||
return files, plans, {
|
||||
"kept_file": os.path.join(files, "m5", "kept.pdf"),
|
||||
"kept_plan": os.path.join(plans, "s5.tok.png"),
|
||||
"orphan_file": os.path.join(files, "up_cancelled", "manual.pdf"),
|
||||
# a plan file is never collected by age any more; keep one around and
|
||||
# assert exactly that
|
||||
"kept_reject": os.path.join(plans, "s5.reject.png"),
|
||||
}
|
||||
|
||||
|
||||
async def _referenced_config() -> dict:
|
||||
cfg = await _cfg([{"id": "s5", "plan_url": "/api/houseplan/content/plans/_/s5.tok.png"}])
|
||||
cfg["markers"] = [
|
||||
{"id": "m5", "binding": "virtual",
|
||||
"pdfs": [{"name": "k", "url": "/api/houseplan/content/files/m5/kept.pdf"}]}
|
||||
]
|
||||
assert (await _save(client, cfg, 0))["success"]
|
||||
return cfg
|
||||
|
||||
|
||||
async def _assert_swept(hass, p) -> None:
|
||||
import os
|
||||
|
||||
assert await hass.async_add_executor_job(os.path.isfile, p["kept_file"]), "referenced file kept"
|
||||
assert await hass.async_add_executor_job(os.path.isfile, p["kept_plan"]), "referenced plan kept"
|
||||
assert not await hass.async_add_executor_job(os.path.isfile, p["orphan_file"]), (
|
||||
"a staging folder from a dialog nobody saved is the one thing age collects"
|
||||
)
|
||||
assert await hass.async_add_executor_job(os.path.isfile, p["kept_reject"]), (
|
||||
"a plan file is never removed for being old"
|
||||
)
|
||||
|
||||
|
||||
async def test_startup_sweep_collects_what_no_commit_will(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""HP-1461-01 / HP-1462-01: collection must not depend on a future save.
|
||||
|
||||
The files are seeded AFTER the configuration is stored. The previous version
|
||||
of this test seeded them before, and `config/set` collects too — so it
|
||||
passed without the startup pass doing anything, hiding HP-1462-01: during
|
||||
setup the entry is not "loaded" yet, so looking its runtime data up by
|
||||
domain returned None and the pass degraded to removing streaming
|
||||
temporaries only.
|
||||
"""
|
||||
import os
|
||||
|
||||
await _setup(hass)
|
||||
client = await hass_ws_client(hass)
|
||||
files, _plans, p = _paths(hass)
|
||||
# the plan it names has to exist: config/set refuses a NEW broken
|
||||
# reference (HP-1470-02). The orphans still arrive after the save.
|
||||
await _seed_aged(hass, [p["kept_plan"]])
|
||||
assert (await _save(client, await _referenced_config(), 0))["success"]
|
||||
|
||||
await _seed_aged(hass, list(p.values()))
|
||||
|
||||
# reload the entry: that is what runs the sweep at startup
|
||||
entry = hass.config_entries.async_entries(DOMAIN)[0]
|
||||
assert await hass.config_entries.async_reload(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
exists = lambda p: os.path.isfile(p) # noqa: E731
|
||||
assert await hass.async_add_executor_job(exists, os.path.join(files, "m5", "kept.pdf"))
|
||||
assert await hass.async_add_executor_job(exists, os.path.join(plans, "s5.tok.png"))
|
||||
await _assert_swept(hass, p)
|
||||
assert not await hass.async_add_executor_job(
|
||||
exists, os.path.join(files, "up_cancelled", "manual.pdf")
|
||||
), "an aged cancelled attachment is collected without any further config write"
|
||||
assert not await hass.async_add_executor_job(exists, os.path.join(plans, "s5.orphan.png"))
|
||||
assert not await hass.async_add_executor_job(os.path.isdir, os.path.join(files, "up_cancelled"))
|
||||
os.path.isdir, os.path.join(files, "up_cancelled")
|
||||
), "the emptied staging folder goes with its last file"
|
||||
|
||||
|
||||
async def test_periodic_sweep_collects_too(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""The scheduled pass, invoked directly rather than by faking a 24 h jump.
|
||||
|
||||
Firing a time change proves the timer fires; awaiting the callback proves it
|
||||
does the work. This asserts the second, which is the part that regressed.
|
||||
"""
|
||||
from custom_components.houseplan.store import get_data
|
||||
|
||||
await _setup(hass)
|
||||
client = await hass_ws_client(hass)
|
||||
_files, _plans, p = _paths(hass)
|
||||
# the plan it names has to exist: config/set refuses a NEW broken
|
||||
# reference (HP-1470-02). The orphans still arrive after the save.
|
||||
await _seed_aged(hass, [p["kept_plan"]])
|
||||
assert (await _save(client, await _referenced_config(), 0))["success"]
|
||||
|
||||
await _seed_aged(hass, list(p.values()))
|
||||
|
||||
data = get_data(hass)
|
||||
assert data is not None and data.sweep is not None, "setup must publish the sweep"
|
||||
await data.sweep()
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await _assert_swept(hass, p)
|
||||
|
||||
|
||||
async def test_sweep_and_a_config_write_do_not_race(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""Both take the same write lock, so an accepted config cannot lose a file.
|
||||
|
||||
Without it the sweep could decide a file is unreferenced, a commit could
|
||||
start referencing it, and the file would go — leaving the accepted revision
|
||||
pointing at nothing.
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
await _setup(hass)
|
||||
client = await hass_ws_client(hass)
|
||||
_files, plans, p = _paths(hass)
|
||||
# the plan it names has to exist: config/set refuses a NEW broken
|
||||
# reference (HP-1470-02). The orphans still arrive after the save.
|
||||
await _seed_aged(hass, [p["kept_plan"]])
|
||||
rev = (await _save(client, await _referenced_config(), 0))["result"]["rev"]
|
||||
|
||||
# an aged, currently unreferenced plan that the commit below adopts
|
||||
newcomer = os.path.join(plans, "s5.newcomer.png")
|
||||
await _seed_aged(hass, [p["kept_file"], p["kept_plan"], newcomer])
|
||||
|
||||
cfg2 = await _referenced_config()
|
||||
cfg2["spaces"][0]["plan_url"] = "/api/houseplan/content/plans/_/s5.newcomer.png"
|
||||
|
||||
# Drive the sweep directly rather than through a reload: an entry reload has
|
||||
# an unload window in which any WS call legitimately answers `not_ready`, so
|
||||
# a save racing THAT proves nothing about the lock and fails at random.
|
||||
from custom_components.houseplan.store import get_data
|
||||
|
||||
data = get_data(hass)
|
||||
assert data is not None and data.sweep is not None
|
||||
_swept, saved = await asyncio.gather(data.sweep(), _save(client, cfg2, rev))
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# assert the CONCRETE outcome: a save that came back `not_ready` would leave
|
||||
# the old config pointing at the old file and satisfy a vaguer check
|
||||
assert saved["success"], saved.get("error")
|
||||
await client.send_json_auto_id({"type": "houseplan/config/get"})
|
||||
stored = (await client.receive_json())["result"]["config"]
|
||||
assert stored["spaces"][0]["plan_url"].endswith("s5.newcomer.png")
|
||||
assert await hass.async_add_executor_job(
|
||||
os.path.isfile, os.path.join(plans, "s5.newcomer.png")
|
||||
), "the file the accepted config points at must exist"
|
||||
|
||||
|
||||
async def test_files_cleanup_keeps_referenced_files(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""v1.46.5: the client may say what it no longer needs, never what may go.
|
||||
|
||||
`files/cleanup` used to rmtree the folder it was handed. A partial migration
|
||||
leaves some urls pointing into that folder — the copy deliberately does not
|
||||
rewrite the ones it could not confirm — so those were live links to files
|
||||
being deleted. A wrong id from any client had the same effect on a device's
|
||||
manuals.
|
||||
"""
|
||||
import os
|
||||
|
||||
from custom_components.houseplan.const import FILES_DIR
|
||||
|
||||
await _setup(hass)
|
||||
client = await hass_ws_client(hass)
|
||||
folder = os.path.join(hass.config.path(FILES_DIR), "m7")
|
||||
|
||||
def _seed() -> None:
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
for n in ("kept.pdf", "orphan.pdf"):
|
||||
with open(os.path.join(folder, n), "wb") as fh:
|
||||
fh.write(b"x")
|
||||
|
||||
await hass.async_add_executor_job(_seed)
|
||||
|
||||
cfg = await _cfg([{"id": "s7", "plan_url": None}])
|
||||
cfg["markers"] = [
|
||||
{"id": "other", "binding": "virtual",
|
||||
"pdfs": [{"name": "k", "url": "/api/houseplan/content/files/m7/kept.pdf"}]}
|
||||
]
|
||||
assert (await _save(client, cfg, 0))["success"]
|
||||
|
||||
await client.send_json_auto_id({"type": "houseplan/files/cleanup", "marker_id": "m7"})
|
||||
resp = await client.receive_json()
|
||||
assert resp["success"] and resp["result"] == {"ok": True, "removed": 1, "kept": 1}
|
||||
|
||||
assert await hass.async_add_executor_job(os.path.isfile, os.path.join(folder, "kept.pdf")), (
|
||||
"a file the configuration still references survives a cleanup of its folder"
|
||||
)
|
||||
assert not await hass.async_add_executor_job(os.path.isfile, os.path.join(folder, "orphan.pdf"))
|
||||
|
||||
|
||||
async def test_detaching_a_plan_keeps_the_file(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""HP-1465-01, through the real save — where the earlier tests never looked.
|
||||
|
||||
Every check for this lived in the pure collector with old and new config
|
||||
equal, i.e. the scheduled pass. The transition that matters is a save, and
|
||||
there the file was deleted the moment the reference was cleared.
|
||||
"""
|
||||
import os
|
||||
|
||||
from custom_components.houseplan.const import PLANS_DIR
|
||||
|
||||
await _setup(hass)
|
||||
client = await hass_ws_client(hass)
|
||||
plans = hass.config.path(PLANS_DIR)
|
||||
|
||||
url, name = await _upload(client, "d1", b"PLAN", ext="png")
|
||||
rev = (await _save(client, await _cfg([{"id": "d1", "plan_url": url}]), 0))["result"]["rev"]
|
||||
assert await hass.async_add_executor_job(os.path.isfile, os.path.join(plans, name))
|
||||
|
||||
# detach: the space stays, its plan does not
|
||||
rev = (await _save(client, await _cfg([{"id": "d1", "plan_url": None}]), rev))["result"]["rev"]
|
||||
assert await hass.async_add_executor_job(os.path.isfile, os.path.join(plans, name)), (
|
||||
"the editor says the image stays on disk — it has to actually stay"
|
||||
)
|
||||
|
||||
# a restart does not change its mind either
|
||||
entry = hass.config_entries.async_entries(DOMAIN)[0]
|
||||
assert await hass.config_entries.async_reload(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert await hass.async_add_executor_job(os.path.isfile, os.path.join(plans, name))
|
||||
|
||||
# re-attach, then replace: THAT removes the one it replaced
|
||||
rev = (await _save(client, await _cfg([{"id": "d1", "plan_url": url}]), rev))["result"]["rev"]
|
||||
url2, name2 = await _upload(client, "d1", b"NEWPLAN", ext="png")
|
||||
rev = (await _save(client, await _cfg([{"id": "d1", "plan_url": url2}]), rev))["result"]["rev"]
|
||||
assert await hass.async_add_executor_job(os.path.isfile, os.path.join(plans, name2))
|
||||
assert not await hass.async_add_executor_job(os.path.isfile, os.path.join(plans, name))
|
||||
|
||||
# and deleting the space keeps its plan
|
||||
await _save(client, await _cfg([]), rev)
|
||||
await hass.async_block_till_done()
|
||||
assert await hass.async_add_executor_job(os.path.isfile, os.path.join(plans, name2))
|
||||
|
||||
|
||||
async def test_stored_plans_can_be_listed_and_deleted_on_request(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""HP-1466-01/-02: "we never delete" needs the files to be findable.
|
||||
|
||||
A detached plan stays on disk. Without a way to see it, that is neither a
|
||||
recovery path nor a disk policy — it is just accumulation. Listing gives
|
||||
both: the user attaches it again, or deletes it on purpose, which is the
|
||||
only way a plan file is ever removed.
|
||||
"""
|
||||
await _setup(hass)
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
used_url, used = await _upload(client, "p1", b"USED", ext="png")
|
||||
_free_url, free = await _upload(client, "p2", b"FREE", ext="png")
|
||||
rev = (await _save(client, await _cfg([{"id": "p1", "plan_url": used_url}]), 0))["result"]["rev"]
|
||||
|
||||
await client.send_json_auto_id({"type": "houseplan/plans/list"})
|
||||
# the HA test config dir is shared across the module: look at ours, not all
|
||||
plans = {p["name"]: p for p in (await client.receive_json())["result"]["plans"]}
|
||||
assert {used, free} <= set(plans)
|
||||
assert plans[used]["used_by"] and not plans[free]["used_by"]
|
||||
assert plans[used]["size"] == 4 and plans[free]["url"].endswith(free)
|
||||
|
||||
# a plan a space still uses is refused — the config decides, not the client
|
||||
await client.send_json_auto_id({"type": "houseplan/plans/delete", "name": used})
|
||||
resp = await client.receive_json()
|
||||
assert not resp["success"] and resp["error"]["code"] == "in_use"
|
||||
|
||||
# an unused one goes on request
|
||||
await client.send_json_auto_id({"type": "houseplan/plans/delete", "name": free})
|
||||
assert (await client.receive_json())["result"]["removed"] is True
|
||||
|
||||
# detach the other, and now it is deletable — and listed as free until then
|
||||
await _save(client, await _cfg([{"id": "p1", "plan_url": None}]), rev)
|
||||
await client.send_json_auto_id({"type": "houseplan/plans/list"})
|
||||
plans = {p["name"]: p for p in (await client.receive_json())["result"]["plans"]}
|
||||
assert used in plans, "the detached plan is still there, ready to re-attach"
|
||||
assert not plans[used]["used_by"]
|
||||
assert free not in plans, "and the one we deleted is gone"
|
||||
|
||||
# nothing outside the plans folder can be reached through the name
|
||||
await client.send_json_auto_id(
|
||||
{"type": "houseplan/plans/delete", "name": "../../configuration.yaml"}
|
||||
)
|
||||
bad = await client.receive_json()
|
||||
assert not bad["success"] and bad["error"]["code"] == "invalid_name"
|
||||
|
||||
|
||||
async def test_config_set_refuses_a_plan_that_no_longer_exists(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""HP-1470-02: a stored internal plan url must name a file that exists.
|
||||
|
||||
The card can pick a plan and delete it from the same dialog, and two clients
|
||||
can do the same in either order. The lock serialises them; it says nothing
|
||||
about whether the file survived, so the check has to be here.
|
||||
"""
|
||||
await _setup(hass)
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
url, name = await _upload(client, "x1", b"PLAN", ext="png")
|
||||
await client.send_json_auto_id({"type": "houseplan/plans/delete", "name": name})
|
||||
assert (await client.receive_json())["result"]["removed"] is True
|
||||
|
||||
resp = await _save(client, await _cfg([{"id": "x1", "plan_url": url}]), 0)
|
||||
assert not resp["success"] and resp["error"]["code"] == "missing_plan"
|
||||
|
||||
# an external or legacy url is the user's business, not ours to verify
|
||||
assert (await _save(client, await _cfg([{"id": "x1", "plan_url": "/local/mine.png"}]), 0))["success"]
|
||||
|
||||
|
||||
async def test_uploads_are_bounded_by_a_store_quota(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, monkeypatch
|
||||
) -> None:
|
||||
"""HP-1470-01: nothing is deleted for being old, so growth stops at the door."""
|
||||
from custom_components.houseplan import websocket_api as wsapi
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from custom_components.houseplan.const import PLANS_DIR
|
||||
from custom_components.houseplan.plans import dir_usage
|
||||
|
||||
await _setup(hass)
|
||||
client = await hass_ws_client(hass)
|
||||
# every test in this module shares one config directory, so the plans folder
|
||||
# is not empty here — budget two more from whatever is already stored
|
||||
stored, _b = await hass.async_add_executor_job(
|
||||
lambda: dir_usage(Path(hass.config.path(PLANS_DIR)))[::-1]
|
||||
)
|
||||
monkeypatch.setattr(wsapi, "MAX_PLANS_FILES", stored + 2)
|
||||
|
||||
await _upload(client, "q1", b"one")
|
||||
await _upload(client, "q1", b"two")
|
||||
|
||||
import base64
|
||||
|
||||
await client.send_json_auto_id({
|
||||
"type": "houseplan/plan/set", "space_id": "q1", "ext": "png",
|
||||
"data": base64.b64encode(b"three").decode(),
|
||||
})
|
||||
resp = await client.receive_json()
|
||||
assert not resp["success"] and resp["error"]["code"] == "too_many_files"
|
||||
|
||||
|
||||
async def test_parallel_uploads_cannot_slip_past_the_quota_together(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, monkeypatch
|
||||
) -> None:
|
||||
"""HP-1490-02: N uploads used to measure the store before any of them
|
||||
wrote, so all N passed a quota only one of them fits under. The
|
||||
check→write pair is one job under upload_lock now, so whatever the
|
||||
interleaving, at most ONE of two competing uploads can take the last slot.
|
||||
"""
|
||||
import asyncio
|
||||
import base64
|
||||
|
||||
from custom_components.houseplan import websocket_api as wsapi
|
||||
from custom_components.houseplan.const import PLANS_DIR
|
||||
from custom_components.houseplan.plans import dir_usage
|
||||
from pathlib import Path
|
||||
|
||||
await _setup(hass)
|
||||
c1 = await hass_ws_client(hass)
|
||||
c2 = await hass_ws_client(hass)
|
||||
_bytes, stored = await hass.async_add_executor_job(
|
||||
dir_usage, Path(hass.config.path(PLANS_DIR))
|
||||
)
|
||||
monkeypatch.setattr(wsapi, "MAX_PLANS_FILES", stored + 1) # room for ONE
|
||||
|
||||
payload = base64.b64encode(b"PLAN").decode()
|
||||
|
||||
async def upload(client, sid):
|
||||
await client.send_json_auto_id({
|
||||
"type": "houseplan/plan/set", "space_id": sid, "ext": "png", "data": payload,
|
||||
})
|
||||
return await client.receive_json()
|
||||
|
||||
r1, r2 = await asyncio.gather(upload(c1, "pa"), upload(c2, "pb"))
|
||||
oks = [r for r in (r1, r2) if r["success"]]
|
||||
errs = [r for r in (r1, r2) if not r["success"]]
|
||||
assert len(oks) == 1, "exactly one takes the last slot"
|
||||
assert errs and errs[0]["error"]["code"] == "too_many_files"
|
||||
_bytes2, after = await hass.async_add_executor_job(
|
||||
dir_usage, Path(hass.config.path(PLANS_DIR))
|
||||
)
|
||||
assert after == stored + 1, "the store holds what the quota promised, not more"
|
||||
|
||||
|
||||
async def test_layout_coordinates_are_bounded(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""HP-1500-03: any finite float used to pass, and one stored 1e100
|
||||
stretched every client's frame until the plan was invisible. Positions are
|
||||
normalised to the canvas; +-4 is generous slack for an icon dragged past an
|
||||
edge, not an envelope for absurdity."""
|
||||
await _setup(hass)
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
await client.send_json_auto_id({
|
||||
"type": "houseplan/layout/update", "device_id": "d1",
|
||||
"pos": {"s": "f1", "x": 1e100, "y": 0.5},
|
||||
})
|
||||
resp = await client.receive_json()
|
||||
assert not resp["success"], "an absurd coordinate is refused at the door"
|
||||
|
||||
await client.send_json_auto_id({
|
||||
"type": "houseplan/layout/update", "device_id": "d1",
|
||||
"pos": {"s": "f1", "x": -1.2, "y": 0.5},
|
||||
})
|
||||
resp = await client.receive_json()
|
||||
assert resp["success"], "a bit past the edge is a dragged icon, not an attack"
|
||||
|
||||
|
||||
async def test_geometry_repair_is_explicit_previewable_and_undoable(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""HP-1500-01: an install that crashed in the v1.48 migration window has a
|
||||
square config and an old-coordinates layout, and NOTHING left to tell —
|
||||
both triggers went with the write that succeeded. No automation can fix
|
||||
that safely (a correct layout looks the same), so the repair is a person
|
||||
saying "this space, this old aspect": preview first, one-deep backup with
|
||||
the write, undo restores it."""
|
||||
await _setup(hass)
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
# the stranded state: nothing in the layout says it is old
|
||||
await client.send_json_auto_id({
|
||||
"type": "houseplan/layout/set",
|
||||
"layout": {"lamp": {"s": "wide", "x": 0.2, "y": 0.1},
|
||||
"other": {"s": "elsewhere", "x": 0.9, "y": 0.9}},
|
||||
})
|
||||
assert (await client.receive_json())["success"]
|
||||
|
||||
# preview does not touch the store
|
||||
await client.send_json_auto_id({
|
||||
"type": "houseplan/geometry/repair", "space_id": "wide", "aspect": 2.0, "dry_run": True,
|
||||
})
|
||||
dry = (await client.receive_json())["result"]
|
||||
assert dry["moved"] == 1 and dry["after"]["lamp"]["y"] == 0.3
|
||||
await client.send_json_auto_id({"type": "houseplan/layout/get"})
|
||||
assert (await client.receive_json())["result"]["layout"]["lamp"]["y"] == 0.1
|
||||
|
||||
# the repair itself
|
||||
await client.send_json_auto_id({
|
||||
"type": "houseplan/geometry/repair", "space_id": "wide", "aspect": 2.0,
|
||||
})
|
||||
res = (await client.receive_json())["result"]
|
||||
assert res["moved"] == 1
|
||||
await client.send_json_auto_id({"type": "houseplan/layout/get"})
|
||||
lay = (await client.receive_json())["result"]["layout"]
|
||||
assert lay["lamp"] == {"s": "wide", "x": 0.2, "y": 0.3}
|
||||
assert lay["other"] == {"s": "elsewhere", "x": 0.9, "y": 0.9}, "another space untouched"
|
||||
|
||||
# a routine drag must not eat the backup...
|
||||
await client.send_json_auto_id({
|
||||
"type": "houseplan/layout/update", "device_id": "other",
|
||||
"pos": {"s": "elsewhere", "x": 0.5, "y": 0.5},
|
||||
})
|
||||
assert (await client.receive_json())["success"]
|
||||
|
||||
# ...because undo is the safety net for repairing the WRONG space
|
||||
await client.send_json_auto_id({
|
||||
"type": "houseplan/geometry/repair", "space_id": "wide", "aspect": 2.0, "undo": True,
|
||||
})
|
||||
res = (await client.receive_json())["result"]
|
||||
assert res["restored"] == 1
|
||||
await client.send_json_auto_id({"type": "houseplan/layout/get"})
|
||||
lay = (await client.receive_json())["result"]["layout"]
|
||||
assert lay["lamp"] == {"s": "wide", "x": 0.2, "y": 0.1}, "back to before the repair"
|
||||
assert lay["other"]["x"] == 0.5, "the drag after the repair survives the undo"
|
||||
|
||||
# undo without a backup for that space is a clean error
|
||||
await client.send_json_auto_id({
|
||||
"type": "houseplan/geometry/repair", "space_id": "nosuch", "aspect": 2.0, "undo": True,
|
||||
})
|
||||
bad = await client.receive_json()
|
||||
assert not bad["success"] and bad["error"]["code"] == "no_backup"
|
||||
|
||||
|
||||
async def test_a_noop_repair_does_not_eat_the_backup(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""HP-1501-02: a repair that matches nothing used to answer moved: 0 and
|
||||
replace the one-deep backup with an empty one — a typo after a wrong
|
||||
repair destroyed the only way back. Now it is an error, the revision does
|
||||
not move, and the previous repair is still undoable."""
|
||||
await _setup(hass)
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
await client.send_json_auto_id({
|
||||
"type": "houseplan/layout/set",
|
||||
"layout": {"lamp": {"s": "wide", "x": 0.2, "y": 0.1}},
|
||||
})
|
||||
assert (await client.receive_json())["success"]
|
||||
|
||||
await client.send_json_auto_id({
|
||||
"type": "houseplan/geometry/repair", "space_id": "wide", "aspect": 2.0,
|
||||
})
|
||||
rev = (await client.receive_json())["result"]["rev"]
|
||||
|
||||
# the typo: syntactically valid, matches nothing
|
||||
await client.send_json_auto_id({
|
||||
"type": "houseplan/geometry/repair", "space_id": "wid", "aspect": 2.0,
|
||||
})
|
||||
bad = await client.receive_json()
|
||||
assert not bad["success"] and bad["error"]["code"] == "nothing_to_repair"
|
||||
|
||||
await client.send_json_auto_id({"type": "houseplan/layout/get"})
|
||||
got = (await client.receive_json())["result"]
|
||||
assert got["rev"] == rev, "a refused repair moves nothing, including the revision"
|
||||
|
||||
# the wrong-space repair from before the typo is STILL undoable
|
||||
await client.send_json_auto_id({
|
||||
"type": "houseplan/geometry/repair", "space_id": "wide", "aspect": 2.0, "undo": True,
|
||||
})
|
||||
res = (await client.receive_json())["result"]
|
||||
assert res["restored"] == 1
|
||||
await client.send_json_auto_id({"type": "houseplan/layout/get"})
|
||||
lay = (await client.receive_json())["result"]["layout"]
|
||||
assert lay["lamp"] == {"s": "wide", "x": 0.2, "y": 0.1}
|
||||
|
||||
@@ -90,11 +90,19 @@ def test_room_schema_poly_or_rect():
|
||||
v.ROOM_SCHEMA({"id": "r4", "name": "D", "poly": [[0, 0], [1, 1]]})
|
||||
|
||||
|
||||
def test_space_schema_aspect_range():
|
||||
ok = {"id": "f1", "title": "1", "aspect": 1.4, "view_box": [0, 0, 1, 1], "rooms": []}
|
||||
v.SPACE_SCHEMA(ok)
|
||||
def test_space_schema_drops_the_old_aspect_and_bounds_the_image_ratio():
|
||||
"""v1.48.0: the canvas is square; only the IMAGE keeps proportions.
|
||||
|
||||
A stale tab may still send `aspect`. It is dropped rather than trusted —
|
||||
the coordinates it arrives with were normalised against a different box, so
|
||||
honouring the field would not make them right anyway.
|
||||
"""
|
||||
ok = {"id": "f1", "title": "1", "view_box": [0, 0, 1, 1], "rooms": []}
|
||||
assert "aspect" not in v.SPACE_SCHEMA({**ok, "aspect": 1.4})
|
||||
v.SPACE_SCHEMA({**ok, "plan_aspect": 1.4})
|
||||
v.SPACE_SCHEMA({**ok, "plan_aspect": None})
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.SPACE_SCHEMA({**ok, "aspect": 0})
|
||||
v.SPACE_SCHEMA({**ok, "plan_aspect": 0})
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.SPACE_SCHEMA({**ok, "view_box": [0, 0, 1]})
|
||||
|
||||
@@ -182,7 +190,7 @@ def test_collection_caps():
|
||||
|
||||
def test_finite_on_every_coordinate():
|
||||
"""audit follow-up B5: NaN/Infinity must be refused everywhere, not only in layout."""
|
||||
base = {"id": "s1", "title": "S", "aspect": 1.0, "view_box": [0, 0, 100, 100], "rooms": []}
|
||||
base = {"id": "s1", "title": "S", "aspect": 1.0, "view_box": [0, 0, 1, 1], "rooms": []}
|
||||
# view_box
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.CONFIG_SCHEMA({"spaces": [{**base, "view_box": [0, 0, "NaN", 100]}]})
|
||||
@@ -203,13 +211,59 @@ def test_finite_on_every_coordinate():
|
||||
{"id": "r", "name": "R", "poly": [[0, 0], [1, 0], [1, 1]]}]}]})
|
||||
|
||||
|
||||
def test_geometry_magnitudes_are_bounded():
|
||||
"""HP-1501-01: any finite float used to pass, and one schema-valid 1e100
|
||||
room vertex made the space unviewable for every client — the exact failure
|
||||
HP-1500-03 closed for layout positions, one schema over. ±4 is slack for a
|
||||
vertex nudged past an edge, not an envelope for absurdity."""
|
||||
base = {"id": "s1", "title": "S", "view_box": [0, 0, 1, 1], "rooms": []}
|
||||
huge = 1e100
|
||||
for cfg in (
|
||||
{**base, "rooms": [{"id": "r", "name": "R", "poly": [[0, 0], [huge, 0], [1, 1]]}]},
|
||||
{**base, "rooms": [{"id": "r", "name": "R", "poly": [[0, 0], [-huge, 0], [1, 1]]}]},
|
||||
{**base, "rooms": [{"id": "r", "name": "R", "x": huge, "y": 0, "w": 1, "h": 1}]},
|
||||
{**base, "rooms": [{"id": "r", "name": "R", "x": 0, "y": 0, "w": huge, "h": 1}]},
|
||||
{**base, "view_box": [0, 0, huge, 1]},
|
||||
{**base, "openings": [{"id": "o", "type": "door", "x": huge, "y": 0.5,
|
||||
"angle": 0, "length": 0.1}]},
|
||||
{**base, "openings": [{"id": "o", "type": "door", "x": 0.5, "y": 0.5,
|
||||
"angle": 1e6, "length": 0.1}]},
|
||||
):
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.CONFIG_SCHEMA({"spaces": [cfg]})
|
||||
# a vertex a bit past the canvas edge is a drawing, not an attack
|
||||
assert v.CONFIG_SCHEMA({"spaces": [{**base, "rooms": [
|
||||
{"id": "r", "name": "R", "poly": [[-0.2, 0], [1.3, 0], [1, 1]]}]}]})
|
||||
|
||||
|
||||
def test_sizes_are_not_coordinates():
|
||||
"""HP-1502-01: a size must be strictly positive — SVG refuses zero and
|
||||
negative width/height, and the clients divide by these. view_box [0,0,0,0]
|
||||
used to pass the shared validator and blank the plan on every client.
|
||||
Coordinates may still be negative: a crop origin can sit past the edge."""
|
||||
base = {"id": "s1", "title": "S", "view_box": [0, 0, 1, 1], "rooms": []}
|
||||
for cfg in (
|
||||
{**base, "view_box": [0, 0, 0, 0]},
|
||||
{**base, "view_box": [0, 0, -1, -2]},
|
||||
{**base, "view_box": [0, 0, 1, 0.0001]}, # below the 0.001 floor
|
||||
{**base, "rooms": [{"id": "r", "name": "R", "x": 0.1, "y": 0.1, "w": 0, "h": 0.5}]},
|
||||
{**base, "rooms": [{"id": "r", "name": "R", "x": 0.1, "y": 0.1, "w": 0.5, "h": -1}]},
|
||||
):
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.CONFIG_SCHEMA({"spaces": [cfg]})
|
||||
# negative COORDINATES stay legal, and a normal crop viewport passes
|
||||
assert v.CONFIG_SCHEMA({"spaces": [{**base, "view_box": [-0.2, -0.1, 1.4, 1.2]}]})
|
||||
assert v.CONFIG_SCHEMA({"spaces": [{**base, "rooms": [
|
||||
{"id": "r", "name": "R", "x": -0.1, "y": -0.1, "w": 0.4, "h": 0.3}]}]})
|
||||
|
||||
|
||||
def test_openings_cap_enforced():
|
||||
"""audit follow-up B5: MAX_OPENINGS was defined but never wired in."""
|
||||
many = [{"id": f"o{i}", "type": "door", "x": 0.1, "y": 0.1, "angle": 0, "length": 0.1}
|
||||
for i in range(v.MAX_OPENINGS + 1)]
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.CONFIG_SCHEMA({"spaces": [{"id": "s1", "title": "S", "aspect": 1.0,
|
||||
"view_box": [0, 0, 100, 100], "rooms": [],
|
||||
"view_box": [0, 0, 1, 1], "rooms": [],
|
||||
"openings": many}]})
|
||||
|
||||
|
||||
@@ -265,17 +319,6 @@ def test_collect_plans_keeps_a_fresh_unreferenced_upload(tmp_path):
|
||||
assert (d / "f1.inflight.png").is_file()
|
||||
|
||||
|
||||
def test_collect_plans_takes_an_aged_orphan(tmp_path):
|
||||
PLAN_ORPHAN_TTL_S = const.PLAN_ORPHAN_TTL_S
|
||||
collect_plans = plans.collect_plans
|
||||
|
||||
d = _plans(tmp_path, ["f1.keep.png"])
|
||||
_plans(tmp_path, ["f1.abandoned.png"], age=PLAN_ORPHAN_TTL_S + 60)
|
||||
removed = collect_plans(d, _cfg("/p/f1.keep.png"), _cfg("/p/f1.keep.png"))
|
||||
assert removed == 1
|
||||
assert (d / "f1.keep.png").is_file() and not (d / "f1.abandoned.png").exists()
|
||||
|
||||
|
||||
def test_collect_plans_never_touches_a_referenced_or_foreign_file(tmp_path):
|
||||
PLAN_ORPHAN_TTL_S = const.PLAN_ORPHAN_TTL_S
|
||||
collect_plans = plans.collect_plans
|
||||
@@ -347,6 +390,16 @@ def test_every_display_mode_the_editor_offers_is_accepted():
|
||||
v.MARKER_SCHEMA(_marker(display="wat"))
|
||||
|
||||
|
||||
def test_run_target_is_bounded_to_runnable_domains():
|
||||
"""Owner's spec 2026-07-29: a tap may RUN automations, scripts and scenes —
|
||||
and nothing else. The target rides the marker, so the schema is the door."""
|
||||
for ok in ("automation.morning", "script.curtains", "scene.movie"):
|
||||
v.MARKER_SCHEMA(_marker(tap_action="run", tap_target=ok, tap_confirm=True))
|
||||
for bad in ("light.lamp", "lock.front_door", "shell_command.rm", "automation.", "x"):
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.MARKER_SCHEMA(_marker(tap_action="run", tap_target=bad))
|
||||
|
||||
|
||||
def test_every_tap_action_the_editor_offers_is_accepted():
|
||||
for action in _ts_list("TAP_ACTIONS"):
|
||||
v.MARKER_SCHEMA(_marker(tap_action=action))
|
||||
@@ -477,31 +530,6 @@ def test_attachment_refs_reads_marker_urls():
|
||||
assert plans.attachment_refs(cfg) == set()
|
||||
|
||||
|
||||
def test_collect_attachments_supersedes_and_ages(tmp_path):
|
||||
import os
|
||||
import time
|
||||
|
||||
collect_attachments = plans.collect_attachments
|
||||
files = tmp_path / "files"
|
||||
(files / "m1").mkdir(parents=True)
|
||||
for n in ("old.pdf", "new.pdf", "cancelled.pdf"):
|
||||
(files / "m1" / n).write_bytes(b"x")
|
||||
|
||||
# the commit swapped old.pdf for new.pdf; cancelled.pdf is a fresh upload
|
||||
# nobody saved — it may belong to a dialog that is still open
|
||||
removed = collect_attachments(files, _acfg("m1/old.pdf"), _acfg("m1/new.pdf"))
|
||||
assert removed == 1
|
||||
assert not (files / "m1" / "old.pdf").exists()
|
||||
assert (files / "m1" / "new.pdf").is_file()
|
||||
assert (files / "m1" / "cancelled.pdf").is_file()
|
||||
|
||||
old = time.time() - const.PLAN_ORPHAN_TTL_S - 60
|
||||
os.utime(files / "m1" / "cancelled.pdf", (old, old))
|
||||
assert collect_attachments(files, _acfg("m1/new.pdf"), _acfg("m1/new.pdf")) == 1
|
||||
assert not (files / "m1" / "cancelled.pdf").exists()
|
||||
assert (files / "m1" / "new.pdf").is_file()
|
||||
|
||||
|
||||
def test_collect_attachments_removes_the_empty_folder_and_never_raises(tmp_path):
|
||||
import os
|
||||
import time
|
||||
@@ -552,3 +580,346 @@ def test_legacy_segments_are_dropped_by_the_server():
|
||||
"segments": [[1, 2, 3, 4]] * 100000,
|
||||
})
|
||||
assert "segments" not in out
|
||||
|
||||
|
||||
def _aged(path, seconds):
|
||||
import os
|
||||
import time
|
||||
|
||||
t = time.time() - seconds
|
||||
os.utime(path, (t, t))
|
||||
|
||||
|
||||
def _sp(sid, url):
|
||||
return {"id": sid, "plan_url": f"/api/houseplan/content/plans/_/{url}" if url else None}
|
||||
|
||||
|
||||
def test_plan_collection_matrix(tmp_path):
|
||||
"""Which config transition means "the user asked for this file to go"?
|
||||
|
||||
`old_refs - new_refs` cannot tell replace, detach and delete-space apart —
|
||||
they look identical. v1.46.4/v1.46.5 added guards for detach and only ever
|
||||
reached them on the scheduled pass, so the commit itself still deleted a
|
||||
detached plan the moment it was detached (HP-1465-01). One case is a
|
||||
deletion the user asked for; the rest are kept.
|
||||
"""
|
||||
collect = plans.collect_plans
|
||||
d = tmp_path / "plans"
|
||||
d.mkdir()
|
||||
|
||||
def seed(*names):
|
||||
for n in names:
|
||||
(d / n).write_bytes(b"x")
|
||||
_aged(d / n, const.SCHEDULED_GRACE_S * 2) # old enough for any rule
|
||||
|
||||
# 1. replace: the user picked a different image for the same space
|
||||
seed("f1.old.png", "f1.new.png")
|
||||
assert collect(d, {"spaces": [_sp("f1", "f1.old.png")]},
|
||||
{"spaces": [_sp("f1", "f1.new.png")]}) == 1
|
||||
assert not (d / "f1.old.png").exists() and (d / "f1.new.png").is_file()
|
||||
|
||||
# 2. detach: same space, switched to "draw"
|
||||
seed("f2.png")
|
||||
assert collect(d, {"spaces": [_sp("f2", "f2.png")]},
|
||||
{"spaces": [_sp("f2", None)]}) == 0
|
||||
assert (d / "f2.png").is_file(), "the editor says the file stays — it stays"
|
||||
|
||||
# 3. the space is deleted outright
|
||||
seed("f3.png")
|
||||
assert collect(d, {"spaces": [_sp("f3", "f3.png")]}, {"spaces": []}) == 0
|
||||
assert (d / "f3.png").is_file()
|
||||
|
||||
# 4. the scheduled pass, later, still keeps both
|
||||
cfg = {"spaces": [_sp("f2", None)]}
|
||||
assert collect(d, cfg, cfg) == 0
|
||||
assert (d / "f2.png").is_file() and (d / "f3.png").is_file()
|
||||
|
||||
# 5. an upload whose save was rejected is kept too, at any age. Ageing those
|
||||
# out raced the retry: the sweep deleted a file the save was committing a
|
||||
# reference to (caught by test_sweep_and_a_config_write_do_not_race).
|
||||
seed("f4.current.png", "f4.reject.png")
|
||||
live = {"spaces": [_sp("f4", "f4.current.png")]}
|
||||
assert collect(d, live, live) == 0
|
||||
assert (d / "f4.current.png").is_file() and (d / "f4.reject.png").is_file()
|
||||
|
||||
# 6. the same file still referenced by another space is never touched
|
||||
seed("shared.png")
|
||||
assert collect(d, {"spaces": [_sp("a", "shared.png"), _sp("b", "shared.png")]},
|
||||
{"spaces": [_sp("a", None), _sp("b", "shared.png")]}) == 0
|
||||
assert (d / "shared.png").is_file()
|
||||
|
||||
|
||||
def test_attachment_collection_matrix(tmp_path):
|
||||
"""Removing an attachment is a trash button; deleting the device is not."""
|
||||
collect = plans.collect_attachments
|
||||
|
||||
def case(name):
|
||||
d = tmp_path / name
|
||||
d.mkdir()
|
||||
return d
|
||||
|
||||
def seed(root, folder, fname, age=None):
|
||||
(root / folder).mkdir(parents=True, exist_ok=True)
|
||||
p = root / folder / fname
|
||||
p.write_bytes(b"x")
|
||||
if age:
|
||||
_aged(p, age)
|
||||
return p
|
||||
|
||||
def cfg(*markers):
|
||||
return {"markers": [
|
||||
{"id": mid, "pdfs": [{"url": f"/api/houseplan/content/files/{mid}/{n}"} for n in names]}
|
||||
for mid, names in markers
|
||||
]}
|
||||
|
||||
# 1. the user removed one attachment from a device that still exists
|
||||
d = case("dropped")
|
||||
seed(d, "m1", "dropped.pdf")
|
||||
seed(d, "m1", "kept.pdf")
|
||||
assert collect(d, cfg(("m1", ["dropped.pdf", "kept.pdf"])), cfg(("m1", ["kept.pdf"]))) == 1
|
||||
assert not (d / "m1" / "dropped.pdf").exists()
|
||||
assert (d / "m1" / "kept.pdf").is_file()
|
||||
|
||||
# 2. the device itself is gone: its manuals are not ours to throw away
|
||||
d = case("device_gone")
|
||||
seed(d, "m2", "manual.pdf", age=const.SCHEDULED_GRACE_S * 2)
|
||||
assert collect(d, cfg(("m2", ["manual.pdf"])), cfg()) == 0
|
||||
assert (d / "m2" / "manual.pdf").is_file()
|
||||
# and the scheduled pass, later, agrees
|
||||
assert collect(d, cfg(), cfg()) == 0
|
||||
assert (d / "m2" / "manual.pdf").is_file()
|
||||
|
||||
# 3. a dialog that was never saved, in its own staging folder
|
||||
d = case("staging")
|
||||
seed(d, "up_x", "manual.pdf", age=const.PLAN_ORPHAN_TTL_S + 60)
|
||||
assert collect(d, cfg(), cfg()) == 1
|
||||
assert not (d / "up_x").exists()
|
||||
|
||||
# 4. an upload into a live device's folder whose save was rejected: kept,
|
||||
# for the same reason as a plan's — a retry may be about to reference it
|
||||
d = case("reject")
|
||||
seed(d, "m3", "current.pdf")
|
||||
seed(d, "m3", "rejected.pdf", age=const.SCHEDULED_GRACE_S + 60)
|
||||
live = cfg(("m3", ["current.pdf"]))
|
||||
assert collect(d, live, live) == 0
|
||||
assert (d / "m3" / "current.pdf").is_file()
|
||||
assert (d / "m3" / "rejected.pdf").is_file()
|
||||
|
||||
|
||||
def test_only_a_staging_folder_ages_out(tmp_path):
|
||||
"""The one age rule left. Everything else waits for the user to say so."""
|
||||
import os
|
||||
import time
|
||||
|
||||
files = tmp_path / "files"
|
||||
(files / "m1").mkdir(parents=True)
|
||||
(files / "up_abandoned").mkdir(parents=True)
|
||||
ancient = time.time() - const.SCHEDULED_GRACE_S * 12
|
||||
hour_ago = time.time() - const.PLAN_ORPHAN_TTL_S - 60
|
||||
|
||||
for path, when in (
|
||||
((files / "m1" / "ancient.pdf"), ancient),
|
||||
((files / "up_abandoned" / "manual.pdf"), hour_ago),
|
||||
):
|
||||
path.write_bytes(b"x")
|
||||
os.utime(path, (when, when))
|
||||
|
||||
cfg = {"markers": [{"id": "m1", "pdfs": []}]}
|
||||
assert plans.collect_attachments(files, cfg, cfg) == 1
|
||||
assert (files / "m1" / "ancient.pdf").is_file(), "age alone is never a reason"
|
||||
assert not (files / "up_abandoned").exists(), "a cancelled dialog goes after an hour"
|
||||
|
||||
|
||||
|
||||
|
||||
# ---------- square canvas migration (v1.48.0) ----------
|
||||
|
||||
|
||||
gm = _load_pure("geometry_migration")
|
||||
|
||||
|
||||
def _sq(space, layout=None):
|
||||
cfg = {"spaces": [space]}
|
||||
gm.migrate_config(cfg, layout if layout is not None else {})
|
||||
return cfg["spaces"][0]
|
||||
|
||||
|
||||
def test_the_viewport_becomes_the_whole_square():
|
||||
"""The grid is drawn over the view box, and the fit fits it.
|
||||
|
||||
Transforming the old rectangle instead would leave the new margins outside
|
||||
the canvas — no grid there and nothing to draw on — which is the room the
|
||||
square canvas was meant to add.
|
||||
"""
|
||||
sp = _sq({"id": "f1", "aspect": 0.5, "view_box": [0.1, 0.2, 0.5, 0.5], "rooms": []})
|
||||
assert sp["view_box"] == [0.0, 0.0, 1.0, 1.0]
|
||||
|
||||
|
||||
def test_a_wide_plan_gains_margins_above_and_below():
|
||||
sp = _sq({
|
||||
"id": "f1", "aspect": 2.0, "cell_cm": 5, "view_box": [0, 0, 1, 1],
|
||||
"rooms": [{"id": "r", "x": 0.0, "y": 0.0, "w": 1.0, "h": 1.0}],
|
||||
})
|
||||
r = sp["rooms"][0]
|
||||
assert (r["x"], r["w"]) == (0.0, 1.0), "the width is untouched"
|
||||
assert r["y"] == 0.25 and r["h"] == 0.5, "half the height, centred"
|
||||
assert sp["cell_cm"] == 5, "the grid is tied to the width, which did not change"
|
||||
assert "aspect" not in sp
|
||||
|
||||
|
||||
def test_a_tall_plan_gains_margins_on_the_sides_and_rescales_the_grid():
|
||||
sp = _sq({
|
||||
"id": "f1", "aspect": 0.5, "cell_cm": 5, "view_box": [0, 0, 1, 1],
|
||||
"rooms": [{"id": "r", "poly": [[0, 0], [1, 0], [1, 1], [0, 1]]}],
|
||||
})
|
||||
poly = sp["rooms"][0]["poly"]
|
||||
assert [round(c, 6) for c in poly[0]] == [0.25, 0.0]
|
||||
assert [round(c, 6) for c in poly[2]] == [0.75, 1.0], "half the width, centred"
|
||||
assert sp["cell_cm"] == 10, "the canvas got twice as wide, so a cell is twice the cm"
|
||||
|
||||
|
||||
def test_a_square_plan_is_left_alone():
|
||||
before = {
|
||||
"id": "f1", "aspect": 1.0, "cell_cm": 5, "view_box": [0, 0, 1, 1],
|
||||
"rooms": [{"id": "r", "x": 0.1, "y": 0.2, "w": 0.3, "h": 0.4}],
|
||||
}
|
||||
sp = _sq({**before, "rooms": [dict(before["rooms"][0])]})
|
||||
assert sp["rooms"][0] == before["rooms"][0]
|
||||
assert sp["cell_cm"] == 5 and sp["view_box"] == [0.0, 0.0, 1.0, 1.0]
|
||||
|
||||
|
||||
def test_migration_preserves_real_lengths_and_shapes():
|
||||
"""A wall keeps its length in centimetres, and a square stays square."""
|
||||
GRID = 1000.0
|
||||
|
||||
def wall_cm(space, p, q):
|
||||
# render units per normalised unit is the canvas width, always 1000
|
||||
dx = (q[0] - p[0]) * GRID
|
||||
dy = (q[1] - p[1]) * GRID
|
||||
pitch = GRID / 40 # whatever the grid is, the same constant both sides
|
||||
return ((dx * dx + dy * dy) ** 0.5 / pitch) * float(space["cell_cm"])
|
||||
|
||||
for aspect in (2.0, 0.5, 0.8155784250916674, 1.4142):
|
||||
# a square room, 0.2 x 0.2 of the OLD box, i.e. 200 x 200/aspect render
|
||||
old = {"id": "f", "aspect": aspect, "cell_cm": 5,
|
||||
"rooms": [{"id": "r", "poly": [[0.2, 0.2], [0.4, 0.2], [0.4, 0.4], [0.2, 0.4]]}]}
|
||||
before_w = 0.2 * GRID
|
||||
before_h = 0.2 * GRID / aspect
|
||||
before_cm_w = (before_w / (GRID / 40)) * 5
|
||||
sp = _sq(old)
|
||||
poly = sp["rooms"][0]["poly"]
|
||||
after_w = (poly[1][0] - poly[0][0]) * GRID
|
||||
after_h = (poly[2][1] - poly[1][1]) * GRID
|
||||
assert abs(after_w / after_h - before_w / before_h) < 1e-9, "shape preserved"
|
||||
# cell_cm is stored rounded — a user reads it — so allow 0.01 cm on a
|
||||
# 40 cm wall rather than pretending the scale is infinitely precise
|
||||
assert abs(wall_cm(sp, poly[0], poly[1]) - before_cm_w) < 1e-2, "length in cm preserved"
|
||||
|
||||
|
||||
def test_migration_moves_marker_positions_of_that_space_only():
|
||||
layout = {
|
||||
"a": {"s": "f1", "x": 0.5, "y": 0.5},
|
||||
"b": {"s": "other", "x": 0.5, "y": 0.5},
|
||||
"c": "not a dict",
|
||||
}
|
||||
cfg = {"spaces": [{"id": "f1", "aspect": 2.0, "rooms": []},
|
||||
{"id": "other", "rooms": []}]}
|
||||
assert gm.migrate_config(cfg, layout) is True
|
||||
assert layout["a"] == {"s": "f1", "x": 0.5, "y": 0.5}, "x untouched for a wide plan"
|
||||
assert layout["a"]["y"] == 0.5
|
||||
assert layout["b"] == {"s": "other", "x": 0.5, "y": 0.5}, "another space is not touched"
|
||||
|
||||
|
||||
def test_migration_runs_once_and_only_when_needed():
|
||||
cfg = {"spaces": [{"id": "f1", "aspect": 2.0, "rooms": [], "cell_cm": 5}]}
|
||||
assert gm.migrate_config(cfg, {}) is True
|
||||
snapshot = repr(cfg)
|
||||
assert gm.migrate_config(cfg, {}) is False, "already square: nothing to do"
|
||||
assert repr(cfg) == snapshot
|
||||
|
||||
|
||||
def test_migration_survives_a_crash_between_the_two_store_writes():
|
||||
"""HP-1490-01: the two stores are written independently and either write
|
||||
can fail. The intent (space -> old aspect) is saved BEFORE anything moves
|
||||
and cleared with the layout write, so whichever half is missing after a
|
||||
crash, the next start finishes exactly it — once.
|
||||
"""
|
||||
cfg = {"spaces": [{"id": "f1", "aspect": 2.0, "rooms": []}]}
|
||||
layout = {"m": {"s": "f1", "x": 0.1, "y": 0.1}}
|
||||
|
||||
# start of the migration: the intent is computed from the config
|
||||
pending = gm.pending_from_config(cfg)
|
||||
assert pending == {"f1": 2.0}
|
||||
|
||||
# the config half commits; the process dies before the layout half
|
||||
assert gm.migrate_config(cfg) is True
|
||||
assert gm.pending_from_config(cfg) == {}, "the trigger left with the config write"
|
||||
|
||||
# next start: the config offers nothing, the SAVED intent still knows
|
||||
assert gm.migrate_layout(layout, pending) is True
|
||||
assert layout["m"] == {"s": "f1", "x": 0.1, "y": 0.3}, "y is re-centred for a wide plan"
|
||||
|
||||
# and the layout half never runs twice, because the intent is cleared by
|
||||
# the same write that stores the migrated layout — with no intent there is
|
||||
# nothing to apply
|
||||
assert gm.migrate_layout(layout, {}) is False
|
||||
assert layout["m"] == {"s": "f1", "x": 0.1, "y": 0.3}
|
||||
|
||||
|
||||
def test_migration_intent_is_the_union_of_saved_and_current():
|
||||
"""A crash BEFORE the config write leaves both the intent and the aspects;
|
||||
merging them must not double anything, and a space added to the config
|
||||
since (there cannot be one mid-crash, but the code should not care) still
|
||||
migrates."""
|
||||
cfg = {"spaces": [{"id": "f1", "aspect": 2.0, "rooms": []}]}
|
||||
saved = {"f1": 2.0}
|
||||
merged = {**saved, **gm.pending_from_config(cfg)}
|
||||
assert merged == {"f1": 2.0}
|
||||
layout = {"m": {"s": "f1", "x": 0.1, "y": 0.1}}
|
||||
gm.migrate_config(cfg)
|
||||
gm.migrate_layout(layout, merged)
|
||||
assert layout["m"]["y"] == 0.3
|
||||
assert cfg["spaces"][0]["view_box"] == [0.0, 0.0, 1.0, 1.0]
|
||||
|
||||
|
||||
# ---------- store-wide limits (HP-1470-01) ----------
|
||||
|
||||
|
||||
def test_check_quota_counts_the_whole_store_not_one_request(tmp_path):
|
||||
"""Per-request caps say nothing about how many requests there are."""
|
||||
d = tmp_path / "plans"
|
||||
d.mkdir()
|
||||
for i in range(3):
|
||||
(d / f"p{i}.png").write_bytes(b"x" * 1000)
|
||||
|
||||
plans.check_quota(d, 1000, max_bytes=10_000, max_files=10) # fits
|
||||
|
||||
with pytest.raises(plans.QuotaError) as e:
|
||||
plans.check_quota(d, 8000, max_bytes=10_000, max_files=10)
|
||||
assert e.value.reason == "quota_exceeded" and "MB" in e.value.detail
|
||||
|
||||
with pytest.raises(plans.QuotaError) as e:
|
||||
plans.check_quota(d, 1, max_bytes=10_000, max_files=3)
|
||||
assert e.value.reason == "too_many_files"
|
||||
|
||||
|
||||
def test_dir_usage_walks_subfolders_and_ignores_the_unreadable(tmp_path):
|
||||
d = tmp_path / "files"
|
||||
(d / "m1").mkdir(parents=True)
|
||||
(d / "m1" / "a.pdf").write_bytes(b"x" * 10)
|
||||
(d / "b.pdf").write_bytes(b"x" * 5)
|
||||
assert plans.dir_usage(d) == (15, 2)
|
||||
assert plans.dir_usage(tmp_path / "nope") == (0, 0)
|
||||
|
||||
|
||||
def test_check_quota_refuses_when_the_disk_is_nearly_full(tmp_path, monkeypatch):
|
||||
import shutil
|
||||
|
||||
d = tmp_path / "plans"
|
||||
d.mkdir()
|
||||
monkeypatch.setattr(
|
||||
shutil, "disk_usage", lambda _p: type("U", (), {"free": const.MIN_FREE_BYTES // 2})()
|
||||
)
|
||||
with pytest.raises(plans.QuotaError) as e:
|
||||
plans.check_quota(d, 1, max_bytes=10 ** 12, max_files=10 ** 6)
|
||||
assert e.value.reason == "low_disk_space"
|
||||
|
||||
Reference in New Issue
Block a user