mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 03:38:47 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b180c5917 | ||
|
|
3084472c75 | ||
|
|
c00048611e | ||
|
|
f5e6c0318d | ||
|
|
e1e730560d | ||
|
|
94b298962a | ||
|
|
f7fe63776a | ||
|
|
01bc4f9711 | ||
|
|
85491d0fea | ||
|
|
d37a67c29f | ||
|
|
a66272c6f4 | ||
|
|
f4af2fe508 | ||
|
|
8e07e3c958 | ||
|
|
9868f1035f | ||
|
|
f2c9b07cc1 | ||
|
|
2c7a2f849d | ||
|
|
33e71ca96c | ||
|
|
7128ab504d | ||
|
|
f953a3c286 | ||
|
|
ef270d11b7 | ||
|
|
dc24390222 | ||
|
|
254354bf56 | ||
|
|
c9a60a110d | ||
|
|
75279308c1 | ||
|
|
b7ae3e7adf | ||
|
|
379fb68db2 | ||
|
|
96a70495d3 | ||
|
|
d3db9e30e6 | ||
|
|
2e731debd9 | ||
|
|
a49b5e6d2e | ||
|
|
4418312b0b | ||
|
|
3f719cc32a | ||
|
|
260615a63f | ||
|
|
e4e300adaa | ||
|
|
96d387ff1d | ||
|
|
8b531db3f5 | ||
|
|
68aa1f04ba | ||
|
|
3d41fe16b8 | ||
|
|
ac734688d4 | ||
|
|
2e2d353b04 | ||
|
|
f8c8cb4eeb | ||
|
|
c749b52a0d | ||
|
|
15e5dd7392 | ||
|
|
f1b501a956 | ||
|
|
5d2dbb1009 | ||
|
|
40cb0302e3 | ||
|
|
14cc4df4bd | ||
|
|
ead56dd9b6 | ||
|
|
018b37940f | ||
|
|
ebeaa5c0c6 | ||
|
|
02ba18dc7b | ||
|
|
715a93ec61 | ||
|
|
09b0ba41a5 | ||
|
|
0467cee98a | ||
|
|
c0653dfc73 | ||
|
|
946e7543ad | ||
|
|
641c61dc19 | ||
|
|
ae9168f6ec | ||
|
|
45c863138a | ||
|
|
e04ef2f2e6 | ||
|
|
a841d17543 | ||
|
|
e63b7882a6 | ||
|
|
c1e3cdb768 | ||
|
|
41b20e1901 | ||
|
|
49b0cb4e05 | ||
|
|
0fd0ba408d | ||
|
|
5c7d1ca8bb | ||
|
|
b4bb732736 | ||
|
|
10d4084d17 | ||
|
|
19e19b5c5f | ||
|
|
88dc0d1de7 | ||
|
|
ad7e946e75 | ||
|
|
9191a94701 | ||
|
|
eea669fa12 | ||
|
|
bd9fabfe99 | ||
|
|
b03ef70794 | ||
|
|
1d6ca968e8 | ||
|
|
82eed100b2 | ||
|
|
f27c91ade1 | ||
|
|
4b4df4b3a2 | ||
|
|
df9e158efb | ||
|
|
0522413c48 | ||
|
|
9bfef453db | ||
|
|
9c92bcdf2f | ||
|
|
8adb262410 | ||
|
|
3811a1ca73 | ||
|
|
8895354c4e | ||
|
|
ea41bec86b | ||
|
|
05f162434b | ||
|
|
0e14139fe2 | ||
|
|
6b9909768f | ||
|
|
20c7b55a87 | ||
|
|
3a89966e4b | ||
|
|
8330b48cf3 | ||
|
|
81ea5c0f3c | ||
|
|
df2ed0c3e1 | ||
|
|
d7a1b344e4 | ||
|
|
7eaf513c9e | ||
|
|
1e20279adf | ||
|
|
031e5439eb | ||
|
|
2bf9e44178 | ||
|
|
c2eaf50118 | ||
|
|
ac082569d7 | ||
|
|
a90316c9f3 | ||
|
|
912613bbf8 | ||
|
|
935a519c32 | ||
|
|
dd930b64f7 | ||
|
|
f235c8afc5 | ||
|
|
5e6f9c407c | ||
|
|
849117eb27 | ||
|
|
37b39e7a1e | ||
|
|
d4f2d81a2f | ||
|
|
bcb546dbc7 | ||
|
|
ccf36e283e | ||
|
|
821bdfbd9a | ||
|
|
22992b256f | ||
|
|
d273a90db8 | ||
|
|
48d47a46af | ||
|
|
0d712b9069 | ||
|
|
dfa2a8badc | ||
|
|
2dfab3565b | ||
|
|
6ee09d3750 | ||
|
|
29c5a89aad | ||
|
|
71f44fd528 | ||
|
|
c8722642b7 | ||
|
|
0a7b9a627f | ||
|
|
961d6afe67 | ||
|
|
b9fecbdd95 | ||
|
|
9fd36bd710 | ||
|
|
a9346c9c14 | ||
|
|
794b02b84f |
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: 💬 Telegram chat (@ha_houseplan)
|
||||
url: https://t.me/ha_houseplan
|
||||
about: Questions, setup help, ideas and screenshots — the fastest way to get an answer.
|
||||
- name: 💡 GitHub discussions
|
||||
url: https://github.com/Matysh/houseplan-card/discussions
|
||||
about: Longer-form ideas and show-and-tell.
|
||||
@@ -34,6 +34,44 @@ jobs:
|
||||
run: npm run build
|
||||
- name: Card bundle in sync with integration
|
||||
run: cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
|
||||
smoke:
|
||||
# audit T2: the end-to-end layer used to run only when a human remembered.
|
||||
# Gated on `frontend` so a typecheck failure does not burn browser minutes.
|
||||
needs: frontend
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22 }
|
||||
- run: npm ci
|
||||
- name: Install Chromium for Playwright
|
||||
run: npx playwright install --with-deps chromium
|
||||
- name: Build a FRESH bundle for the smokes
|
||||
# the committed demo/srv/assets copy is a snapshot; testing it would
|
||||
# report green about code that no longer exists (audit T2)
|
||||
run: npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
|
||||
- name: Smoke suite
|
||||
run: |
|
||||
fail=0
|
||||
mkdir -p /tmp/smoke-logs
|
||||
for f in demo/smoke_*.mjs; do
|
||||
name=$(basename "$f" .mjs)
|
||||
if node "$f" > "/tmp/smoke-logs/$name.log" 2>&1; then
|
||||
echo "ok $name"
|
||||
else
|
||||
echo "FAIL $name"
|
||||
tail -20 "/tmp/smoke-logs/$name.log"
|
||||
fail=1
|
||||
fi
|
||||
done
|
||||
exit $fail
|
||||
- name: Upload smoke logs
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: smoke-logs
|
||||
path: /tmp/smoke-logs
|
||||
|
||||
backend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -3,6 +3,19 @@
|
||||
Thanks for your interest! The project is one HACS package: a storage **integration**
|
||||
(`custom_components/houseplan/`, Python) and a **Lovelace card** (`src/`, TypeScript + Lit).
|
||||
|
||||
## Changelog
|
||||
|
||||
User-visible changes go into **both** changelogs in the same commit:
|
||||
`docs/CHANGELOG.md` (English) and `docs/CHANGELOG.ru.md` (Russian). Entries
|
||||
older than v1.42.0 exist only in the English file — no need to backfill them.
|
||||
|
||||
## Where to ask
|
||||
|
||||
Not sure whether something is a bug, or just want to discuss an idea before
|
||||
writing code? The **[Telegram chat @ha_houseplan](https://t.me/ha_houseplan)**
|
||||
is the quickest route to the author and other users. Bugs and concrete feature
|
||||
requests still belong in [issues](https://github.com/Matysh/houseplan-card/issues).
|
||||
|
||||
## Five-minute setup
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,10 +1,40 @@
|
||||
# 🏠 House Plan — an interactive house plan for Home Assistant
|
||||
# 🏠 House Plan — interactive floor plan card for Home Assistant
|
||||
|
||||
**A live map of your home right inside Home Assistant: floors, rooms and devices on a real floor plan — with live states, temperature and signal strength. Everything is configured with the mouse, without a single line of YAML.**
|
||||
[](https://github.com/hacs/integration)
|
||||
[](https://github.com/Matysh/houseplan-card/releases)
|
||||
[](https://github.com/Matysh/houseplan-card/stargazers)
|
||||
[](https://github.com/Matysh/houseplan-card/actions)
|
||||
[](LICENSE)
|
||||
[](https://t.me/ha_houseplan)
|
||||
|
||||

|
||||
**Turn Home Assistant into a live, interactive map of your home.** Upload or draw
|
||||
a floor plan, outline the rooms with your mouse — and every smart device appears
|
||||
in its real place: live states, tap-to-toggle lights, temperature and humidity per
|
||||
room, Zigbee signal maps, glowing light pools and a fullscreen kiosk mode for wall
|
||||
tablets. No YAML, no Inkscape, no external editors — the whole floorplan lives
|
||||
right on your Lovelace dashboard.
|
||||
|
||||
🇷🇺 [Документация на русском](README.ru.md)
|
||||

|
||||
|
||||
🇷🇺 [Документация на русском](README.ru.md) · 💬 [Telegram chat: **@ha_houseplan**](https://t.me/ha_houseplan)
|
||||
|
||||
**Feature highlights**
|
||||
|
||||
- 🖱 **GUI-first floorplan editor** — rooms, doors & windows, island rooms,
|
||||
virtual walls and a visual decor layer, all drawn with clicks; smart
|
||||
alignment guides and a live ruler in real meters/feet.
|
||||
- 💡 **Lights toggle on click** out of the box; wall-switch markers can control
|
||||
whole groups of lights (works for dumb switches and stateless remotes too).
|
||||
- 🌒 **“Light sources” fill** — a dark house where every lit lamp casts a pool
|
||||
of its own color that spills through doorways and open zone boundaries.
|
||||
- 🌡 **Room cards** with temperature, humidity, Zigbee LQI and light count;
|
||||
comfort-range temperature fills, per-room signal heatmap.
|
||||
- 🚪 **Doors, windows and locks** with contact sensors — unlocking is always an
|
||||
explicit button, never an accidental tap.
|
||||
- 📺 **Kiosk mode** for wall tablets and TVs: fullscreen, swipe between floors,
|
||||
auto-carousel, per-screen icon sizes.
|
||||
- 🔔 New devices appear automatically with a red “new” dot; the layout is stored
|
||||
**server-side** — one shared plan for every user and screen, synced live.
|
||||
|
||||
---
|
||||
|
||||
@@ -49,6 +79,23 @@ Key advantages in short:
|
||||
|
||||
---
|
||||
|
||||
## Wall tablet / TV (kiosk mode)
|
||||
|
||||
Add the card to a dedicated dashboard with a **panel view** and set `kiosk: true`
|
||||
(or tick "Wall device (kiosk) mode" in the card editor):
|
||||
|
||||
```yaml
|
||||
type: custom:houseplan-card
|
||||
kiosk: true
|
||||
cycle: 0 # seconds between auto space switches, 0 = off (nice for TVs)
|
||||
```
|
||||
|
||||
No header, no editors — just the live plan. Swipe to change floors (at 1:1),
|
||||
pinch to zoom, double-tap to reset. Long-press an empty spot for 3 seconds to
|
||||
tune icon and text sizes for THIS screen (saved per device). To hide Home
|
||||
Assistant's own header use the companion app's kiosk settings or the
|
||||
[kiosk-mode](https://github.com/NemesisRE/kiosk-mode) plugin.
|
||||
|
||||
## Installation
|
||||
|
||||
One click if you already run HACS:
|
||||
@@ -212,11 +259,29 @@ turned the way it is mounted.
|
||||
|
||||
---
|
||||
|
||||
## Getting help & sharing your plan
|
||||
|
||||
- 💬 **[Telegram chat — @ha_houseplan](https://t.me/ha_houseplan)** — questions,
|
||||
setup help, feature ideas, and screenshots of your plans. The fastest way to
|
||||
reach the author and other users.
|
||||
- 🐞 [GitHub issues](https://github.com/Matysh/houseplan-card/issues) — bug
|
||||
reports and feature requests (please attach your House Plan version).
|
||||
- 💡 [GitHub discussions](https://github.com/Matysh/houseplan-card/discussions) —
|
||||
longer-form ideas.
|
||||
- 📜 [Changelog](docs/CHANGELOG.md) — what changed in every version
|
||||
([на русском](docs/CHANGELOG.ru.md)).
|
||||
|
||||
When reporting a problem, the version number helps a lot: it is shown in the
|
||||
browser console on load (`HOUSEPLAN-CARD vX.Y.Z`) and in **Settings → Devices &
|
||||
Services → House Plan**.
|
||||
|
||||
---
|
||||
|
||||
## Frequently asked questions
|
||||
|
||||
**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 by filtering (bridges, service records, duplicates) — enable the **👁 "Show all devices"** button in the header.
|
||||
|
||||
**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.
|
||||
|
||||
|
||||
+67
-4
@@ -1,13 +1,42 @@
|
||||
# 🏠 House Plan — интерактивный план дома для Home Assistant
|
||||
# 🏠 House Plan — интерактивный поэтажный план дома для Home Assistant
|
||||
|
||||
**Живая карта вашего дома прямо в Home Assistant: этажи, комнаты и устройства на настоящем плане — с реальными состояниями, температурой и уровнем сигнала. Всё настраивается мышкой, без единой строчки YAML.**
|
||||
[](https://github.com/hacs/integration)
|
||||
[](https://github.com/Matysh/houseplan-card/releases)
|
||||
[](https://github.com/Matysh/houseplan-card/stargazers)
|
||||
[](https://t.me/ha_houseplan)
|
||||
|
||||

|
||||
**Превратите Home Assistant в живую интерактивную карту дома.** Загрузите или
|
||||
нарисуйте план этажа, обведите комнаты мышкой — и умные устройства появятся на
|
||||
своих местах: живые состояния, свет по клику, температура и влажность по
|
||||
комнатам, карта Zigbee-сигнала, светящиеся пятна ламп и полноэкранный
|
||||
киоск-режим для настенного планшета. Без YAML, без Inkscape и внешних
|
||||
редакторов — весь план настраивается прямо на дашборде.
|
||||
|
||||
🇬🇧 [English documentation](README.md)
|
||||

|
||||
|
||||
🇬🇧 [Documentation in English](README.md) · 💬 [Чат в Telegram: **@ha_houseplan**](https://t.me/ha_houseplan)
|
||||
|
||||
**Главное**
|
||||
|
||||
- 🖱 **Редакторы прямо в карточке** — комнаты, двери и окна, комнаты-острова,
|
||||
виртуальные стены и декор-слой рисуются кликами; помощник выравнивания и
|
||||
линейка в реальных метрах.
|
||||
- 💡 **Свет переключается кликом** из коробки; значок выключателя может
|
||||
управлять группой ламп (в т.ч. «тупые» выключатели и кнопки-пульты).
|
||||
- 🌒 **Заливка «Свет по источникам»** — тёмный дом, где каждая горящая лампа
|
||||
даёт пятно своего цвета, проникающее через дверные проёмы и открытые зоны.
|
||||
- 🌡 **Карточки комнат**: температура, влажность, Zigbee-сигнал, свет «1 из 3»;
|
||||
температурная заливка по комфортным границам.
|
||||
- 🚪 **Двери, окна и замки** с датчиками — отпирание только явной кнопкой,
|
||||
никогда случайным тапом.
|
||||
- 📺 **Киоск-режим** для настенных планшетов и ТВ: полноэкранно, свайп между
|
||||
этажами, автокарусель, свои размеры на каждом экране.
|
||||
- 🔔 Новые устройства сами появляются на плане с красной точкой; раскладка
|
||||
хранится **на сервере HA** — один план для всех экранов, живая синхронизация.
|
||||
|
||||
---
|
||||
|
||||
|
||||
## Что это и зачем
|
||||
|
||||
House Plan показывает ваш умный дом так, как он выглядит на самом деле — на плане этажей. Вместо длинных списков сущностей вы видите комнаты и устройства на своих местах: где протечка, какая температура в детской, включён ли свет в прихожей, открыты ли ворота.
|
||||
@@ -49,6 +78,23 @@ House Plan показывает ваш умный дом так, как он в
|
||||
|
||||
---
|
||||
|
||||
## Настенный планшет / ТВ (киоск-режим)
|
||||
|
||||
Отдельный дашборд с view типа «панель», у карточки — `kiosk: true` (или
|
||||
галочка «Режим настенного устройства» в редакторе карточки):
|
||||
|
||||
```yaml
|
||||
type: custom:houseplan-card
|
||||
kiosk: true
|
||||
cycle: 0 # автосмена пространств каждые N секунд, 0 = выкл (удобно для ТВ)
|
||||
```
|
||||
|
||||
Без шапки и редакторов — только живой план. Свайп листает этажи (при 1:1),
|
||||
пинч — зум, двойной тап — сброс. Долгое нажатие (3 с) по пустому месту —
|
||||
настройка размеров значков и текста для ЭТОГО экрана (хранится на
|
||||
устройстве). Шапку самого Home Assistant скрывают настройки companion-app
|
||||
или плагин [kiosk-mode](https://github.com/NemesisRE/kiosk-mode).
|
||||
|
||||
## Установка
|
||||
|
||||
В один клик, если у вас уже есть HACS:
|
||||
@@ -216,6 +262,23 @@ title: План дома
|
||||
|
||||
---
|
||||
|
||||
## Помощь и обмен опытом
|
||||
|
||||
- 💬 **[Чат в Telegram — @ha_houseplan](https://t.me/ha_houseplan)** — вопросы,
|
||||
помощь с настройкой, идеи и скриншоты ваших планов. Самый быстрый способ
|
||||
связаться с автором и другими пользователями.
|
||||
- 🐞 [Issues на GitHub](https://github.com/Matysh/houseplan-card/issues) — баги
|
||||
и запросы фич (пожалуйста, указывайте версию House Plan).
|
||||
- 💡 [Discussions](https://github.com/Matysh/houseplan-card/discussions) — для
|
||||
развёрнутых обсуждений.
|
||||
- 📜 [История изменений](docs/CHANGELOG.ru.md) — что менялось в каждой версии.
|
||||
|
||||
Версия видна в консоли браузера при загрузке (`HOUSEPLAN-CARD vX.Y.Z`) и в
|
||||
**Настройки → Устройства и службы → House Plan** — с ней разбираться сильно
|
||||
быстрее.
|
||||
|
||||
---
|
||||
|
||||
## Часто задаваемые вопросы
|
||||
|
||||
**Нужно ли что-то писать в YAML?** Нет. Единственная строчка — это добавление карточки на дашборд; всё остальное делается мышкой.
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from homeassistant.components.frontend import add_extra_js_url
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
|
||||
from . import websocket_api as hp_ws
|
||||
from .const import (
|
||||
@@ -18,6 +20,8 @@ from .const import (
|
||||
PLANS_URL,
|
||||
VERSION,
|
||||
)
|
||||
from .geometry_migration import migrate_config
|
||||
from .plans import collect_attachments, collect_plans, sweep_upload_temps
|
||||
from .repairs import async_check_plan_files
|
||||
from .store import HouseplanConfigEntry, create_data
|
||||
|
||||
@@ -28,9 +32,10 @@ async def async_setup(hass: HomeAssistant, config) -> bool:
|
||||
"""Register global handlers (survive config-entry reloads): WS commands, HTTP view."""
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
hp_ws.async_register(hass)
|
||||
from .http_api import HouseplanUploadView
|
||||
from .http_api import HouseplanContentView, HouseplanUploadView
|
||||
|
||||
hass.http.register_view(HouseplanUploadView())
|
||||
hass.http.register_view(HouseplanContentView())
|
||||
return True
|
||||
|
||||
|
||||
@@ -61,14 +66,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
|
||||
|
||||
if card_path.exists():
|
||||
static_paths.append(StaticPathConfig(FRONTEND_URL, str(card_path), cache_headers=False))
|
||||
static_paths.append(StaticPathConfig(PLANS_URL, str(plans_path), cache_headers=True))
|
||||
static_paths.append(StaticPathConfig(FILES_URL, str(files_path), cache_headers=True))
|
||||
await hass.http.async_register_static_paths(static_paths)
|
||||
# NOTE (audit B1): plans and marker files are NO LONGER static.
|
||||
# They are served by HouseplanContentView, which requires auth.
|
||||
# Only the card bundle stays public — Lovelace resources must be.
|
||||
if static_paths:
|
||||
await hass.http.async_register_static_paths(static_paths)
|
||||
except ImportError: # very old HA versions
|
||||
if card_path.exists():
|
||||
hass.http.register_static_path(FRONTEND_URL, str(card_path), cache_headers=False)
|
||||
hass.http.register_static_path(PLANS_URL, str(plans_path), cache_headers=True)
|
||||
hass.http.register_static_path(FILES_URL, str(files_path), cache_headers=True)
|
||||
|
||||
if not card_path.exists():
|
||||
_LOGGER.warning("houseplan-card.js not found next to the integration: %s", card_path)
|
||||
@@ -95,7 +100,76 @@ 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.
|
||||
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 {}
|
||||
if cfg and migrate_config(cfg, layout):
|
||||
rev = int(stored.get("rev", 0)) + 1
|
||||
await data.config_store.async_save({"config": cfg, "rev": rev})
|
||||
await data.store.async_save(
|
||||
{"layout": layout, "rev": int(lay_stored.get("rev", 0)) + 1}
|
||||
)
|
||||
_LOGGER.info(
|
||||
"House Plan: migrated %s space(s) to the square canvas", len(cfg.get("spaces") or [])
|
||||
)
|
||||
hass.bus.async_fire("houseplan_config_updated", {"rev": rev})
|
||||
|
||||
await async_check_plan_files(hass, entry)
|
||||
|
||||
# Scheduled collection of everything nobody ended up referencing.
|
||||
#
|
||||
# A commit collects what that commit superseded, which is the right rule for
|
||||
# a commit — but it only ever runs when somebody saves. Cancel a dialog
|
||||
# after the file has already uploaded, lose the connection after the upload
|
||||
# succeeded, or call the API directly, and the file is unreferenced with no
|
||||
# future write to notice it (HP-1461-01). The earlier version of this sweep
|
||||
# only removed streaming temporaries, which are a different, narrower case.
|
||||
#
|
||||
# Passing the CURRENT configuration as both sides means "nothing was
|
||||
# superseded": every referenced file is preserved and only unreferenced ones
|
||||
# past PLAN_ORPHAN_TTL_S go. It runs under the same lock as a config write,
|
||||
# so it cannot decide from a snapshot that a commit is about to replace.
|
||||
async def _sweep(_now=None) -> None:
|
||||
files_dir = Path(hass.config.path(FILES_DIR))
|
||||
plans_dir = Path(hass.config.path(PLANS_DIR))
|
||||
try:
|
||||
# `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
|
||||
|
||||
n = await hass.async_add_executor_job(_collect)
|
||||
if n:
|
||||
_LOGGER.info("House Plan: removed %s unreferenced file(s)", n)
|
||||
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))
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,28 @@
|
||||
"""Single source of truth for the write-authorization policy.
|
||||
|
||||
The WS and HTTP paths used to duplicate this decision and drifted apart: the
|
||||
WS copy was fixed to fail closed while the upload view still failed OPEN when
|
||||
the config entry was unavailable (audit follow-up B2, 2026-07-27). One helper,
|
||||
one behaviour.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import CONF_ADMIN_ONLY
|
||||
from .store import get_entry
|
||||
|
||||
|
||||
def may_write(hass: HomeAssistant, user) -> bool:
|
||||
"""True when `user` may modify House Plan data.
|
||||
|
||||
Fails CLOSED: when the entry cannot be read — during a reload, or while the
|
||||
integration is disabled — the policy is unknown, and "unknown" is not the
|
||||
same as "permissive": only admins are allowed through.
|
||||
"""
|
||||
is_admin = bool(getattr(user, "is_admin", False))
|
||||
entry = get_entry(hass)
|
||||
if entry is None:
|
||||
return is_admin
|
||||
admin_only = bool(entry.options.get(CONF_ADMIN_ONLY, False))
|
||||
return is_admin if admin_only else True
|
||||
@@ -9,9 +9,43 @@ FRONTEND_URL = "/houseplan_files/houseplan-card.js"
|
||||
PLANS_URL = "/houseplan_files/plans"
|
||||
PLANS_DIR = "houseplan/plans" # relative to the HA configuration directory
|
||||
FILES_URL = "/houseplan_files/files"
|
||||
# authenticated read path (audit B1): /api/houseplan/content/<plans|files>/<sub>/<name>
|
||||
CONTENT_URL = "/api/houseplan/content"
|
||||
|
||||
# How many paths one houseplan/content/sign call may carry. The card batches to
|
||||
# the same number; a client that sends more used to get a partial answer with no
|
||||
# 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.26.0"
|
||||
VERSION = "1.49.0"
|
||||
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"spaces": [],
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,133 @@
|
||||
"""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 migrate_config(config: dict[str, Any], layout: dict[str, Any] | None = None) -> bool:
|
||||
"""Migrate every space, and the marker positions that belong to them."""
|
||||
spaces = config.get("spaces") or []
|
||||
factors = {}
|
||||
for space in spaces:
|
||||
if "aspect" in space:
|
||||
factors[str(space.get("id"))] = transform_for(space.get("aspect") or 1)
|
||||
if not factors:
|
||||
return False
|
||||
|
||||
for space in spaces:
|
||||
migrate_space(space)
|
||||
|
||||
for pos in (layout or {}).values():
|
||||
if not isinstance(pos, dict):
|
||||
continue
|
||||
f = factors.get(str(pos.get("s")))
|
||||
if not f:
|
||||
continue
|
||||
dx, dy, kx, ky = f
|
||||
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
|
||||
return True
|
||||
@@ -6,6 +6,8 @@ breaks the connection on a large PDF) but via a plain multipart POST — like me
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
@@ -18,8 +20,12 @@ except ImportError: # older HA versions
|
||||
KEY_HASS = "hass" # type: ignore[assignment]
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import CONF_ADMIN_ONLY, FILES_DIR, FILES_URL
|
||||
from .store import get_entry
|
||||
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, QuotaError, check_quota, reserve_filename
|
||||
from .validation import (
|
||||
FILE_EXTENSIONS,
|
||||
MAX_FILE_BYTES,
|
||||
@@ -31,6 +37,79 @@ from .validation import (
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_CHUNK = 64 * 1024
|
||||
# batch disk writes: one executor job per megabyte instead of per chunk
|
||||
_FLUSH_AT = 1024 * 1024
|
||||
|
||||
_MIME = {
|
||||
".pdf": "application/pdf",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".svg": "image/svg+xml",
|
||||
".webp": "image/webp",
|
||||
".gif": "image/gif",
|
||||
".txt": "text/plain",
|
||||
}
|
||||
|
||||
|
||||
class HouseplanContentView(HomeAssistantView):
|
||||
"""Authenticated read access to plans and marker files (audit B1).
|
||||
|
||||
The directories used to be exposed as unauthenticated static paths, so
|
||||
anyone who could reach the HA endpoint could pull floor plans and uploaded
|
||||
manuals without logging in. This view keeps the same URLs but requires a
|
||||
Home Assistant session (or a signed path, which the frontend uses for
|
||||
<image href> inside the SVG).
|
||||
"""
|
||||
|
||||
url = "/api/houseplan/content/{kind}/{sub}/{name}"
|
||||
name = "api:houseplan:content"
|
||||
requires_auth = True
|
||||
|
||||
async def get(self, request: web.Request, kind: str, sub: str, name: str) -> web.StreamResponse:
|
||||
hass: HomeAssistant = request.app[KEY_HASS]
|
||||
if kind not in ("plans", "files"):
|
||||
return web.Response(status=404)
|
||||
safe_sub = sanitize_marker_id(sub)
|
||||
safe_name = sanitize_filename(name)
|
||||
if not safe_sub or not safe_name:
|
||||
return web.Response(status=404)
|
||||
base = Path(hass.config.path(PLANS_DIR if kind == "plans" else FILES_DIR)).resolve()
|
||||
# plans live flat in one directory: the sub segment is a placeholder ("_")
|
||||
path = (base / safe_name if kind == "plans" else base / safe_sub / safe_name).resolve()
|
||||
# defence in depth: the sanitizers already strip separators
|
||||
if not str(path).startswith(str(base)):
|
||||
return web.Response(status=404)
|
||||
|
||||
if not await hass.async_add_executor_job(path.is_file):
|
||||
return web.Response(status=404)
|
||||
suffix = path.suffix.lower()
|
||||
headers = {
|
||||
"Cache-Control": "private, max-age=3600",
|
||||
"Content-Type": _MIME.get(suffix, "application/octet-stream"),
|
||||
}
|
||||
if suffix == ".svg":
|
||||
# An uploaded SVG is user content served from Home Assistant's own
|
||||
# origin. Inside the card it is referenced by <image>, where scripts
|
||||
# never run — but the same url opened as a top-level document is a
|
||||
# live document of this origin, and a <script> in it reaches the
|
||||
# session's localStorage and API (HP-1454-01, 2026-07-28: uploading
|
||||
# needs write access, which by default every authenticated user has,
|
||||
# and the signed url is easy to hand to an admin).
|
||||
#
|
||||
# `sandbox` with no allow-* tokens drops the document into an opaque
|
||||
# origin: no scripts, no same-origin access, no forms. The explicit
|
||||
# directives below are belt and braces for older engines. Only SVG
|
||||
# gets this — a CSP on a PDF response can break the browser's built-in
|
||||
# viewer, and a raster image cannot execute anything in the first place.
|
||||
headers["Content-Security-Policy"] = (
|
||||
"sandbox; default-src 'none'; script-src 'none'; object-src 'none'; "
|
||||
"base-uri 'none'; form-action 'none'; style-src 'unsafe-inline'; img-src data:"
|
||||
)
|
||||
# FileResponse streams from disk: a 50 MB manual used to be read whole
|
||||
# into memory and copied into the response body, so a couple of parallel
|
||||
# downloads could push a small Home Assistant host into swap (HP-1454-06).
|
||||
return web.FileResponse(path, chunk_size=_CHUNK, headers=headers)
|
||||
|
||||
|
||||
class HouseplanUploadView(HomeAssistantView):
|
||||
@@ -42,62 +121,135 @@ class HouseplanUploadView(HomeAssistantView):
|
||||
|
||||
async def post(self, request: web.Request) -> web.Response:
|
||||
hass: HomeAssistant = request.app[KEY_HASS]
|
||||
entry = get_entry(hass)
|
||||
admin_only = bool(entry and entry.options.get(CONF_ADMIN_ONLY, False))
|
||||
if admin_only:
|
||||
user = request.get("hass_user")
|
||||
if user is None or not user.is_admin:
|
||||
return web.json_response({"error": "unauthorized"}, status=403)
|
||||
if not may_write(hass, request.get("hass_user")):
|
||||
return web.json_response({"error": "unauthorized"}, status=403)
|
||||
|
||||
files_root = Path(hass.config.path(FILES_DIR))
|
||||
marker_id = "misc"
|
||||
filename: str | None = None
|
||||
blob: bytes | None = None
|
||||
too_large = False
|
||||
# Every temporary file this request creates, promoted or not. The outer
|
||||
# `finally` removes whatever is left: a dropped connection, a second
|
||||
# `file` part or a failure while promoting used to leave a `.upload-*`
|
||||
# behind for good, and the collector only ever walks marker folders, so
|
||||
# nothing would have picked it up (HP-1460-02).
|
||||
temps: list[Path] = []
|
||||
error: tuple[dict, int] | None = None
|
||||
|
||||
def _new_tmp() -> Path:
|
||||
files_root.mkdir(parents=True, exist_ok=True)
|
||||
fd, name = tempfile.mkstemp(prefix=TMP_PREFIX, dir=str(files_root))
|
||||
os.close(fd)
|
||||
return Path(name)
|
||||
|
||||
def _flush(target: Path, blocks: list[bytes]) -> None:
|
||||
with open(target, "ab") as fh:
|
||||
for block in blocks:
|
||||
fh.write(block)
|
||||
|
||||
def _cleanup(paths: list[Path]) -> None:
|
||||
for path in paths:
|
||||
try:
|
||||
path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
try:
|
||||
reader = await request.multipart()
|
||||
async for part in reader:
|
||||
if part.name == "marker_id":
|
||||
marker_id = sanitize_marker_id(await part.text())
|
||||
elif part.name == "file":
|
||||
filename = part.filename or "file"
|
||||
# read in chunks, aborting at the limit, instead of loading the whole file into memory
|
||||
chunks: list[bytes] = []
|
||||
size = 0
|
||||
while chunk := await part.read_chunk(_CHUNK):
|
||||
size += len(chunk)
|
||||
if size > MAX_FILE_BYTES:
|
||||
too_large = True
|
||||
try:
|
||||
reader = await request.multipart()
|
||||
async for part in reader:
|
||||
if part.name == "marker_id":
|
||||
marker_id = sanitize_marker_id(await part.text())
|
||||
elif part.name == "file":
|
||||
if filename is not None:
|
||||
# one upload per request: a second part would strand
|
||||
# the first temporary file and make the response
|
||||
# ambiguous about which url was returned
|
||||
error = ({"error": "one_file_only"}, 400)
|
||||
break
|
||||
chunks.append(chunk)
|
||||
if too_large:
|
||||
break
|
||||
blob = b"".join(chunks)
|
||||
except Exception as err: # noqa: BLE001
|
||||
_LOGGER.warning("House Plan upload: multipart read error: %s", err)
|
||||
return web.json_response({"error": "bad_request"}, status=400)
|
||||
filename = part.filename or "file"
|
||||
if file_ext(filename) not in FILE_EXTENSIONS:
|
||||
error = ({"error": "bad_ext", "allowed": sorted(FILE_EXTENSIONS)}, 400)
|
||||
break
|
||||
# Stream to a temporary file instead of collecting the
|
||||
# whole upload in memory and copying it again into one
|
||||
# buffer: a 50 MB manual used to cost ~100 MB of RSS
|
||||
# mid-request (HP-1454-06). Blocks are batched so this
|
||||
# is one executor job per megabyte, not per 64 KB.
|
||||
tmp = await hass.async_add_executor_job(_new_tmp)
|
||||
temps.append(tmp)
|
||||
size = 0
|
||||
pending: list[bytes] = []
|
||||
buffered = 0
|
||||
while chunk := await part.read_chunk(_CHUNK):
|
||||
size += len(chunk)
|
||||
if size > MAX_FILE_BYTES:
|
||||
error = (
|
||||
{"error": "too_large", "max_mb": MAX_FILE_BYTES // 1024 // 1024},
|
||||
413,
|
||||
)
|
||||
break
|
||||
pending.append(chunk)
|
||||
buffered += len(chunk)
|
||||
if buffered >= _FLUSH_AT:
|
||||
await hass.async_add_executor_job(_flush, tmp, pending)
|
||||
pending, buffered = [], 0
|
||||
if error:
|
||||
break
|
||||
if pending:
|
||||
await hass.async_add_executor_job(_flush, tmp, pending)
|
||||
except Exception as err: # noqa: BLE001
|
||||
_LOGGER.warning("House Plan upload: multipart read error: %s", err)
|
||||
error = ({"error": "bad_request"}, 400)
|
||||
|
||||
if too_large:
|
||||
if error:
|
||||
return web.json_response(error[0], status=error[1])
|
||||
if not temps or not filename:
|
||||
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
|
||||
|
||||
def _promote() -> str:
|
||||
"""Claim a free name, then move the finished upload onto it.
|
||||
|
||||
Never overwrite an existing attachment: its bytes may be
|
||||
referenced by the stored configuration, and this upload is not
|
||||
part of that transaction — a cancelled dialog or a rejected save
|
||||
would leave the old url serving the new content (HP-1454-02).
|
||||
The name is reserved atomically, so two uploads racing on the
|
||||
same filename cannot agree on it (HP-1460-01).
|
||||
"""
|
||||
name = reserve_filename(target_dir, safe_name)
|
||||
try:
|
||||
os.replace(tmp_path, target_dir / name)
|
||||
except OSError:
|
||||
(target_dir / name).unlink(missing_ok=True)
|
||||
raise
|
||||
return name
|
||||
|
||||
try:
|
||||
name = await hass.async_add_executor_job(_promote)
|
||||
except OSError as err:
|
||||
_LOGGER.warning("House Plan upload: could not store the file: %s", err)
|
||||
return web.json_response({"error": "io_error"}, status=500)
|
||||
temps.remove(tmp_path) # it is the attachment now, not a temporary
|
||||
return web.json_response(
|
||||
{"error": "too_large", "max_mb": MAX_FILE_BYTES // 1024 // 1024}, status=413
|
||||
{"ok": True, "url": f"{CONTENT_URL}/files/{marker_id}/{name}", "name": filename}
|
||||
)
|
||||
if blob is None or not filename:
|
||||
return web.json_response({"error": "no_file"}, status=400)
|
||||
ext = file_ext(filename)
|
||||
if ext not in FILE_EXTENSIONS:
|
||||
return web.json_response(
|
||||
{"error": "bad_ext", "allowed": sorted(FILE_EXTENSIONS)}, status=400
|
||||
)
|
||||
|
||||
safe_name = sanitize_filename(filename)
|
||||
target_dir = Path(hass.config.path(FILES_DIR)) / marker_id
|
||||
path = target_dir / safe_name
|
||||
|
||||
def _write() -> int:
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(blob)
|
||||
return int(path.stat().st_mtime)
|
||||
|
||||
mtime = await hass.async_add_executor_job(_write)
|
||||
return web.json_response(
|
||||
{"ok": True, "url": f"{FILES_URL}/{marker_id}/{safe_name}?v={mtime}", "name": filename}
|
||||
)
|
||||
finally:
|
||||
# BaseException too: cancelling the request task raises
|
||||
# asyncio.CancelledError, which an `except Exception` never saw —
|
||||
# an aborted large upload leaked its temporary file every time
|
||||
if temps:
|
||||
await hass.async_add_executor_job(_cleanup, list(temps))
|
||||
|
||||
@@ -16,5 +16,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.26.0"
|
||||
"version": "1.49.0"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
"""Blob lifecycle — pure, so it is unit-testable without Home Assistant.
|
||||
|
||||
The file system is not part of the configuration store's transaction, so who
|
||||
may write or delete a plan or an attachment, and when, is a correctness
|
||||
question rather than housekeeping. It lives here, apart from the WebSocket and
|
||||
HTTP plumbing, precisely because it is the part that has to be reasoned about
|
||||
and tested.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .const import MIN_FREE_BYTES, PLAN_ORPHAN_TTL_S
|
||||
from .validation import MAX_FILENAME, PLAN_EXTENSIONS, sanitize_filename
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Streaming uploads land here first. The prefix is a dot so the name can never
|
||||
# collide with an attachment (sanitize_filename strips leading dots) and is easy
|
||||
# to sweep.
|
||||
TMP_PREFIX = ".upload-"
|
||||
|
||||
|
||||
def reserve_filename(directory: Path, name: str) -> str:
|
||||
"""Atomically claim a free name inside `directory` and return it.
|
||||
|
||||
Creates the file, empty, with `O_CREAT | O_EXCL`, so the name is *taken* the
|
||||
moment it is chosen. The previous version asked `exists()` and returned a
|
||||
string; two uploads racing between the check and the write agreed on the
|
||||
same name and one silently overwrote the other, both reporting success
|
||||
(HP-1460-01). The caller writes the real bytes over the placeholder — it
|
||||
owns the name by then — and must remove it if it never gets that far.
|
||||
|
||||
The result is guaranteed to satisfy `sanitize_filename(result) == result`:
|
||||
the content view sanitises the name in the request too, so a name it would
|
||||
shorten or rewrite is a file that is written and then never served.
|
||||
"""
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
# Split the extension off the RAW name: sanitize_filename() truncates to
|
||||
# MAX_FILENAME, so sanitising first would cut ".pdf" off a long name and the
|
||||
# attachment would be stored — and served — without its type.
|
||||
base = name.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
|
||||
stem, dot, suffix = base.rpartition(".")
|
||||
if not dot:
|
||||
stem, suffix = base, ""
|
||||
stem = sanitize_filename(stem)
|
||||
ext = f".{sanitize_filename(suffix)[:16]}" if suffix else ""
|
||||
i = 1
|
||||
while True:
|
||||
tag = "" if i == 1 else f"-{i}"
|
||||
# budget the stem so the WHOLE name fits, including the collision tag —
|
||||
# appending "-2" to an already maximal name produced a url the view
|
||||
# truncated back to something else, i.e. a permanent 404
|
||||
room = MAX_FILENAME - len(ext) - len(tag)
|
||||
candidate = (stem[:room] if room > 0 else "f") + tag + ext
|
||||
candidate = sanitize_filename(candidate)
|
||||
if candidate.startswith("."): # a name that is only an extension
|
||||
candidate = "file" + candidate
|
||||
try:
|
||||
fd = os.open(directory / candidate, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
|
||||
except FileExistsError:
|
||||
i += 1
|
||||
if i > 10000: # pathological directory; do not spin forever
|
||||
raise
|
||||
continue
|
||||
os.close(fd)
|
||||
return candidate
|
||||
|
||||
|
||||
def attachment_refs(cfg: dict[str, Any] | None) -> set[str]:
|
||||
""""<marker>/<file>" for every attachment a configuration references."""
|
||||
out: set[str] = set()
|
||||
for m in (cfg or {}).get("markers") or []:
|
||||
for pdf in m.get("pdfs") or []:
|
||||
url = pdf.get("url") if isinstance(pdf, dict) else None
|
||||
if not isinstance(url, str) or "/files/" not in url:
|
||||
continue
|
||||
rel = url.split("?", 1)[0].split("/files/", 1)[1]
|
||||
if rel.count("/") == 1:
|
||||
out.add(rel)
|
||||
return out
|
||||
|
||||
|
||||
def sweep_upload_temps(files_dir: Path, now: float | None = None) -> int:
|
||||
"""Remove abandoned streaming temporaries (HP-1460-02).
|
||||
|
||||
The request itself deletes its own, but a hard kill — a restart mid-upload,
|
||||
an OOM — leaves one behind, and the attachment collector only walks marker
|
||||
folders, so it would never be seen. Age-gated for the same reason as the
|
||||
rest: a fresh one belongs to a request still in flight.
|
||||
"""
|
||||
cutoff = (time.time() if now is None else now) - PLAN_ORPHAN_TTL_S
|
||||
removed = 0
|
||||
try:
|
||||
items = [p for p in files_dir.iterdir() if p.is_file()] if files_dir.is_dir() else []
|
||||
except OSError as err:
|
||||
_LOGGER.warning("House Plan: could not list %s: %s", files_dir, err)
|
||||
return 0
|
||||
for item in items:
|
||||
if not item.name.startswith(TMP_PREFIX):
|
||||
continue
|
||||
try:
|
||||
if item.stat().st_mtime >= cutoff:
|
||||
continue
|
||||
item.unlink()
|
||||
removed += 1
|
||||
except OSError:
|
||||
continue
|
||||
return removed
|
||||
|
||||
|
||||
def collect_attachments(
|
||||
files_dir: Path,
|
||||
old_cfg: dict[str, Any] | None,
|
||||
new_cfg: dict[str, Any],
|
||||
now: float | None = None,
|
||||
) -> int:
|
||||
"""The same commit-scoped rule as `collect_plans`, for marker attachments.
|
||||
|
||||
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)
|
||||
# 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 []
|
||||
except OSError as err:
|
||||
_LOGGER.warning("House Plan: could not list %s: %s", files_dir, err)
|
||||
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:
|
||||
continue
|
||||
for item in items:
|
||||
rel = f"{folder.name}/{item.name}"
|
||||
if rel in new_refs:
|
||||
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
|
||||
except OSError as err:
|
||||
_LOGGER.warning("House Plan: could not remove the attachment %s: %s", item, err)
|
||||
try:
|
||||
next(folder.iterdir())
|
||||
except StopIteration:
|
||||
try:
|
||||
folder.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
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:
|
||||
return ""
|
||||
return url.split("?", 1)[0].rsplit("/", 1)[-1]
|
||||
|
||||
|
||||
def plan_refs(cfg: dict[str, Any] | None) -> set[str]:
|
||||
"""Plan file names a configuration references."""
|
||||
out: set[str] = set()
|
||||
for sp in (cfg or {}).get("spaces") or []:
|
||||
name = plan_basename(sp.get("plan_url"))
|
||||
if name:
|
||||
out.add(name)
|
||||
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(".")
|
||||
return len(parts) in (2, 3) and parts[-1].lower() in PLAN_EXTENSIONS
|
||||
|
||||
|
||||
def collect_plans(
|
||||
plans_dir: Path,
|
||||
old_cfg: dict[str, Any] | None,
|
||||
new_cfg: dict[str, Any],
|
||||
now: float | None = None,
|
||||
) -> int:
|
||||
"""Drop plan files the accepted configuration made obsolete (review R3-1).
|
||||
|
||||
Called inside the config write lock, right after the new revision is
|
||||
stored, so it decides from the two configurations that actually bracket the
|
||||
commit instead of trusting a client to say what may be deleted. The earlier
|
||||
design — a `plan/cleanup` command carrying `keep` — could not be ordered
|
||||
against another client's commit: a delayed call removed the file that
|
||||
client had just saved, leaving the accepted configuration pointing at
|
||||
nothing, which is the damage copy-on-write was introduced to prevent.
|
||||
|
||||
Two rules, both conservative:
|
||||
* 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 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
|
||||
a file-system problem must not turn a durable commit into a failed call.
|
||||
"""
|
||||
new_refs = plan_refs(new_cfg)
|
||||
old_refs = plan_refs(old_cfg)
|
||||
# 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 []
|
||||
except OSError as err:
|
||||
# The directory can vanish or turn unreadable between the check and the
|
||||
# walk. This is housekeeping running behind a commit that is already
|
||||
# durable, so it reports "nothing collected" instead of failing (R4-1).
|
||||
_LOGGER.warning("House Plan: could not list %s: %s", plans_dir, err)
|
||||
return 0
|
||||
for item in items:
|
||||
if not item.is_file() or item.name in new_refs or not is_plan_file(item.name):
|
||||
continue
|
||||
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()
|
||||
removed += 1
|
||||
except OSError as err:
|
||||
_LOGGER.warning("House Plan: could not remove the old plan %s: %s", item, err)
|
||||
return removed
|
||||
@@ -11,7 +11,7 @@ from pathlib import Path
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
|
||||
from .const import DOMAIN, PLANS_DIR, PLANS_URL
|
||||
from .const import CONTENT_URL, DOMAIN, PLANS_DIR, PLANS_URL
|
||||
from .store import HouseplanConfigEntry
|
||||
|
||||
|
||||
@@ -25,9 +25,15 @@ async def async_check_plan_files(hass: HomeAssistant, entry: HouseplanConfigEntr
|
||||
res = []
|
||||
for sp in spaces:
|
||||
url = sp.get("plan_url") or ""
|
||||
if not url.startswith(PLANS_URL + "/"):
|
||||
# both the legacy static URL and the authenticated content URL
|
||||
prefix = None
|
||||
if url.startswith(PLANS_URL + "/"):
|
||||
prefix = PLANS_URL + "/"
|
||||
elif url.startswith(CONTENT_URL + "/plans/_/"):
|
||||
prefix = CONTENT_URL + "/plans/_/"
|
||||
if prefix is None:
|
||||
continue # external/legacy URL — not ours to verify
|
||||
fname = url[len(PLANS_URL) + 1 :].split("?", 1)[0]
|
||||
fname = url[len(prefix) :].split("?", 1)[0]
|
||||
if not (plans_dir / fname).is_file():
|
||||
res.append((sp.get("id", "?"), fname))
|
||||
return res
|
||||
@@ -45,8 +51,17 @@ async def async_check_plan_files(hass: HomeAssistant, entry: HouseplanConfigEntr
|
||||
translation_key="broken_plan",
|
||||
translation_placeholders={"space": space_id, "file": fname},
|
||||
)
|
||||
# clear stale issues for spaces that are fine again (or gone)
|
||||
for sp in spaces:
|
||||
sid = sp.get("id", "?")
|
||||
if sid not in broken:
|
||||
ir.async_delete_issue(hass, DOMAIN, f"broken_plan_{sid}")
|
||||
# Clear stale issues. Iterating the CURRENT spaces could only ever clear
|
||||
# issues for spaces that still exist, so deleting or renaming a space with a
|
||||
# missing plan left its warning in Repairs forever, with nothing left to fix
|
||||
# it (HP-1454-09). Enumerate what we actually published instead.
|
||||
registry = ir.async_get(hass)
|
||||
stale = [
|
||||
issue_id
|
||||
for (domain, issue_id) in list(registry.issues)
|
||||
if domain == DOMAIN
|
||||
and issue_id.startswith("broken_plan_")
|
||||
and issue_id[len("broken_plan_") :] not in broken
|
||||
]
|
||||
for issue_id in stale:
|
||||
ir.async_delete_issue(hass, DOMAIN, issue_id)
|
||||
|
||||
@@ -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,11 @@ 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)
|
||||
# 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]
|
||||
|
||||
@@ -16,6 +16,9 @@ MAX_FILE_BYTES = 50 * 1024 * 1024
|
||||
|
||||
SPACE_ID_RE = re.compile(r"^[a-z0-9_-]{1,64}$")
|
||||
_SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._-]+")
|
||||
# The name length the content view will accept back in a request. Anything a
|
||||
# generated name must fit inside, collision tag included (HP-1460-01).
|
||||
MAX_FILENAME = 120
|
||||
|
||||
# ---------- sanitizers ----------
|
||||
|
||||
@@ -33,7 +36,7 @@ def sanitize_marker_id(value: str) -> str:
|
||||
def sanitize_filename(value: str) -> str:
|
||||
"""Drop the path and leading dots, keep a safe file name."""
|
||||
raw = value.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
|
||||
return _SAFE_NAME_RE.sub("_", raw).lstrip(".")[:120] or "file"
|
||||
return _SAFE_NAME_RE.sub("_", raw).lstrip(".")[:MAX_FILENAME] or "file"
|
||||
|
||||
|
||||
def file_ext(filename: str) -> str:
|
||||
@@ -47,13 +50,58 @@ def valid_space_id(value: str) -> bool:
|
||||
|
||||
|
||||
# ---------- voluptuous schemas ----------
|
||||
def _finite(value):
|
||||
"""Coerce to float and reject NaN/Infinity (audit B5).
|
||||
|
||||
'NaN' and 'Infinity' pass Coerce(float) and serialize to null on write,
|
||||
silently corrupting a stored position forever.
|
||||
"""
|
||||
f = float(value)
|
||||
if f != f or f in (float("inf"), float("-inf")):
|
||||
raise vol.Invalid("coordinate must be a finite number")
|
||||
return f
|
||||
|
||||
|
||||
# generous caps: the product targets 20-200 devices and a handful of floors
|
||||
MAX_SPACES = 50
|
||||
MAX_ROOMS = 400
|
||||
MAX_MARKERS = 2000
|
||||
MAX_OPENINGS = 500
|
||||
MAX_DECOR = 1000
|
||||
MAX_LAYOUT = 5000
|
||||
# Inner limits (HP-1454-05). The outer collections were capped, the collections
|
||||
# INSIDE them were not: a 150 000-point polygon or a 100 000-entry known_devices
|
||||
# list passed validation, then made the card build gigantic SVG attributes and
|
||||
# walk them on every render. Any authenticated writer could store one, and with
|
||||
# `admin_only` off that is every user. These are product limits, not guesses: a
|
||||
# hand-drawn room does not need 500 vertices, and no home has 200 lights behind
|
||||
# one switch.
|
||||
MAX_POLY_POINTS = 500
|
||||
MAX_OPEN_TO = 50
|
||||
MAX_CONTROLS = 200
|
||||
MAX_PDFS = 50
|
||||
MAX_KNOWN_DEVICES = 20000
|
||||
MAX_TEXT = 500 # names, models, ids
|
||||
MAX_DESCRIPTION = 4000
|
||||
MAX_URL = 2000
|
||||
# Comfortably below the WebSocket frame limit (aiohttp's default is 4 MB): a
|
||||
# payload larger than the frame never reaches the handler at all — the socket
|
||||
# closes with 1009 and the user sees a dropped connection instead of an error
|
||||
# they can act on. For scale, a real three-floor home with ~200 devices stores
|
||||
# about 70 KB, so this is ~30x headroom.
|
||||
MAX_CONFIG_BYTES = 2 * 1024 * 1024
|
||||
|
||||
_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))
|
||||
|
||||
POS_SCHEMA = vol.Schema(
|
||||
{vol.Required("x"): vol.Coerce(float), vol.Required("y"): vol.Coerce(float)},
|
||||
{vol.Required("x"): _finite, vol.Required("y"): _finite},
|
||||
extra=vol.ALLOW_EXTRA, # v2 records carry the "s" key (space id)
|
||||
)
|
||||
LAYOUT_SCHEMA = vol.Schema({str: POS_SCHEMA})
|
||||
LAYOUT_SCHEMA = vol.All(vol.Schema({str: POS_SCHEMA}), vol.Length(max=MAX_LAYOUT))
|
||||
|
||||
POINT = vol.All([vol.Coerce(float)], vol.Length(min=2, max=2))
|
||||
POINT = vol.All([_finite], vol.Length(min=2, max=2))
|
||||
|
||||
|
||||
def _require_geometry(room: dict) -> dict:
|
||||
@@ -65,14 +113,28 @@ def _require_geometry(room: dict) -> dict:
|
||||
ROOM_SCHEMA = vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required("id"): str,
|
||||
vol.Required("name"): str,
|
||||
vol.Optional("area"): vol.Any(str, None),
|
||||
vol.Optional("x"): vol.Coerce(float),
|
||||
vol.Optional("y"): vol.Coerce(float),
|
||||
vol.Optional("w"): vol.Coerce(float),
|
||||
vol.Optional("h"): vol.Coerce(float),
|
||||
vol.Optional("poly"): vol.All([POINT], vol.Length(min=3)),
|
||||
vol.Required("id"): _TEXT,
|
||||
vol.Required("name"): _TEXT,
|
||||
vol.Optional("area"): _TEXT_OR_NONE,
|
||||
vol.Optional("open_to"): vol.All([_TEXT], vol.Length(max=MAX_OPEN_TO)),
|
||||
vol.Optional("settings"): vol.Any(
|
||||
None,
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional("fill_mode"): vol.Any(None, vol.In(["none", "lqi", "light", "temp"])),
|
||||
vol.Optional("temp_source"): vol.Any(str, None),
|
||||
vol.Optional("hum_source"): vol.Any(str, None),
|
||||
vol.Optional("name_scale"): vol.Any(None, vol.All(vol.Coerce(float), vol.Range(min=0.5, max=3))),
|
||||
vol.Optional("label_scale"): vol.Any(None, vol.All(vol.Coerce(float), vol.Range(min=0.5, max=3))),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
),
|
||||
),
|
||||
vol.Optional("x"): _finite,
|
||||
vol.Optional("y"): _finite,
|
||||
vol.Optional("w"): _finite,
|
||||
vol.Optional("h"): _finite,
|
||||
vol.Optional("poly"): vol.All([POINT], vol.Length(min=3, max=MAX_POLY_POINTS)),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
),
|
||||
@@ -84,31 +146,68 @@ SPACE_DISPLAY_SCHEMA = vol.Schema(
|
||||
vol.Optional("show_names"): bool,
|
||||
vol.Optional("room_color"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
|
||||
vol.Optional("room_opacity"): vol.All(vol.Coerce(float), vol.Range(min=0, max=1)),
|
||||
vol.Optional("fill_mode"): vol.In(["none", "lqi", "light", "temp"]),
|
||||
vol.Optional("fill_mode"): vol.In(["none", "lqi", "light", "temp", "glow"]),
|
||||
vol.Optional("temp_min"): vol.Coerce(float),
|
||||
vol.Optional("temp_max"): vol.Coerce(float),
|
||||
vol.Optional("show_lqi"): bool,
|
||||
vol.Optional("label_temp"): bool,
|
||||
vol.Optional("label_hum"): bool,
|
||||
vol.Optional("label_lqi"): bool,
|
||||
vol.Optional("label_light"): bool,
|
||||
vol.Optional("card_font_scale"): vol.All(vol.Coerce(float), vol.Range(min=0.5, max=3)),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
_DECOR_COMMON = {
|
||||
vol.Required("id"): str,
|
||||
vol.Optional("color"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
|
||||
vol.Optional("width"): vol.All(vol.Coerce(float), vol.Range(min=0.1, max=30)),
|
||||
}
|
||||
_NORM = vol.All(vol.Coerce(float), vol.Range(min=-1, max=2))
|
||||
DECOR_SCHEMA = vol.Any(
|
||||
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): "line",
|
||||
vol.Required("x1"): _NORM, vol.Required("y1"): _NORM,
|
||||
vol.Required("x2"): _NORM, vol.Required("y2"): _NORM},
|
||||
extra=vol.ALLOW_EXTRA),
|
||||
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): vol.In(["rect", "ellipse"]),
|
||||
vol.Required("x"): _NORM, vol.Required("y"): _NORM,
|
||||
vol.Required("w"): _NORM, vol.Required("h"): _NORM,
|
||||
vol.Optional("fill"): bool},
|
||||
extra=vol.ALLOW_EXTRA),
|
||||
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): "text",
|
||||
vol.Required("x"): _NORM, vol.Required("y"): _NORM,
|
||||
vol.Required("text"): vol.All(str, vol.Length(min=1, max=200)),
|
||||
vol.Optional("size"): vol.In(["s", "m", "l"])},
|
||||
extra=vol.ALLOW_EXTRA),
|
||||
)
|
||||
|
||||
SPACE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("id"): str,
|
||||
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([vol.Coerce(float)], vol.Length(min=4, max=4)),
|
||||
vol.Required("rooms"): [ROOM_SCHEMA],
|
||||
vol.Optional("openings"): [
|
||||
# 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"): vol.All([_finite], vol.Length(min=4, max=4)),
|
||||
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([
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required("id"): str,
|
||||
vol.Required("type"): vol.Any("door", "window"),
|
||||
vol.Required("x"): vol.Coerce(float),
|
||||
vol.Required("y"): vol.Coerce(float),
|
||||
vol.Required("angle"): vol.Coerce(float),
|
||||
vol.Required("x"): _finite,
|
||||
vol.Required("y"): _finite,
|
||||
vol.Required("angle"): _finite,
|
||||
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),
|
||||
@@ -118,11 +217,14 @@ SPACE_SCHEMA = vol.Schema(
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
],
|
||||
], vol.Length(max=MAX_OPENINGS)),
|
||||
# Legacy: walls are derived from room outlines since v1.19.0 — a line has no
|
||||
# independent existence. Still accepted so a stale browser tab cannot fail a save;
|
||||
# the card strips the field on every write.
|
||||
vol.Optional("segments"): [vol.All([vol.Coerce(float)], vol.Length(min=4, max=4))],
|
||||
# Accepted so a stale browser tab cannot fail a save, then DROPPED here
|
||||
# (HP-1454-05): relying on a modern client to strip an unbounded legacy
|
||||
# list is not a limit, it is a hope. `Remove` returns the key stripped.
|
||||
vol.Remove("segments"): object,
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
@@ -134,29 +236,39 @@ MARKER_SCHEMA = vol.Schema(
|
||||
vol.Optional("space"): vol.Any(str, None),
|
||||
vol.Optional("area"): vol.Any(str, None),
|
||||
vol.Optional("hidden"): bool,
|
||||
vol.Optional("name"): vol.Any(str, None),
|
||||
vol.Optional("icon"): vol.Any(str, None),
|
||||
vol.Optional("model"): vol.Any(str, None),
|
||||
vol.Optional("link"): vol.Any(str, None),
|
||||
vol.Optional("description"): vol.Any(str, None),
|
||||
vol.Optional("name"): _TEXT_OR_NONE,
|
||||
vol.Optional("icon"): _TEXT_OR_NONE,
|
||||
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("display"): vol.Any("badge", "ripple", "icon_ripple", 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),
|
||||
vol.Optional("room_id"): vol.Any(str, None),
|
||||
# keep in sync with DISPLAY_MODES in src/logic.ts — a cross-language test
|
||||
# asserts every option the editor offers is accepted here (issue #3)
|
||||
vol.Optional("display"): vol.Any("badge", "ripple", "icon_ripple", "value", None),
|
||||
vol.Optional("ripple_color"): vol.Any(str, None),
|
||||
vol.Optional("ripple_size"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=1, max=20)), None),
|
||||
vol.Optional("size"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=0.2, max=6)), None),
|
||||
vol.Optional("angle"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=-360, max=360)), None),
|
||||
vol.Optional("pdfs"): [
|
||||
vol.Schema({vol.Required("name"): str, vol.Required("url"): str}, extra=vol.ALLOW_EXTRA)
|
||||
],
|
||||
vol.Optional("pdfs"): vol.All(
|
||||
[vol.Schema({vol.Required("name"): _TEXT, vol.Required("url"): _URL}, extra=vol.ALLOW_EXTRA)],
|
||||
vol.Length(max=MAX_PDFS),
|
||||
),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("spaces"): [SPACE_SCHEMA],
|
||||
vol.Optional("markers", default=list): [MARKER_SCHEMA],
|
||||
vol.Required("spaces"): vol.All([SPACE_SCHEMA], vol.Length(max=MAX_SPACES)),
|
||||
vol.Optional("markers", default=list): vol.All([MARKER_SCHEMA], vol.Length(max=MAX_MARKERS)),
|
||||
vol.Optional("settings", default=dict): vol.Schema(
|
||||
{
|
||||
vol.Optional("glow_radius_cm"): vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)),
|
||||
vol.Optional("known_devices"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
|
||||
vol.Optional("new_device_ids"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
|
||||
vol.Optional("fill_colors"): vol.Schema(
|
||||
{
|
||||
str: vol.Schema(
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
"""House Plan WS commands: layout, space configuration, plan uploads."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -13,15 +17,25 @@ from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
from .const import (
|
||||
CONF_ADMIN_ONLY, DEFAULT_CONFIG,
|
||||
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 (
|
||||
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_PLAN_BYTES,
|
||||
PLAN_EXTENSIONS, POS_SCHEMA, valid_space_id,
|
||||
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_CONFIG_BYTES, MAX_PLAN_BYTES,
|
||||
PLAN_EXTENSIONS, POS_SCHEMA, sanitize_filename, valid_space_id,
|
||||
)
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@callback
|
||||
def async_register(hass: HomeAssistant) -> None:
|
||||
"""Register the WS commands."""
|
||||
@@ -32,6 +46,11 @@ def async_register(hass: HomeAssistant) -> None:
|
||||
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)
|
||||
|
||||
|
||||
def _runtime(hass: HomeAssistant, connection, msg_id: int) -> HouseplanData | None:
|
||||
@@ -48,9 +67,8 @@ def _runtime(hass: HomeAssistant, connection, msg_id: int) -> HouseplanData | No
|
||||
|
||||
|
||||
def _check_write(hass: HomeAssistant, connection) -> bool:
|
||||
entry = get_entry(hass)
|
||||
admin_only = bool(entry and entry.options.get(CONF_ADMIN_ONLY, False))
|
||||
return connection.user.is_admin if admin_only else True
|
||||
"""May this connection write? Thin wrapper over the shared policy."""
|
||||
return may_write(hass, getattr(connection, "user", None))
|
||||
|
||||
|
||||
# ---------------- layout ----------------
|
||||
@@ -64,15 +82,27 @@ async def ws_layout_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
if rt is None:
|
||||
return
|
||||
data = await rt.store.async_load() or {}
|
||||
connection.send_result(msg["id"], {"layout": data.get("layout", {})})
|
||||
connection.send_result(
|
||||
msg["id"], {"layout": data.get("layout", {}), "rev": int(data.get("rev", 0))}
|
||||
)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{vol.Required("type"): "houseplan/layout/set", vol.Required("layout"): LAYOUT_SCHEMA}
|
||||
{
|
||||
vol.Required("type"): "houseplan/layout/set",
|
||||
vol.Required("layout"): LAYOUT_SCHEMA,
|
||||
vol.Optional("expected_rev"): int,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Replace the layout entirely."""
|
||||
"""Replace the layout entirely, with optimistic locking (audit B3).
|
||||
|
||||
Wholesale layout writes used to have no revision check at all, so two
|
||||
clients silently overwrote each other. `expected_rev` is optional for
|
||||
backwards compatibility with older cards, but when supplied it is enforced
|
||||
exactly like the config store does.
|
||||
"""
|
||||
if not _check_write(hass, connection):
|
||||
connection.send_error(msg["id"], "unauthorized", "Only administrators may edit the layout")
|
||||
return
|
||||
@@ -80,8 +110,17 @@ async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
if rt is None:
|
||||
return
|
||||
async with rt.write_lock:
|
||||
await rt.store.async_save({"layout": msg["layout"]})
|
||||
connection.send_result(msg["id"], {"ok": True})
|
||||
data = await rt.store.async_load() or {}
|
||||
current_rev = int(data.get("rev", 0))
|
||||
if "expected_rev" in msg and msg["expected_rev"] != current_rev:
|
||||
connection.send_error(
|
||||
msg["id"], "conflict", f"Layout changed elsewhere (rev {current_rev})"
|
||||
)
|
||||
return
|
||||
new_rev = current_rev + 1
|
||||
await rt.store.async_save({"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})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
@@ -104,8 +143,294 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
data = await rt.store.async_load() or {}
|
||||
layout = data.get("layout", {})
|
||||
layout[msg["device_id"]] = msg["pos"]
|
||||
await rt.store.async_save({"layout": layout})
|
||||
connection.send_result(msg["id"], {"ok": True})
|
||||
# keep the revision: a point-wise write used to drop it, which made the
|
||||
# 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})
|
||||
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/files/migrate",
|
||||
vol.Required("from_id"): str,
|
||||
vol.Required("to_id"): str,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_files_migrate(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""COPY a marker's uploaded files to its new id and report the exact mapping.
|
||||
|
||||
Rebinding changes the marker id, so the files must follow (that is how the
|
||||
owner lost a set of manuals, 2026-07-26). This used to MOVE them before the
|
||||
revision-checked config save: when that save was rejected, the server kept
|
||||
the old urls while the files had already left the old folder — a permanent
|
||||
broken link (review CR-2, 2026-07-27).
|
||||
|
||||
Now it copies, never overwrites, and returns {src: dst} for every file so
|
||||
the client can rewrite EXACTLY the urls that made it (review CR-3). The old
|
||||
folder is removed later by houseplan/files/cleanup, once the config is
|
||||
safely committed.
|
||||
"""
|
||||
if not _check_write(hass, connection):
|
||||
connection.send_error(msg["id"], "unauthorized", "Only administrators may edit files")
|
||||
return
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from .const import FILES_DIR
|
||||
from .validation import sanitize_marker_id
|
||||
|
||||
src_id = sanitize_marker_id(msg["from_id"])
|
||||
dst_id = sanitize_marker_id(msg["to_id"])
|
||||
if not src_id or not dst_id or src_id == dst_id:
|
||||
connection.send_result(msg["id"], {"ok": True, "mapping": {}, "copied": 0})
|
||||
return
|
||||
base = Path(hass.config.path(FILES_DIR))
|
||||
src = base / src_id
|
||||
dst = base / dst_id
|
||||
|
||||
def _copy() -> dict[str, str]:
|
||||
if not src.is_dir():
|
||||
return {}
|
||||
dst.mkdir(parents=True, exist_ok=True)
|
||||
mapping: dict[str, str] = {}
|
||||
for f in sorted(src.iterdir()):
|
||||
if not f.is_file():
|
||||
continue
|
||||
# a different file may already own this name — do NOT silently point
|
||||
# the url at it. The shared helper CLAIMS a free one atomically, so
|
||||
# a concurrent migrate or upload cannot pick the same one, and the
|
||||
# name it returns is one the content view accepts back in a request.
|
||||
name = reserve_filename(dst, f.name)
|
||||
target = dst / name
|
||||
try:
|
||||
shutil.copy2(str(f), str(target))
|
||||
except OSError:
|
||||
target.unlink(missing_ok=True) # never leave an empty placeholder
|
||||
raise
|
||||
mapping[f.name] = name
|
||||
return mapping
|
||||
|
||||
try:
|
||||
mapping = await hass.async_add_executor_job(_copy)
|
||||
except OSError as err:
|
||||
connection.send_error(msg["id"], "io_error", f"Could not copy marker files: {err}")
|
||||
return
|
||||
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",
|
||||
vol.Required("paths"): [str],
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_content_sign(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Sign content paths so the BROWSER can fetch them.
|
||||
|
||||
Home Assistant authenticates HTTP requests by a Bearer header or an
|
||||
`authSig` signed path — there is no cookie auth. An <image href> inside SVG
|
||||
and a plain <a href> can send neither, so after the content endpoint became
|
||||
`requires_auth` the plan backgrounds and PDF links returned 401 (audit
|
||||
follow-up B1 regression, 2026-07-27 — reproduced live).
|
||||
|
||||
The card asks for signatures and uses the signed urls for display.
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
from homeassistant.components.http.auth import async_sign_path
|
||||
|
||||
out: dict[str, str] = {}
|
||||
token_id = getattr(connection, "refresh_token_id", None)
|
||||
for path in msg["paths"][:MAX_SIGN_PATHS]:
|
||||
if not isinstance(path, str) or not path.startswith(CONTENT_URL + "/"):
|
||||
continue # only ever sign our own content endpoint
|
||||
clean = path.split("?", 1)[0]
|
||||
try:
|
||||
try:
|
||||
signed = async_sign_path(hass, clean, timedelta(hours=24), refresh_token_id=token_id)
|
||||
except TypeError: # older HA signature: (hass, refresh_token_id, path, expiration)
|
||||
signed = async_sign_path(hass, token_id, clean, timedelta(hours=24))
|
||||
except Exception as err: # noqa: BLE001 — signing must never break the card
|
||||
_LOGGER.warning("House Plan: could not sign %s: %s", clean, err)
|
||||
continue
|
||||
out[path] = signed
|
||||
connection.send_result(msg["id"], {"urls": out})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/files/cleanup",
|
||||
vol.Required("marker_id"): str,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_files_cleanup(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""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
|
||||
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"])
|
||||
base = Path(hass.config.path(FILES_DIR)).resolve()
|
||||
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
|
||||
|
||||
async with rt.write_lock:
|
||||
stored = await rt.config_store.async_load() or {}
|
||||
refs = attachment_refs(stored.get("config") or {})
|
||||
|
||||
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(
|
||||
@@ -123,13 +448,17 @@ async def ws_layout_delete(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
new_rev: int | None = None
|
||||
async with rt.write_lock:
|
||||
data = await rt.store.async_load() or {}
|
||||
layout = data.get("layout", {})
|
||||
if msg["device_id"] in layout:
|
||||
del layout[msg["device_id"]]
|
||||
await rt.store.async_save({"layout": layout})
|
||||
connection.send_result(msg["id"], {"ok": True})
|
||||
new_rev = int(data.get("rev", 0)) + 1
|
||||
await rt.store.async_save({"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})
|
||||
|
||||
|
||||
# ---------------- space configuration ----------------
|
||||
@@ -147,6 +476,47 @@ async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
connection.send_result(msg["id"], {"config": config, "rev": data.get("rev", 0)})
|
||||
|
||||
|
||||
|
||||
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",
|
||||
@@ -168,17 +538,67 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
# Per-field limits bound each list; this bounds their product (HP-1454-05).
|
||||
# Everything below the caps can still add up to something no dashboard can
|
||||
# render, and the store writes it to disk on every save.
|
||||
size = len(json.dumps(msg["config"], separators=(",", ":")))
|
||||
if size > MAX_CONFIG_BYTES:
|
||||
connection.send_error(
|
||||
msg["id"], "too_large",
|
||||
f"Configuration is {size // 1024} KB, the limit is {MAX_CONFIG_BYTES // 1024} KB",
|
||||
)
|
||||
return
|
||||
async with rt.write_lock:
|
||||
data = await rt.config_store.async_load() or {}
|
||||
current_rev = data.get("rev", 0)
|
||||
if "expected_rev" not in msg and current_rev:
|
||||
# audit B4: expected_rev stays optional for old cards mid-upgrade,
|
||||
# but a blind overwrite of a non-empty store is worth a warning —
|
||||
# it is exactly how a stale client silently discards someone's work.
|
||||
_LOGGER.warning(
|
||||
"House Plan: config/set without expected_rev over rev %s — "
|
||||
"the client bypasses conflict detection (outdated card?)",
|
||||
current_rev,
|
||||
)
|
||||
if "expected_rev" in msg and msg["expected_rev"] != current_rev:
|
||||
connection.send_error(
|
||||
msg["id"], "conflict",
|
||||
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
|
||||
# transaction, so collection has to be pinned to this commit (R3-1).
|
||||
# It is best-effort housekeeping behind an already durable write — a
|
||||
# failure here must not withhold the event and the success response,
|
||||
# or the client retries an edit the server has already accepted and
|
||||
# gets a conflict for its trouble (R4-1).
|
||||
def _collect() -> None:
|
||||
collect_plans(Path(hass.config.path(PLANS_DIR)), data.get("config"), msg["config"])
|
||||
collect_attachments(Path(hass.config.path(FILES_DIR)), data.get("config"), msg["config"])
|
||||
|
||||
try:
|
||||
await hass.async_add_executor_job(_collect)
|
||||
except Exception: # noqa: BLE001 — see above: the commit stands regardless
|
||||
_LOGGER.exception("House Plan: collecting superseded files failed")
|
||||
hass.bus.async_fire("houseplan_config_updated", {"rev": new_rev})
|
||||
# refresh repair issues (broken plan references) without waiting for a restart
|
||||
entry = get_entry(hass)
|
||||
@@ -219,20 +639,33 @@ async def ws_plan_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> N
|
||||
connection.send_error(msg["id"], "too_large", f"Plan is larger than {MAX_PLAN_BYTES // 1024 // 1024} MB")
|
||||
return
|
||||
|
||||
# Copy-on-write: a plan is written under a NEW unique name and nothing is
|
||||
# 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 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
|
||||
# files of a differently named space.
|
||||
plans_dir = Path(hass.config.path(PLANS_DIR))
|
||||
path = plans_dir / f"{space_id}.{msg['ext']}"
|
||||
try:
|
||||
await hass.async_add_executor_job(
|
||||
check_quota, plans_dir, len(raw), MAX_PLANS_BYTES, MAX_PLANS_FILES
|
||||
)
|
||||
except QuotaError as err:
|
||||
connection.send_error(msg["id"], err.reason, err.detail)
|
||||
return
|
||||
name = f"{space_id}.{secrets.token_hex(4)}.{msg['ext']}"
|
||||
path = plans_dir / name
|
||||
|
||||
def _write() -> int:
|
||||
def _write() -> None:
|
||||
plans_dir.mkdir(parents=True, exist_ok=True)
|
||||
# remove old variants with a different extension
|
||||
for old_ext in PLAN_EXTENSIONS:
|
||||
old = plans_dir / f"{space_id}.{old_ext}"
|
||||
if old_ext != msg["ext"] and old.exists():
|
||||
old.unlink()
|
||||
path.write_bytes(raw)
|
||||
return int(path.stat().st_mtime)
|
||||
|
||||
mtime = await hass.async_add_executor_job(_write)
|
||||
connection.send_result(
|
||||
msg["id"], {"ok": True, "url": f"{PLANS_URL}/{space_id}.{msg['ext']}?v={mtime}"}
|
||||
)
|
||||
await hass.async_add_executor_job(_write)
|
||||
connection.send_result(msg["id"], {"ok": True, "url": f"{CONTENT_URL}/plans/_/{name}"})
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const calls = [];
|
||||
c.hass = { ...c.hass, callService: (d, s, data) => { calls.push([d, s, data.entity_id]); return Promise.resolve(); } };
|
||||
// glow на текущем пространстве
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== c._space ? s : ({
|
||||
...s, settings: { ...(s.settings || {}), fill_mode: 'glow' } })) };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
out.mode = c._mode;
|
||||
out.suppress = c._suppressClick;
|
||||
out.holdFired = c._holdFired;
|
||||
out.drag = !!c._drag;
|
||||
// реальный клик по включённой лампе (элемент .dev)
|
||||
const litDev = c._devices.find((d) => d.space === c._space && d.entities.some((e) => e.startsWith('light.') && c.hass.states[e]?.state === 'on'));
|
||||
out.hasLit = !!litDev;
|
||||
const el = [...sr().querySelectorAll('.dev')].find((e) => e.textContent.includes(litDev.name) || true);
|
||||
// найдём элемент точно: по индексу устройства
|
||||
const devEls = [...sr().querySelectorAll('.dev')];
|
||||
out.devCount = devEls.length;
|
||||
const target = devEls[c._devices.filter((d) => d.space === c._space).indexOf(litDev)] || devEls[0];
|
||||
const before = calls.length;
|
||||
c._infoCard = null;
|
||||
// эмулируем полный цикл: pointerdown/up + click
|
||||
const opts = { bubbles: true, composed: true };
|
||||
target.dispatchEvent(new PointerEvent('pointerdown', { ...opts, pointerId: 5 }));
|
||||
target.dispatchEvent(new PointerEvent('pointerup', { ...opts, pointerId: 5 }));
|
||||
target.dispatchEvent(new MouseEvent('click', opts));
|
||||
await c.updateComplete;
|
||||
out.reaction = calls.length > before ? 'service:' + JSON.stringify(calls.at(-1)) : c._infoCard ? 'info' : 'NOTHING';
|
||||
out.suppressAfter = c._suppressClick;
|
||||
// и через прямой вызов
|
||||
c._infoCard = null;
|
||||
c._clickDevice(new MouseEvent('click'), litDev);
|
||||
await c.updateComplete;
|
||||
out.directReaction = calls.length > before + 1 ? 'service' : c._infoCard ? 'info' : 'NOTHING';
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
+36
-1
@@ -6,10 +6,45 @@ import { fileURLToPath } from 'node:url';
|
||||
import { dirname } from 'node:path';
|
||||
const ROOT = dirname(fileURLToPath(import.meta.url)) + '/srv';
|
||||
const CT = { '.html': 'text/html', '.js': 'text/javascript', '.svg': 'image/svg+xml' };
|
||||
// ---- assertion harness (audit T1) --------------------------------------
|
||||
// Until 2026-07-27 the smokes printed booleans and always exited 0: a broken
|
||||
// build reported success. `check()` accumulates named failures, `finish()`
|
||||
// prints them and sets the exit code.
|
||||
const _failures = [];
|
||||
let _pageErrors = 0;
|
||||
|
||||
/** Assert one named fact. `expected` defaults to true. */
|
||||
export function check(name, actual, expected = true) {
|
||||
const ok = JSON.stringify(actual) === JSON.stringify(expected);
|
||||
if (!ok) _failures.push(`${name}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
||||
return ok;
|
||||
}
|
||||
|
||||
/** Assert a whole result object: every key must equal true unless listed. */
|
||||
export function checkAll(out, expected = {}) {
|
||||
for (const [k, v] of Object.entries(out)) check(k, v, k in expected ? expected[k] : true);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Print the result, report failures, close the browser, set the exit code. */
|
||||
export async function finish(browser, out) {
|
||||
if (out !== undefined) console.log(JSON.stringify(out, null, 1));
|
||||
if (_pageErrors) _failures.push(`${_pageErrors} uncaught exception(s) inside the card`);
|
||||
await browser?.close?.();
|
||||
if (_failures.length) {
|
||||
console.error('\nFAILED (' + _failures.length + '):');
|
||||
for (const f of _failures) console.error(' - ' + f);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log('OK');
|
||||
}
|
||||
}
|
||||
|
||||
export async function launch(viewport = { width: 820, height: 760 }, scale = 1) {
|
||||
const browser = await chromium.launch({ args: ['--no-sandbox'] });
|
||||
const page = await (await browser.newContext({ viewport, deviceScaleFactor: scale })).newPage();
|
||||
page.on('pageerror', (e) => console.log('EXC', e.message));
|
||||
// audit T1: an exception inside the card used to be logged and ignored
|
||||
page.on('pageerror', (e) => { _pageErrors++; console.log('EXC', e.message); });
|
||||
await page.route('**/*', (r) => {
|
||||
const u = new URL(r.request().url());
|
||||
let p = decodeURIComponent(u.pathname);
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const g = c._gridPitch;
|
||||
const guides = () => sr().querySelectorAll('.alignline').length;
|
||||
// 1) рисование контура: курсор на одном X с вершиной комнаты → гид
|
||||
c._setMode('plan'); c._tool = 'draw'; await c.updateComplete;
|
||||
const r1 = c._spaceModel().rooms.find((r) => r.id === 'r1');
|
||||
const poly = 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 v = poly[0];
|
||||
c._path = [[v[0] + g * 10, v[1] + g * 10]];
|
||||
c._cursorPt = [v[0], v[1] + g * 20]; // тот же X, что у вершины
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
out.drawGuide = guides() >= 1;
|
||||
out.dot = sr().querySelectorAll('.aligndot').length >= 1;
|
||||
// нет выравнивания → нет гидов
|
||||
c._cursorPt = [v[0] + g * 7 + 1.7, v[1] + g * 13 + 2.3]; c.requestUpdate(); await c.updateComplete;
|
||||
out.noGuideOffAxis = guides() === 0;
|
||||
// 2) бейдж угла: 45° красит
|
||||
c._cursorPt = [c._path[0][0] + g * 5, c._path[0][1] + g * 5]; c.requestUpdate(); await c.updateComplete;
|
||||
const ml = sr().querySelector('.measurelabel');
|
||||
out.badge45 = !!ml && ml.classList.contains('on45') && ml.textContent.includes('45');
|
||||
c._cursorPt = [c._path[0][0] + g * 5, c._path[0][1] + g * 2]; c.requestUpdate(); await c.updateComplete;
|
||||
const ml2 = sr().querySelector('.measurelabel');
|
||||
out.badgeOff45 = !!ml2 && !ml2.classList.contains('on45') && ml2.textContent.includes('°');
|
||||
c._path = []; c._cursorPt = null;
|
||||
// 3) редактор устройств: drag значка в линию с другим значком
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
const devs = c._devices.filter((d) => d.space === c._space);
|
||||
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._drag = { id: b.id, sx: 0, sy: 0, ox: 0, oy: 0, moved: true };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
out.devGuide = guides() >= 1;
|
||||
c._drag = null; c.requestUpdate(); await c.updateComplete;
|
||||
out.devGuideGone = guides() === 0;
|
||||
// 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; // 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;
|
||||
c._decorDraft = null; c._curSpaceCfg.decor = []; c.requestUpdate(); await c.updateComplete;
|
||||
// 5) в Просмотре гидов не бывает
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
out.noneInView = guides() === 0;
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -0,0 +1,50 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
// новый маркер: радио «Виртуальное» по умолчанию, списка нет
|
||||
c._openMarkerDialog(); await c.updateComplete;
|
||||
const radios = () => [...sr().querySelectorAll('.bindsel input[type="radio"]')];
|
||||
out.twoRadios = radios().length === 2;
|
||||
out.virtDefault = radios()[0].checked && !radios()[1].checked;
|
||||
out.noDropWhenVirtual = !sr().querySelector('.dropbtn');
|
||||
// переключить на «Из списка HA» → появляется дропдаун, открыт (нет выбора), сейв заблокирован
|
||||
radios()[1].click(); await c.updateComplete;
|
||||
out.dropShown = !!sr().querySelector('.dropbtn');
|
||||
out.panelOpen = !!sr().querySelector('.droppanel');
|
||||
const saveBtn = [...sr().querySelectorAll('.dialog .btn.on')].pop();
|
||||
out.saveDisabled = saveBtn?.disabled === true;
|
||||
// без чекбокса в списке нет individual-сущностей устройств
|
||||
const subs = () => [...sr().querySelectorAll('.cand .cs')].map((e) => e.textContent);
|
||||
const entLabel = c._t('marker.sub_entity');
|
||||
out.noDeviceEntities = !subs().some((s) => s.includes(entLabel));
|
||||
// (группы/хелперы в демо все уже размещены и потому скрыты как занятые —
|
||||
// их «всегда в списке» проверяется кодом: блок вне чекбокса)
|
||||
// включить чекбокс → сущности появились
|
||||
const cb = sr().querySelector('.entcheck input');
|
||||
cb.click(); await c.updateComplete;
|
||||
out.entitiesShown = [...sr().querySelectorAll('.cand .cs')].some((s) => s.textContent.includes(entLabel));
|
||||
out.tooltip = sr().querySelector('.entcheck').getAttribute('title') === c._t('marker.show_entities_tip');
|
||||
// выбрать первый кандидат → панель закрылась, сейв разблокирован
|
||||
sr().querySelector('.cand').click(); await c.updateComplete;
|
||||
out.picked = c._markerDialog.binding !== '' && c._markerDialog.binding !== 'virtual';
|
||||
out.panelClosed = !sr().querySelector('.droppanel');
|
||||
out.saveEnabled = [...sr().querySelectorAll('.dialog .btn.on')].pop()?.disabled === false;
|
||||
// радио назад на «Виртуальное» → binding=virtual
|
||||
radios()[0].click(); await c.updateComplete;
|
||||
out.backToVirtual = c._markerDialog.binding === 'virtual' && c._markerDialog.bindingMode === 'virtual';
|
||||
c._markerDialog = null; await c.updateComplete;
|
||||
// редактирование существующего устройства: радио «Из списка», выбранная привязка в кнопке
|
||||
const dev = c._devices.find((d) => !d.virtual && d.bindingKind === 'device');
|
||||
c._openMarkerDialog(dev); await c.updateComplete;
|
||||
out.editHaMode = c._markerDialog.bindingMode === 'ha';
|
||||
out.editShowsCur = sr().querySelector('.dropbtn b') !== null;
|
||||
out.editPanelClosed = !sr().querySelector('.droppanel');
|
||||
c._markerDialog = null;
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -0,0 +1,44 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const calls = [];
|
||||
c.hass = { ...c.hass, callService: (d, s, data) => { calls.push([d, s, data.entity_id]); return Promise.resolve(); } };
|
||||
await c.updateComplete;
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
// устройство с управляемой сущностью
|
||||
const dev = c._devices.find((d) => d.entities?.some((e) => e.startsWith('light.') || e.startsWith('switch.')));
|
||||
out.hasDev = !!dev;
|
||||
c._infoCard = dev; await c.updateComplete;
|
||||
// 1) блок сущностей идёт ПЕРВЫМ, до модели/ссылок
|
||||
const body = sr().querySelector('.dialog .body');
|
||||
out.entListFirst = body.firstElementChild?.classList.contains('entlist')
|
||||
|| body.querySelector('.entlist') === body.children[0];
|
||||
const rows = [...sr().querySelectorAll('.entrow')];
|
||||
out.rows = rows.length > 0;
|
||||
// 2) у переключаемой сущности — кнопка с крупной зоной нажатия
|
||||
const btn = sr().querySelector('.entbtn');
|
||||
out.hasButton = !!btn;
|
||||
const box = btn?.getBoundingClientRect();
|
||||
out.tapTarget = box ? box.height >= 30 && box.width >= 60 : null;
|
||||
// 3) кнопка реально переключает
|
||||
const before = calls.length;
|
||||
btn.click(); await c.updateComplete;
|
||||
out.toggles = calls.length > before && calls.at(-1)[1] === 'toggle';
|
||||
// 4) замок никогда не переключается из карточки
|
||||
const n = calls.length;
|
||||
c._cardToggle('lock.front_door');
|
||||
out.lockNeverToggles = calls.length === n;
|
||||
// 5) диагностические/конфиг-сущности не засоряют список
|
||||
const ents = c._cardEntities(dev).map((e) => e.eid);
|
||||
out.noConfigEntities = ents.every((e) => {
|
||||
const cat = c.hass.entities[e]?.entity_category;
|
||||
return cat !== 'config' && cat !== 'diagnostic';
|
||||
});
|
||||
c._infoCard = null; await c.updateComplete;
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -0,0 +1,51 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
c._setMode('plan'); await c.updateComplete;
|
||||
const stage = sr().querySelector('.stage');
|
||||
const label = sr().querySelector('.roomlabel');
|
||||
out.hasLabel = !!label;
|
||||
// 1) draw: клик по карточке не добавляет точку
|
||||
c._tool = 'draw'; c._path = []; await c.updateComplete;
|
||||
label.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true, clientX: 10, clientY: 10 }));
|
||||
await c.updateComplete;
|
||||
out.drawIgnored = c._path.length === 0;
|
||||
// 2) клик по угловой метке тоже не считается кликом инструмента
|
||||
const handle = label.querySelector('.rlhandle');
|
||||
handle.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true, clientX: 10, clientY: 10 }));
|
||||
await c.updateComplete;
|
||||
out.handleIgnored = c._path.length === 0;
|
||||
// 3) во время резайза клики по сцене игнорируются
|
||||
const room = c._spaceModel().rooms.find((r) => r.name);
|
||||
c._rlResize = { id: 'rl_' + room.id, space: c._space, k0: 1, cx: 0, cy: 0, d0: 50 };
|
||||
const before = c._path.length;
|
||||
c._markupClick(new MouseEvent('click', { bubbles: true, clientX: 200, clientY: 200 }));
|
||||
out.duringResizeIgnored = c._path.length === before;
|
||||
c._rlResize = null;
|
||||
// 4) обычный клик по сцене (мимо карточки) по-прежнему работает — точка ставится
|
||||
const r = stage.getBoundingClientRect();
|
||||
// найти долю экрана, попадающую в свободное место (не внутри комнаты)
|
||||
let fx = 0.4, fy = 0.4;
|
||||
outer: for (let ix = 1; ix < 20; ix++) for (let iy = 1; iy < 20; iy++) {
|
||||
const ev = new MouseEvent('click', { clientX: r.left + r.width * ix / 20, clientY: r.top + r.height * iy / 20 });
|
||||
const raw = c._svgPoint(ev);
|
||||
if (!c._roomAt(c._snap(raw))) { fx = ix / 20; fy = iy / 20; break outer; }
|
||||
}
|
||||
stage.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true,
|
||||
clientX: r.left + r.width * fx, clientY: r.top + r.height * fy }));
|
||||
await c.updateComplete;
|
||||
out.normalClickWorks = c._path.length === 1;
|
||||
// 5) delroom: клик по карточке не зовёт confirm (переопределим)
|
||||
let confirmCalled = false;
|
||||
window.confirm = () => { confirmCalled = true; return false; };
|
||||
c._tool = 'delroom'; c._path = []; await c.updateComplete;
|
||||
label.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true, clientX: 10, clientY: 10 }));
|
||||
await c.updateComplete;
|
||||
out.delroomIgnored = !confirmCalled;
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -0,0 +1,76 @@
|
||||
// Ревью R2-3: климат комнат считался отдельным обходом реестра на каждую
|
||||
// комнату и каждую величину — 60 комнат × 2000 сущностей съедали кадр на
|
||||
// перечитывании метаданных, которые не менялись. Карта строится один раз на
|
||||
// снимок hass; при этом новые состояния датчиков обязаны попадать в неё сразу.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
|
||||
// считаем обходы реестра через ownKeys — именно его дёргает Object.entries
|
||||
let scans = 0;
|
||||
const wrap = (h) => {
|
||||
const ents = h.entities;
|
||||
const traced = new Proxy(ents, { ownKeys(t) { scans++; return Reflect.ownKeys(t); } });
|
||||
return { ...h, entities: traced };
|
||||
};
|
||||
const fresh = () => wrap(window.__mkHass());
|
||||
|
||||
// включаем и заливку по температуре, и подписи — два потребителя климата
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== 'f1' ? s : {
|
||||
...s, settings: { ...(s.settings || {}), show_names: true, fill_mode: 'temp', label_temp: true, label_hum: true },
|
||||
})};
|
||||
c._cfgEpoch++;
|
||||
c.hass = fresh(); await c.updateComplete;
|
||||
|
||||
scans = 0;
|
||||
c.hass = fresh(); await c.updateComplete;
|
||||
const fewRooms = scans;
|
||||
|
||||
// повторные рендеры на том же снимке hass реестр не трогают
|
||||
scans = 0;
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
out.scansOnRerender = scans;
|
||||
|
||||
// главный инвариант: обходов НЕ становится больше от числа комнат
|
||||
const f1 = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
const extra = [];
|
||||
for (let i = 0; i < 40; i++) {
|
||||
extra.push({ id: 'gen' + i, name: 'R' + i, area: 'living_room',
|
||||
poly: [[0.01, 0.01], [0.02, 0.01], [0.02, 0.02], [0.01, 0.02]] });
|
||||
}
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) =>
|
||||
s.id !== 'f1' ? s : { ...s, rooms: [...s.rooms, ...extra] }) };
|
||||
c._cfgEpoch++;
|
||||
c.hass = fresh(); await c.updateComplete;
|
||||
scans = 0;
|
||||
c.hass = fresh(); await c.updateComplete;
|
||||
out.roomCount = c._spaceModel('f1').rooms.length;
|
||||
out.scansSameWith44Rooms = scans === fewRooms;
|
||||
out.scansPerUpdate = scans;
|
||||
|
||||
// при этом новое состояние датчика обязано быть видно, а не взято из кэша
|
||||
out.tempBefore = c._climate().get('living_room')?.temp;
|
||||
const h = fresh();
|
||||
h.states = { ...h.states, 'sensor.living_temp': { ...h.states['sensor.living_temp'], state: '33.3' } };
|
||||
c.hass = h; await c.updateComplete;
|
||||
out.tempAfter = c._climate().get('living_room')?.temp;
|
||||
out.climateIsMap = c._climate() instanceof Map;
|
||||
return out;
|
||||
});
|
||||
// зафиксировано прогоном на v1.45.0 и сверено с кодом.
|
||||
// scansPerUpdate = 2: один обход у areaClimateMap, один у buildDevices. Важно
|
||||
// не само число, а что оно не растёт вместе с числом комнат.
|
||||
checkAll(res, {
|
||||
scansOnRerender: 0,
|
||||
roomCount: 44,
|
||||
scansSameWith44Rooms: true,
|
||||
scansPerUpdate: 2,
|
||||
tempBefore: 22.4,
|
||||
tempAfter: 33.3,
|
||||
climateIsMap: true,
|
||||
});
|
||||
await finish(browser);
|
||||
@@ -0,0 +1,67 @@
|
||||
// HP-1454-03: две локальные правки уходили с одной ревизией, вторая терялась.
|
||||
// Debounce разносил только СТАРТЫ. Если первый config/set отвечал дольше 500 мс,
|
||||
// вторая правка уходила с тем же expected_rev, сервер принимал первую и
|
||||
// отклонял вторую как conflict — а обработчик конфликта перечитывал серверную
|
||||
// копию поверх локальной. Правка исчезала, и тост винил «другое окно».
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const base = c.hass.callWS;
|
||||
const writes = [];
|
||||
let rev = 10;
|
||||
let releaseFirst;
|
||||
const firstGate = new Promise((r) => { releaseFirst = r; });
|
||||
|
||||
c.hass = { ...c.hass, callWS: async (m) => {
|
||||
if (m.type === 'houseplan/config/set') {
|
||||
const n = writes.length + 1;
|
||||
writes.push({ expected: m.expected_rev, titles: m.config.spaces.map((s) => s.title) });
|
||||
if (n === 1) await firstGate; // первый ответ задержан
|
||||
if (m.expected_rev !== rev) { const e = new Error('conflict'); e.code = 'conflict'; throw e; }
|
||||
rev += 1;
|
||||
return { ok: true, rev };
|
||||
}
|
||||
if (m.type === 'houseplan/config/get') {
|
||||
const r = await base(m);
|
||||
return { config: JSON.parse(JSON.stringify(r.config)), rev };
|
||||
}
|
||||
return base(m);
|
||||
} };
|
||||
c._cfgRev = rev;
|
||||
|
||||
// правка №1 и, пока первая запись висит, правка №2
|
||||
c._serverCfg.spaces[0].title = 'FIRST';
|
||||
c._saveConfig();
|
||||
c._saveConfigDebounced.flush();
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
out.oneInFlight = writes.length === 1;
|
||||
|
||||
c._serverCfg.spaces[0].title = 'SECOND';
|
||||
c._saveConfig();
|
||||
c._saveConfigDebounced.flush();
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
out.stillOneInFlight = writes.length === 1; // вторая ждёт очереди, не летит параллельно
|
||||
|
||||
releaseFirst();
|
||||
await new Promise((r) => setTimeout(r, 120));
|
||||
|
||||
out.writes = writes.length;
|
||||
out.revisions = writes.map((w) => w.expected); // вторая обязана взять новую ревизию
|
||||
out.secondCarriedTheEdit = writes[1]?.titles[0] === 'SECOND';
|
||||
out.editSurvived = c._serverCfg.spaces[0].title === 'SECOND';
|
||||
out.noConflictToast = !(c._toast || '').length;
|
||||
return out;
|
||||
});
|
||||
// зафиксировано прогоном на v1.46.0 и сверено с кодом
|
||||
checkAll(res, {
|
||||
oneInFlight: true,
|
||||
stillOneInFlight: true,
|
||||
writes: 2,
|
||||
revisions: [10, 11],
|
||||
secondCarriedTheEdit: true,
|
||||
editSurvived: true,
|
||||
noConflictToast: true,
|
||||
});
|
||||
await finish(browser);
|
||||
@@ -0,0 +1,61 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const calls = [];
|
||||
c.hass = { ...c.hass, callService: (d, s, data) => { calls.push([d, s, data.entity_id]); return Promise.resolve(); } };
|
||||
// два подопытных light-entity
|
||||
const lights = Object.keys(c.hass.states).filter((e) => e.startsWith('light.')).slice(0, 2);
|
||||
out.twoLights = lights.length === 2;
|
||||
const setSt = async (m) => {
|
||||
const st = { ...c.hass.states };
|
||||
for (const [e, v] of Object.entries(m)) st[e] = { ...st[e], state: v };
|
||||
c.hass = { ...c.hass, states: st }; await c.updateComplete;
|
||||
};
|
||||
// виртуальный маркер-выключатель с controls и tap_action=toggle
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
c._openMarkerDialog();
|
||||
const room = c._spaceModel().rooms.find((r) => r.id && r.area);
|
||||
c._markerDialog = { ...c._markerDialog, name: 'Выключатель', binding: 'virtual',
|
||||
room: c._space + '#' + room.area, tapAction: 'toggle', controls: lights, icon: 'mdi:light-switch' };
|
||||
await c._saveMarker(); await c.updateComplete;
|
||||
const dev = c._devices.find((d) => d.name === 'Выключатель');
|
||||
out.markerSaved = !!dev && JSON.stringify(dev.marker.controls) === JSON.stringify(lights);
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
// 1) все выключены → клик включает все
|
||||
await setSt({ [lights[0]]: 'off', [lights[1]]: 'off' });
|
||||
const dev2 = () => c._devices.find((d) => d.name === 'Выключатель');
|
||||
c._clickDevice(new MouseEvent('click'), dev2());
|
||||
out.turnOnAll = JSON.stringify(calls.at(-1)) === JSON.stringify(['homeassistant', 'turn_on', lights]);
|
||||
// 2) частично включено → клик выключает все
|
||||
await setSt({ [lights[0]]: 'on', [lights[1]]: 'off' });
|
||||
c._clickDevice(new MouseEvent('click'), dev2());
|
||||
out.partialTurnsOff = JSON.stringify(calls.at(-1)) === JSON.stringify(['homeassistant', 'turn_off', lights]);
|
||||
// 3) значок отражает цели: горит одна → класс on
|
||||
const el = [...sr().querySelectorAll('.dev')].find((e) => e.title === 'Выключатель' || e.textContent.includes(''));
|
||||
out.stateOn = !!c._stateClass(dev2()).includes('on');
|
||||
await setSt({ [lights[0]]: 'off' });
|
||||
out.stateOff = c._stateClass(dev2()) === '';
|
||||
// 4) без tap_action=toggle клик НЕ переключает (инфо)
|
||||
const cfg = c._serverCfg.markers.find((m) => m.name === 'Выключатель');
|
||||
cfg.tap_action = 'info'; c._regSignature = ''; c._maybeRebuildDevices(); await c.updateComplete;
|
||||
const n = calls.length;
|
||||
c._clickDevice(new MouseEvent('click'), dev2());
|
||||
out.infoNoToggle = calls.length === n && !!c._infoCard;
|
||||
// 5) инфо-карточка показывает цели
|
||||
await c.updateComplete;
|
||||
out.infoListsTargets = sr().querySelectorAll('.ctrlstate').length === 2;
|
||||
c._infoCard = null; await c.updateComplete;
|
||||
// 6) замок в controls отфильтрован при клике (защита)
|
||||
cfg.tap_action = 'toggle'; cfg.controls = ['lock.front_door', lights[0]];
|
||||
c._regSignature = ''; c._maybeRebuildDevices(); await c.updateComplete;
|
||||
await setSt({ [lights[0]]: 'off' });
|
||||
c._clickDevice(new MouseEvent('click'), dev2());
|
||||
const last = calls.at(-1);
|
||||
out.lockFiltered = JSON.stringify(last) === JSON.stringify(['homeassistant', 'turn_on', [lights[0]]]);
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -0,0 +1,74 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const esc = async () => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); await c.updateComplete; };
|
||||
// 1) третья вкладка есть
|
||||
const tabs = [...sr().querySelectorAll('.modetab')];
|
||||
out.threeTabs = tabs.length === 3;
|
||||
tabs[2].click(); await c.updateComplete;
|
||||
out.decorMode = c._mode === 'decor';
|
||||
out.decorBar = !!sr().querySelector('.editbar.decorbar');
|
||||
out.toolBtns = sr().querySelectorAll('.decorbar .btn:not(.barclose)').length === 6;
|
||||
// 2) нарисовать прямоугольник drag-ом (через прямые вызовы)
|
||||
c._decorTool = 'rect'; c._decorStyle = { color: '#ff0000', width: 3, fill: true }; await c.updateComplete;
|
||||
const g = c._gridPitch;
|
||||
c._decorDraft = { kind: 'rect', a: [g * 10, g * 10], b: [g * 10, g * 10], pid: 1 };
|
||||
c._decorDraft = { ...c._decorDraft, b: [g * 20, g * 16] };
|
||||
await c.updateComplete;
|
||||
out.draftPreview = !!sr().querySelector('.ddraft');
|
||||
c._decorCommitDraft(); await c.updateComplete;
|
||||
out.rectSaved = c._curSpaceCfg.decor?.length === 1 && c._curSpaceCfg.decor[0].kind === 'rect';
|
||||
out.rectFill = c._curSpaceCfg.decor[0].fill === true && c._curSpaceCfg.decor[0].color === '#ff0000';
|
||||
out.rectRendered = !!sr().querySelector('.decorlayer rect.dshape');
|
||||
// 3) линия и овал
|
||||
c._decorDraft = { kind: 'line', a: [g * 2, g * 2], b: [g * 8, g * 2], pid: 1 };
|
||||
c._decorCommitDraft();
|
||||
c._decorDraft = { kind: 'ellipse', a: [g * 30, g * 30], b: [g * 40, g * 36], pid: 1 };
|
||||
c._decorCommitDraft(); await c.updateComplete;
|
||||
out.threeShapes = c._decorList.length === 3;
|
||||
// вырожденная фигура не сохраняется
|
||||
c._decorDraft = { kind: 'line', a: [g, g], b: [g, g], pid: 1 };
|
||||
c._decorCommitDraft();
|
||||
out.degenerateSkipped = c._decorList.length === 3;
|
||||
// 4) надпись через диалог
|
||||
c._decorTextDialog = { x: 0.5, y: 0.5, text: 'Сауна', size: 'l', color: '#0000ff' };
|
||||
c._decorSaveText(); await c.updateComplete;
|
||||
out.textSaved = c._decorList.some((x) => x.kind === 'text' && x.text === 'Сауна');
|
||||
out.textRendered = [...sr().querySelectorAll('.decorlayer text')].some((t) => t.textContent.includes('Сауна'));
|
||||
// 5) select: перемещение сохраняет форму, drag двигает
|
||||
const rect = c._decorList.find((x) => x.kind === 'rect');
|
||||
c._decorTool = 'select';
|
||||
c._decorMove = { id: rect.id, start: [0, 0], orig: JSON.parse(JSON.stringify(rect)), pid: 7, moved: false };
|
||||
c._decorMoveUpdate({ clientX: 0, clientY: 0, }); // без смещения
|
||||
const before = { ...c._decorList.find((x) => x.id === rect.id) };
|
||||
c._decorMove.start = [0, 0];
|
||||
// сдвиг на 5 клеток по x: подделаем _svgPoint? проще напрямую:
|
||||
const m = c._decorMove; const dx5 = (g * 5) / 1000;
|
||||
c._curSpaceCfg.decor = c._decorList.map((x) => x.id === m.id ? { ...x, x: m.orig.x + dx5 } : x);
|
||||
out.moveKeepsSize = Math.abs(c._decorList.find((x) => x.id === rect.id).w - before.w) < 1e-9;
|
||||
c._decorMove = null;
|
||||
// 6) erase удаляет
|
||||
const n0 = c._decorList.length;
|
||||
c._decorTool = 'erase';
|
||||
c._decorShapeDown({ stopPropagation() {}, preventDefault() {}, target: null, pointerId: 1 }, c._decorList[0]);
|
||||
out.eraseWorks = c._decorList.length === n0 - 1;
|
||||
// 7) Esc-лестница: инструмент → select → выход
|
||||
c._decorTool = 'line'; c._decorSel = null; await c.updateComplete;
|
||||
await esc(); out.escTool = c._decorTool === 'select' && c._mode === 'decor';
|
||||
await esc(); out.escExit = c._mode === 'view';
|
||||
// 8) в Просмотре фигуры видны, но inert
|
||||
const shp = sr().querySelector('.decorlayer .dshape');
|
||||
out.visibleInView = !!shp;
|
||||
out.inertInView = shp ? getComputedStyle(shp).pointerEvents === 'none' : null;
|
||||
// 9) Delete удаляет выбранное
|
||||
sr().querySelectorAll('.modetab')[2].click(); await c.updateComplete;
|
||||
c._decorTool = 'select'; c._decorSel = c._decorList[0].id; const n1 = c._decorList.length; await c.updateComplete;
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete' })); await c.updateComplete;
|
||||
out.deleteKey = c._decorList.length === n1 - 1;
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -0,0 +1,55 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
let pageError = null;
|
||||
window.addEventListener('error', (e) => { pageError = String(e.message); });
|
||||
// сохранение падает, а диалог закрыт до ответа — карточка не должна умереть
|
||||
c.hass = { ...c.hass, callWS: async (msg) => {
|
||||
if (String(msg.type).endsWith('/set')) { await new Promise((r) => setTimeout(r, 60)); throw new Error('boom'); }
|
||||
return { config: c._serverCfg, rev: c._cfgRev };
|
||||
} };
|
||||
await c.updateComplete;
|
||||
// 1) диалог общих настроек
|
||||
c._openSettingsDialog(); await c.updateComplete;
|
||||
const p = c._saveSettingsDialog();
|
||||
c._settingsDialog = null; // Esc во время сохранения
|
||||
await c.updateComplete;
|
||||
await p.catch(() => {});
|
||||
await new Promise((r) => setTimeout(r, 120));
|
||||
await c.updateComplete;
|
||||
out.settingsStaysClosed = c._settingsDialog === null;
|
||||
out.cardAliveAfterSettings = !!sr().querySelector('.stage');
|
||||
// 2) диалог правил
|
||||
c._openRulesDialog(); await c.updateComplete;
|
||||
const p2 = c._saveRules();
|
||||
c._rulesDialog = null; await c.updateComplete;
|
||||
await p2.catch(() => {});
|
||||
await new Promise((r) => setTimeout(r, 120));
|
||||
await c.updateComplete;
|
||||
out.rulesStaysClosed = c._rulesDialog === null;
|
||||
out.cardAliveAfterRules = !!sr().querySelector('.stage');
|
||||
// 3) диалог устройства
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
c._openMarkerDialog(c._devices[0]); await c.updateComplete;
|
||||
const p3 = c._saveMarker();
|
||||
c._markerDialog = null; await c.updateComplete;
|
||||
await p3.catch(() => {});
|
||||
await new Promise((r) => setTimeout(r, 120));
|
||||
await c.updateComplete;
|
||||
out.markerStaysClosed = c._markerDialog === null;
|
||||
out.cardAliveAfterMarker = !!sr().querySelector('.stage');
|
||||
out.noPageError = pageError === null;
|
||||
// 4) при ОТКРЫТОМ диалоге ошибка снимает busy (поведение сохранено)
|
||||
c._openSettingsDialog(); await c.updateComplete;
|
||||
const p4 = c._saveSettingsDialog();
|
||||
await p4.catch(() => {});
|
||||
await new Promise((r) => setTimeout(r, 120));
|
||||
out.busyClearedWhenOpen = c._settingsDialog !== null && c._settingsDialog.busy === false;
|
||||
c._settingsDialog = null;
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -1,4 +1,4 @@
|
||||
import { launch } from './serve.mjs';
|
||||
import { launch, check, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const 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()=>()=>{} } };
|
||||
@@ -70,5 +70,18 @@ const res = await page.evaluate(async () => {
|
||||
out.bigRenderMs = Math.round(performance.now() - t1);
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
||||
// сборка 100+ устройств должна укладываться в бюджет (docs/TESTING.md)
|
||||
// timings are asserted as budgets, not frozen values (CI machines vary)
|
||||
check("bigBuildMs under 200ms", res.bigBuildMs < 200);
|
||||
check("bigRenderMs under 100ms", res.bigRenderMs < 100);
|
||||
delete res.bigBuildMs;
|
||||
delete res.bigRenderMs;
|
||||
checkAll(res, {
|
||||
"emptyDevices": 0,
|
||||
"emptyCount": "0 dev.",
|
||||
"xssPwned": false,
|
||||
"xssDeviceRendered": 1,
|
||||
"bigCount": 162,
|
||||
});
|
||||
await finish(browser, res);
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const tabs = () => [...sr().querySelectorAll('.modetab')];
|
||||
// 1) две вкладки, Просмотра нет, крестиков в неактивных нет
|
||||
out.twoTabs = tabs().length === 3; // третья — Редактор подложки (v1.33.0)
|
||||
out.labels = tabs().map((t) => t.textContent.trim());
|
||||
out.noCrossIdle = sr().querySelectorAll('.modetab .closex').length === 0;
|
||||
out.startView = c._mode === 'view';
|
||||
// 2) клик по «Редактор плана» → активна, панель с крестиком
|
||||
tabs()[0].click(); await c.updateComplete;
|
||||
out.planActive = c._mode === 'plan' && tabs()[0].classList.contains('active');
|
||||
out.planBar = !!sr().querySelector('.editbar');
|
||||
out.planBarClose = !!sr().querySelector('.editbar .barclose');
|
||||
out.tabCross = !!tabs()[0].querySelector('.closex');
|
||||
// 3) повторный клик по активной вкладке — ничего
|
||||
tabs()[0].click(); await c.updateComplete;
|
||||
out.reclickNoop = c._mode === 'plan';
|
||||
// 4) прямое переключение План → Устройства
|
||||
tabs()[1].click(); await c.updateComplete;
|
||||
out.directSwitch = c._mode === 'devices';
|
||||
out.devBar = !!sr().querySelector('.editbar.devbar');
|
||||
out.devBarBtns = sr().querySelectorAll('.editbar.devbar .btn:not(.barclose)').length === 3; // add/show-all/rules (v1.33.2: Reset removed)
|
||||
// 5) инструменты устройств из шапки исчезли (в .bar их больше нет)
|
||||
out.headerCleanInDev = !sr().querySelector('.bar > .btn[title*="' + (c._t('title.add_device')) + '"]');
|
||||
// 6) крестик на панели → Просмотр
|
||||
sr().querySelector('.editbar .barclose').click(); await c.updateComplete;
|
||||
out.barCloseWorks = c._mode === 'view' && !sr().querySelector('.editbar');
|
||||
// 7) крестик в самой вкладке → Просмотр (и не переключает режим)
|
||||
tabs()[1].click(); await c.updateComplete;
|
||||
tabs()[1].querySelector('.closex').click(); await c.updateComplete;
|
||||
out.tabCrossWorks = c._mode === 'view';
|
||||
return out;
|
||||
});
|
||||
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
||||
checkAll(res, {
|
||||
"labels": ["Plan editor", "Device editor", "Background editor"],
|
||||
});
|
||||
await finish(browser, res);
|
||||
@@ -0,0 +1,35 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const esc = async () => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); await c.updateComplete; };
|
||||
// общие настройки
|
||||
c._openSettingsDialog(); await c.updateComplete;
|
||||
await esc(); out.settings = !c._settingsDialog;
|
||||
// правила иконок
|
||||
c._openRulesDialog(); await c.updateComplete;
|
||||
await esc(); out.rules = !c._rulesDialog;
|
||||
// диалог устройства
|
||||
c._setMode('devices'); c._openMarkerDialog(); await c.updateComplete;
|
||||
await esc(); out.marker = !c._markerDialog;
|
||||
// диалог пространства (+ очистка очереди импорта)
|
||||
c._openSpaceDialog('edit', c._space); c._importQueue = ['x']; c._importTotal = 1; await c.updateComplete;
|
||||
await esc(); out.space = !c._spaceDialog && c._importQueue.length === 0;
|
||||
// инфо-карточка устройства
|
||||
c._setMode('view'); c._infoCard = c._devices[0]; await c.updateComplete;
|
||||
await esc(); out.info = !c._infoCard;
|
||||
// инфо двери/замка
|
||||
c._openingInfo = { id: 'op1', type: 'door', rx: 550, ry: 200, len_cm: 90, lock: 'lock.front_door' }; await c.updateComplete;
|
||||
await esc(); out.openingInfo = !c._openingInfo;
|
||||
// приоритет: инфо поверх настроек — Esc закрывает только инфо
|
||||
c._openSettingsDialog(); c._infoCard = c._devices[0]; await c.updateComplete;
|
||||
await esc(); out.stacked = !c._infoCard && !!c._settingsDialog;
|
||||
await esc(); out.stacked2 = !c._settingsDialog;
|
||||
// Esc в разметке по-прежнему откатывает точку, а не только диалоги
|
||||
c._setMode('plan'); c._tool = 'draw'; c._path = [[1, 2], [3, 4]]; await c.updateComplete;
|
||||
await esc(); out.undoPointStillWorks = c._path.length === 1;
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -0,0 +1,49 @@
|
||||
import { launch, check, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
// 1) кнопка настроек комнаты в редакторе плана: заметная, фиксированного размера
|
||||
c._setMode('plan'); await c.updateComplete;
|
||||
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;
|
||||
out.gearHasLabel = btn ? btn.textContent.trim().length > 0 : null;
|
||||
const box = btn?.getBoundingClientRect();
|
||||
out.gearTapTarget = box ? box.height >= 18 && box.width >= 40 : null;
|
||||
btn.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
|
||||
await c.updateComplete;
|
||||
out.gearOpensDialog = c._roomDialog === true && !!c._roomEditId;
|
||||
c._roomDialogCancel(); await c.updateComplete;
|
||||
// 2) комната без имени тоже получает кнопку (её там и называют)
|
||||
const room = c._curSpaceCfg.rooms[0];
|
||||
const savedName = room.name;
|
||||
room.name = '';
|
||||
c._saveConfig(); c.requestUpdate(); await c.updateComplete;
|
||||
out.unnamedStillHasGear = sr().querySelectorAll('.rlgearbtn').length >= 1;
|
||||
room.name = savedName; c._saveConfig(); c.requestUpdate(); await c.updateComplete;
|
||||
// 3) метрики стали крупнее: 0.75em вместо 0.62em
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== c._space ? s : ({
|
||||
...s, settings: { ...(s.settings || {}), show_names: true, label_temp: true } })) };
|
||||
c._setMode('view'); c._saveConfig(); c.requestUpdate(); await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
const lbl = [...sr().querySelectorAll('.roomlabel')].find((l) => l.querySelector('.rlmetrics'));
|
||||
if (lbl) {
|
||||
const nameSz = parseFloat(getComputedStyle(lbl.querySelector('.rlname')).fontSize);
|
||||
const metaSz = parseFloat(getComputedStyle(lbl.querySelector('.rlmetrics')).fontSize);
|
||||
out.metricsRatio = Math.round((metaSz / nameSz) * 100) / 100;
|
||||
} else out.metricsRatio = 'no-metrics';
|
||||
// 4) касание помечает сессию как тач и гасит тултип
|
||||
c._tip = { x: 1, y: 1, title: 't', meta: 'm' };
|
||||
c._notePointer(new PointerEvent('pointerdown', { pointerType: 'touch' }));
|
||||
out.touchClearsTip = c._tip === null;
|
||||
c._showTip(new MouseEvent('mousemove', { clientX: 5, clientY: 5 }), 'x', 'y');
|
||||
out.noTipAfterTouch = !c._tip;
|
||||
return out;
|
||||
});
|
||||
check('metricsRatio 0.75', res.metricsRatio, 0.75);
|
||||
delete res.metricsRatio;
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -0,0 +1,58 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const spId = c._space;
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== spId ? s : ({
|
||||
...s, settings: { ...(s.settings || {}), show_names: true, label_temp: true } })) };
|
||||
c._setMode('view'); c.requestUpdate(); await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
const lbl = () => sr().querySelector('.roomlabel');
|
||||
const nameSize = () => parseFloat(getComputedStyle(lbl().querySelector('.rlname')).fontSize);
|
||||
const metaEl = () => lbl().querySelector('.rlmetrics');
|
||||
const n0 = nameSize();
|
||||
const m0 = metaEl() ? parseFloat(getComputedStyle(metaEl()).fontSize) : null;
|
||||
out.baseline = n0 > 0;
|
||||
// 1) пер-комнатный name_scale ×2 — имя удвоилось, подписи нет
|
||||
const room = c._curSpaceCfg.rooms.find((r) => r.name && r.area);
|
||||
room.settings = { ...(room.settings || {}), name_scale: 2 };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const lblR = [...sr().querySelectorAll('.roomlabel')].find((l) => l.textContent.includes(room.name));
|
||||
const n1 = parseFloat(getComputedStyle(lblR.querySelector('.rlname')).fontSize);
|
||||
out.nameDoubled = Math.abs(n1 / n0 - 2) < 0.05;
|
||||
const meta1 = lblR.querySelector('.rlmetrics');
|
||||
out.metaUntouched = meta1 ? Math.abs(parseFloat(getComputedStyle(meta1).fontSize) - m0) < 0.5 : 'no-meta';
|
||||
// 2) label_scale ×2 — подписи удвоились
|
||||
room.settings.label_scale = 2;
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const meta2 = lblR.querySelector('.rlmetrics');
|
||||
out.metaDoubled = meta2 && m0 ? Math.abs(parseFloat(getComputedStyle(meta2).fontSize) / m0 - 2) < 0.05 : 'no-meta';
|
||||
// 3) базовый множитель пространства ×1.5 — умножает всё
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== spId ? s : ({
|
||||
...s, settings: { ...(s.settings || {}), card_font_scale: 1.5 } })) };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const lblR2 = [...sr().querySelectorAll('.roomlabel')].find((l) => l.textContent.includes(room.name));
|
||||
const n2 = parseFloat(getComputedStyle(lblR2.querySelector('.rlname')).fontSize);
|
||||
out.spaceMultiplies = Math.abs(n2 / n0 - 3) < 0.1; // 2 × 1.5
|
||||
// 4) диалог комнаты: слайдеры + живой пример реагирует
|
||||
c._setMode('plan'); await c.updateComplete;
|
||||
const model = c._spaceModel().rooms.find((r) => r.id === room.id);
|
||||
c._openRoomEdit(model); await c.updateComplete;
|
||||
out.slidersPrefilled = c._roomNameScale === 2 && c._roomLabelScale === 2;
|
||||
const pv = () => sr().querySelector('.cardpreview .cpname');
|
||||
const p0 = parseFloat(pv().style.fontSize);
|
||||
c._roomNameScale = 1; c.requestUpdate(); await c.updateComplete;
|
||||
const p1 = parseFloat(pv().style.fontSize);
|
||||
out.previewLive = Math.abs(p0 / p1 - 2) < 0.05;
|
||||
c._roomDialogCancel(); await c.updateComplete;
|
||||
// 5) превью есть и в диалоге пространства
|
||||
c._openSpaceDialog('edit', spId); await c.updateComplete;
|
||||
out.spacePreview = !!sr().querySelector('.cardpreview');
|
||||
out.spaceSlider = [...sr().querySelectorAll('.dialog label')].some((l) => l.textContent === c._t('space.card_font'));
|
||||
c._spaceDialog = null; await c.updateComplete;
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -0,0 +1,38 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const gears = () => sr().querySelectorAll('.tab .tabedit').length;
|
||||
// шестерёнки видны во всех трёх режимах
|
||||
out.viewGears = gears() > 0;
|
||||
c._setMode('plan'); await c.updateComplete;
|
||||
out.planGears = gears() > 0;
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
out.devGears = gears() > 0;
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
// выравнивание: центр иконки ~ центру текста таба
|
||||
const tab = sr().querySelector('.tab');
|
||||
const icon = tab.querySelector('.tabedit');
|
||||
const tb = tab.getBoundingClientRect(), ib = icon.getBoundingClientRect();
|
||||
const tabMid = tb.top + tb.height / 2, iconMid = ib.top + ib.height / 2;
|
||||
out.alignDelta = Math.abs(tabMid - iconMid);
|
||||
out.aligned = out.alignDelta <= 1.5;
|
||||
// клик по шестерёнке в Просмотре открывает диалог пространства и не переключает таб
|
||||
const cur = c._space;
|
||||
icon.click(); await c.updateComplete;
|
||||
out.dialogOpens = !!c._spaceDialog;
|
||||
out.tabNotSwitched = c._space === cur;
|
||||
c._spaceDialog = null; await c.updateComplete;
|
||||
// кнопка "+" по-прежнему только в Плане
|
||||
out.noAddInView = !sr().querySelector('.tab.tabadd');
|
||||
c._setMode('plan'); await c.updateComplete;
|
||||
out.addInPlan = !!sr().querySelector('.tab.tabadd');
|
||||
return out;
|
||||
});
|
||||
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
||||
checkAll(res, {
|
||||
"alignDelta": 0,
|
||||
});
|
||||
await finish(browser, res);
|
||||
@@ -1,4 +1,4 @@
|
||||
import { launch } from './serve.mjs';
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
@@ -26,5 +26,12 @@ const res = await page.evaluate(async () => {
|
||||
out.lqiAfter = sr().querySelectorAll('.dev .lqi').length;
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
||||
checkAll(res, {
|
||||
"rows": 11,
|
||||
"groups": ["Fill: lights", "Fill: temperature", "Fill: zigbee signal", "Light-sources fill"],
|
||||
"saved": {"c": "#ff00ff", "a": 0.5},
|
||||
"lqiBefore": 7,
|
||||
"lqiAfter": 0,
|
||||
});
|
||||
await finish(browser, res);
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const spId = c._space;
|
||||
// включить glow-режим
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== spId ? s : ({
|
||||
...s, settings: { ...(s.settings || {}), fill_mode: 'glow' } })) };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
// 1) все комнаты залиты темнотой одинаково (независимо от ламп)
|
||||
const rooms = [...sr().querySelectorAll('.room.styled')];
|
||||
out.roomsDark = rooms.length > 0 && rooms.every((el) => {
|
||||
const st = el.getAttribute('style') || '';
|
||||
return st.includes('--room-fill:#0d1b2a');
|
||||
});
|
||||
// 2) пятна от включённых ламп
|
||||
const litLight = c._devices.find((d) => d.space === spId && d.entities.some((e) => e.startsWith('light.') && c.hass.states[e]?.state === 'on'));
|
||||
out.hasLitLight = !!litLight;
|
||||
out.spots = sr().querySelectorAll('.glowlayer circle').length;
|
||||
out.spotsMatchLights = out.spots === c._devices.filter((d) => d.space === spId && d.entities.some((e) => e.startsWith('light.') && c.hass.states[e]?.state === 'on')).length;
|
||||
// 3) выключение лампы убирает её пятно
|
||||
const eid = litLight.entities.find((e) => e.startsWith('light.') && c.hass.states[e]?.state === 'on');
|
||||
const st0 = c.hass.states[eid];
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, [eid]: { ...st0, state: 'off' } } };
|
||||
await c.updateComplete;
|
||||
out.spotGone = sr().querySelectorAll('.glowlayer circle').length === out.spots - 1;
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, [eid]: st0 } }; await c.updateComplete;
|
||||
// 4) rgb-лампа красит градиент своим цветом (форсируем rgb у лампы)
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, [eid]: { ...st0, state: 'on', attributes: { ...st0.attributes, rgb_color: [255, 0, 0] } } } };
|
||||
await c.updateComplete;
|
||||
out.gradientColored = [...sr().querySelectorAll('defs radialGradient stop')].some((s2) => s2.getAttribute('stop-color') === 'rgb(255, 0, 0)');
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, [eid]: st0 } }; await c.updateComplete;
|
||||
// 5) пятно обрезано комнатой (есть clipPath)
|
||||
out.clipped = sr().querySelectorAll('defs clipPath[id^="hp-glowclip"]').length > 0;
|
||||
// 6) дверь в соседнюю комнату → в clip добавлен сектор (2 сабпути M)
|
||||
// добавим дверь между r1 и r2 на общей стене x=0.55… найдём общую стену программно:
|
||||
const spm = c._spaceModel();
|
||||
const r1 = spm.rooms.find((r) => r.id === 'r1');
|
||||
const r2 = spm.rooms.find((r) => r.id === 'r2');
|
||||
// возьмём точку на границе r1, ближайшую к центру r2
|
||||
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; // square canvas
|
||||
const doorPt = (() => {
|
||||
let best = null, bd = 1e9;
|
||||
for (const [x, y] of [[550, 150], [550, 200], [550, 250]]) {
|
||||
const d2 = Math.hypot(x - c2[0], y - c2[1]);
|
||||
if (d2 < bd) { bd = d2; best = [x, y]; }
|
||||
}
|
||||
return best;
|
||||
})();
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== spId ? s : ({
|
||||
...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 c1 = c._roomCenter(r1);
|
||||
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;
|
||||
// теперь каждый под-контур — отдельный path внутри clipPath
|
||||
const clipEls = [...sr().querySelectorAll('defs clipPath[id^="hp-glowclip"]')];
|
||||
out.sectorAdded = clipEls.some((cp) => cp.querySelectorAll('path').length >= 2);
|
||||
// и сектор в том же направлении обхода не выедает комнату: каждый path — один контур
|
||||
out.singleSubpathPerPath = clipEls.every((cp) => [...cp.querySelectorAll('path')]
|
||||
.every((p) => ((p.getAttribute('d') || '').match(/M /g) || []).length === 1));
|
||||
// у входной двери (наружу) сектора нет: дверь на внешней стене r1 (x=min)
|
||||
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, 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);
|
||||
// 6б) профиль градиента: плато до 80%, спад на внешних 20%
|
||||
const stops = [...sr().querySelectorAll('defs radialGradient')][0];
|
||||
const offs = stops ? [...stops.querySelectorAll('stop')].map((st2) => [st2.getAttribute('offset'), st2.getAttribute('stop-opacity')]) : [];
|
||||
out.plateau80 = offs.length === 3 && offs[0][0] === '0%' && offs[1][0] === '70%'
|
||||
&& offs[0][1] === offs[1][1] && offs[2][1] === '0';
|
||||
// 7а) персональный радиус источника перекрывает глобальный
|
||||
const litMarkerId = litLight.id;
|
||||
c._serverCfg = { ...c._serverCfg, markers: [
|
||||
...(c._serverCfg.markers || []).filter((m) => m.id !== litMarkerId),
|
||||
{ id: litMarkerId, binding: litLight.bindingKind === 'virtual' ? 'virtual' : litLight.bindingKind + ':' + litLight.bindingRef, glow_radius_cm: 150 },
|
||||
] };
|
||||
c._regSignature = ''; c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
|
||||
const rOwn = Number(sr().querySelector('.glowlayer circle')?.getAttribute('r'));
|
||||
out.perSourceRadius = Math.abs(rOwn - c._cmToUnits(150)) < 0.5;
|
||||
c._serverCfg = { ...c._serverCfg, markers: (c._serverCfg.markers || []).filter((m) => m.id !== litMarkerId) };
|
||||
c._regSignature = ''; c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
|
||||
// 6в) флаг «источник света»: умный выключатель с обычными светильниками
|
||||
const swDev = c._devices.find((d) => d.space === spId && d.entities.some((e) => e.startsWith('switch.')));
|
||||
if (swDev) {
|
||||
const swEid = swDev.entities.find((e) => e.startsWith('switch.'));
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, [swEid]: { ...c.hass.states[swEid], state: 'on' } } };
|
||||
const spotsBefore = sr().querySelectorAll('.glowlayer circle').length;
|
||||
c._serverCfg = { ...c._serverCfg, markers: [
|
||||
...(c._serverCfg.markers || []).filter((m) => m.id !== swDev.id),
|
||||
{ id: swDev.id, binding: swDev.bindingKind + ':' + swDev.bindingRef, is_light: true },
|
||||
] };
|
||||
c._regSignature = ''; c._maybeRebuildDevices(); c._saveConfig(); c.requestUpdate(); await c.updateComplete;
|
||||
out.switchGlows = sr().querySelectorAll('.glowlayer circle').length === spotsBefore + 1;
|
||||
c._serverCfg = { ...c._serverCfg, markers: (c._serverCfg.markers || []).filter((m) => m.id !== swDev.id) };
|
||||
c._regSignature = ''; c._maybeRebuildDevices(); c._saveConfig(); c.requestUpdate(); await c.updateComplete;
|
||||
out.switchGlowsOffByDefault = sr().querySelectorAll('.glowlayer circle').length === spotsBefore;
|
||||
} else { out.switchGlows = 'no-switch'; out.switchGlowsOffByDefault = 'no-switch'; }
|
||||
// 7) радиус из настроек: 600 см против 300 см — вдвое больше
|
||||
const r600 = Number(sr().querySelector('.glowlayer circle')?.getAttribute('r'));
|
||||
c._serverCfg = { ...c._serverCfg, settings: { ...(c._serverCfg.settings || {}), glow_radius_cm: 300 } };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const r300 = Number(sr().querySelector('.glowlayer circle')?.getAttribute('r'));
|
||||
out.radiusReacts = Math.abs(r600 / r300 - 2) < 0.01;
|
||||
return out;
|
||||
});
|
||||
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
||||
checkAll(res, {
|
||||
"spots": 1,
|
||||
});
|
||||
await finish(browser, res);
|
||||
@@ -0,0 +1,37 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const grid = () => !!sr().querySelector('rect[fill="url(#hp-grid)"]');
|
||||
// Просмотр: сетки нет
|
||||
out.noGridInView = !grid();
|
||||
// все три редактора: сетка есть
|
||||
c._setMode('plan'); await c.updateComplete; out.gridInPlan = grid();
|
||||
c._setMode('devices'); await c.updateComplete; out.gridInDevices = grid();
|
||||
c._setMode('decor'); await c.updateComplete; out.gridInDecor = grid();
|
||||
await new Promise((r) => setTimeout(r, 250)); // transition .room 0.12s
|
||||
// decor: комнаты и устройства полупрозрачны
|
||||
const room = sr().querySelector('.room');
|
||||
out.roomFaded = room ? Number(getComputedStyle(room).opacity) < 0.5 : null;
|
||||
const dl = sr().querySelector('.devlayer');
|
||||
out.devsFaded = dl ? Number(getComputedStyle(dl).opacity) < 0.5 : null;
|
||||
// а декор-слой — нет
|
||||
c._curSpaceCfg.decor = [{ id: 'd1', kind: 'line', x1: 0.1, y1: 0.1, x2: 0.3, y2: 0.1, color: '#ff0000', width: 3 }];
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const shp = sr().querySelector('.decorlayer .dshape');
|
||||
out.decorNotFaded = shp ? Number(getComputedStyle(shp).opacity) > 0.9 : null;
|
||||
// в других редакторах прозрачности нет
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
const room2 = sr().querySelector('.room');
|
||||
out.notFadedInDevices = room2 ? Number(getComputedStyle(room2).opacity) > 0.9 : null;
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
const room3 = sr().querySelector('.room');
|
||||
out.notFadedInView = room3 ? Number(getComputedStyle(room3).opacity) > 0.9 : null;
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -0,0 +1,18 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const gs = () => !!sr().querySelector('.head .btn[title="' + c._t('title.general_settings') + '"]');
|
||||
out.view = gs();
|
||||
c._setMode('plan'); await c.updateComplete; out.plan = gs();
|
||||
c._setMode('devices'); await c.updateComplete; out.devices = gs();
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
sr().querySelector('.head .btn[title="' + c._t('title.general_settings') + '"]').click();
|
||||
await c.updateComplete;
|
||||
out.opensInView = !!c._settingsDialog;
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -0,0 +1,31 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
// устройство без явной иконки (marker.icon пуст)
|
||||
const dev = c._devices.find((d) => !d.marker?.icon && d.icon);
|
||||
out.hasAutoDev = !!dev;
|
||||
c._openMarkerDialog(dev); await c.updateComplete;
|
||||
out.autoIconStored = c._markerDialog.autoIcon === dev.icon;
|
||||
out.iconEmpty = c._markerDialog.icon === '';
|
||||
// подсказка с авто-иконкой видна
|
||||
const hint = sr().querySelector('.iconauto');
|
||||
out.hintShown = !!hint && hint.textContent.includes(dev.icon);
|
||||
out.hintIcon = hint?.querySelector('ha-icon')?.getAttribute('icon') === dev.icon;
|
||||
// пикер (или фолбэк-инпут) получил placeholder
|
||||
const picker = sr().querySelector('ha-icon-picker');
|
||||
const inputs = [...sr().querySelectorAll('.dialog input')];
|
||||
out.placeholderSet = picker
|
||||
? picker.placeholder === dev.icon
|
||||
: inputs.some((i) => i.placeholder === dev.icon);
|
||||
// при явной иконке подсказки нет
|
||||
c._markerDialog = { ...c._markerDialog, icon: 'mdi:star' }; await c.updateComplete;
|
||||
out.noHintWhenExplicit = !sr().querySelector('.iconauto');
|
||||
c._markerDialog = null; await c.updateComplete;
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -0,0 +1,52 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
// добавить открывание на f1
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== 'f1' ? s : ({
|
||||
...s, openings: [{ id: 'op1', type: 'door', rx: 550, ry: 200, angle: 90, len_cm: 90 }] })) };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const hit = sr().querySelector('.op-hit');
|
||||
out.hasOpening = !!hit;
|
||||
const cs = hit ? getComputedStyle(hit) : null;
|
||||
out.viewInert = cs ? cs.pointerEvents === 'none' && cs.cursor === 'default' : null;
|
||||
out.lockBadgeInert = (() => { const l = sr().querySelector('.oplock'); return l ? getComputedStyle(l).pointerEvents === 'none' : 'no-badge'; })();
|
||||
// View: клик по открыванию ничего не открывает
|
||||
c._opClick({ stopPropagation(){} }, { id: 'op1', rx: 550, ry: 200, rlen: 90 });
|
||||
out.viewClickNoop = !c._openingInfo && !c._openingDialog;
|
||||
// курсор устройств в Просмотре — pointer, в Устройствах — grab
|
||||
const dev = sr().querySelector('.dev');
|
||||
out.viewDevCursor = getComputedStyle(dev).cursor;
|
||||
// бейдж замка: замок привязан → бейдж кликабелен и открывает инфо
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s2) => s2.id !== 'f1' ? s2 : ({
|
||||
...s2, openings: [{ id: 'op1', type: 'door', rx: 550, ry: 200, angle: 90, len_cm: 90, lock: 'lock.front_door' }] })) };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const badge = sr().querySelector('.oplock');
|
||||
out.badgeShown = !!badge;
|
||||
const bcs = badge ? getComputedStyle(badge) : null;
|
||||
out.badgeClickableInView = bcs ? bcs.pointerEvents === 'auto' && bcs.cursor === 'pointer' : null;
|
||||
badge?.click(); await c.updateComplete;
|
||||
out.badgeOpensInfo = !!c._openingInfo;
|
||||
c._openingInfo = null;
|
||||
// Plan: op-hit интерактивен, клик редактирует
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
out.devModeCursor = getComputedStyle(sr().querySelector('.dev')).cursor;
|
||||
c._setMode('plan'); await c.updateComplete;
|
||||
out.badgeInertInPlan = (() => { const b = sr().querySelector('.oplock'); return b ? getComputedStyle(b).pointerEvents === 'none' : 'no-badge'; })();
|
||||
const cs2 = getComputedStyle(sr().querySelector('.op-hit'));
|
||||
out.planInteractive = cs2.pointerEvents === 'auto' && cs2.cursor === 'grab';
|
||||
const op = c._spaceModel().openings?.[0] || { id: 'op1', rx: 550, ry: 200, rlen: 90 };
|
||||
c._opClick({ stopPropagation(){} }, op);
|
||||
out.planClickEdits = !!c._openingDialog;
|
||||
c._openingDialog = null; c._setMode('view');
|
||||
return out;
|
||||
});
|
||||
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
||||
checkAll(res, {
|
||||
"lockBadgeInert": "no-badge",
|
||||
"viewDevCursor": "pointer",
|
||||
"devModeCursor": "grab",
|
||||
});
|
||||
await finish(browser, res);
|
||||
@@ -0,0 +1,59 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
c._setMode('plan'); c._tool = 'draw'; await c.updateComplete;
|
||||
const g = c._gridPitch;
|
||||
// r1 — комната; рисуем остров-«колонну» внутри неё
|
||||
const r1 = c._spaceModel().rooms.find((r) => r.id === 'r1');
|
||||
const [cx, cy] = c._roomCenter(r1).map((v) => Math.round(v / g) * g);
|
||||
const pts = [[cx, cy], [cx + g * 2, cy], [cx + g * 2, cy + g * 2], [cx, cy + g * 2]];
|
||||
// 1) клики внутри комнаты больше не отклоняются
|
||||
for (const p of pts) c._markupClick({ clientX: 0, clientY: 0, composedPath: () => [], ...{} , __pt: p });
|
||||
// _markupClick берёт координаты из _svgPoint(ev) — подменим напрямую через _path:
|
||||
c._path = [];
|
||||
for (const p of pts) {
|
||||
const before = c._path.length;
|
||||
// эмулируем клик готовой точкой: повторим логику через прямой вызов невозможен — построим путь вручную и замкнём
|
||||
c._path = [...c._path, p];
|
||||
if (c._path.length === before) break;
|
||||
}
|
||||
out.pointsAccepted = c._path.length === 4;
|
||||
// замыкание: проверка пересечений должна пройти (вложенность легальна)
|
||||
const clash = c._overlapRoom([...c._path]);
|
||||
out.nestedAllowed = !clash;
|
||||
// частичное перекрытие всё ещё запрещено
|
||||
const partial = [[cx - g * 4, cy], [cx + g * 2, cy], [cx + g * 2, cy + g * 2], [cx - g * 4, cy + g * 2]];
|
||||
// сдвинем так, чтобы вылезло за границу r1: возьмём точку заведомо снаружи
|
||||
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 minX = Math.min(...poly1.map((p) => p[0]));
|
||||
const cross = [[minX - g * 2, cy], [cx, cy], [cx, cy + g * 2], [minX - g * 2, cy + g * 2]];
|
||||
out.partialStillRejected = !!c._overlapRoom(cross);
|
||||
// 2) сохранить остров как комнату без зоны (замкнуть контур)
|
||||
c._path = [...pts, pts[0]];
|
||||
c._nameSel = 'Колонна'; c._areaSel = '';
|
||||
c._commitRoom();
|
||||
await c.updateComplete;
|
||||
const island = c._spaceModel().rooms.find((r) => r.name === 'Колонна');
|
||||
out.islandSaved = !!island;
|
||||
// 3) внешняя комната рендерится как path с evenodd и дыркой
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
// включим границы, чтобы r1 рендерился
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== c._space ? s : ({
|
||||
...s, settings: { ...(s.settings || {}), show_borders: true } })) };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const evenodd = [...sr().querySelectorAll('path.room')].find((p) => p.getAttribute('fill-rule') === 'evenodd');
|
||||
out.evenoddPath = !!evenodd;
|
||||
out.holeInPath = evenodd ? (evenodd.getAttribute('d').match(/M /g) || []).length === 2 : null;
|
||||
// 4) остров кликабелен (элемент существует и он поверх дырки)
|
||||
const islandEl = [...sr().querySelectorAll('.room')].find((el) => {
|
||||
const d = el.getAttribute('points') || el.getAttribute('d') || '';
|
||||
return d.includes(String(cx)) && el !== evenodd;
|
||||
});
|
||||
out.islandRendered = !!islandEl;
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -0,0 +1,73 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c0 = window.__card;
|
||||
// создать киоск-экземпляр
|
||||
const c = document.createElement('houseplan-card');
|
||||
c.setConfig({ type: 'custom:houseplan-card', kiosk: true, cycle: 0 });
|
||||
c.hass = c0.hass;
|
||||
document.body.appendChild(c);
|
||||
c.style.cssText = 'position:fixed;left:0;top:0;width:900px;height:700px;z-index:99';
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
c.hass = { ...c0.hass };
|
||||
await c.updateComplete;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
// 1) шапка скрыта
|
||||
const hdr = sr().querySelector('.hdr');
|
||||
out.headerHidden = !hdr || getComputedStyle(hdr).display === 'none';
|
||||
// 2) редакторы заблокированы
|
||||
c._setMode('plan'); await c.updateComplete;
|
||||
out.editorsBlocked = c._mode === 'view';
|
||||
// 3) свайп при 1:1 переключает пространство (и точки показываются)
|
||||
const s0 = c._space;
|
||||
const stage = sr().querySelector('.stage');
|
||||
stage.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true, pointerId: 1, clientX: 600, clientY: 300 }));
|
||||
stage.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, composed: true, pointerId: 1, clientX: 450, clientY: 305 }));
|
||||
await c.updateComplete;
|
||||
out.swipeSwitches = c._space !== s0;
|
||||
out.dotsShown = !!sr().querySelector('.kioskdots');
|
||||
// 4) при зуме свайп не переключает
|
||||
c._zoom = 2; const s1 = c._space;
|
||||
stage.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true, pointerId: 2, clientX: 600, clientY: 300 }));
|
||||
stage.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, composed: true, pointerId: 2, clientX: 450, clientY: 305 }));
|
||||
await c.updateComplete;
|
||||
out.noSwipeZoomed = c._space === s1;
|
||||
// 5) двойной тап сбрасывает зум
|
||||
c._lastTap = Date.now() - 100;
|
||||
stage.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true, pointerId: 3, clientX: 500, clientY: 300 }));
|
||||
stage.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, composed: true, pointerId: 3, clientX: 501, clientY: 300 }));
|
||||
await c.updateComplete;
|
||||
out.dblTapResets = c._zoom === 1;
|
||||
// 6) локальный множитель иконок применяется
|
||||
c._saveKioskScale({ icon: 2 }); await c.updateComplete;
|
||||
const dl = sr().querySelector('.devlayer');
|
||||
const size1 = dl.getAttribute('style');
|
||||
c._saveKioskScale({ icon: 1 }); await c.updateComplete;
|
||||
const size2 = sr().querySelector('.devlayer').getAttribute('style');
|
||||
const v1 = parseFloat(size1.match(/--icon-size:([\d.]+)/)[1]);
|
||||
const v2 = parseFloat(size2.match(/--icon-size:([\d.]+)/)[1]);
|
||||
out.iconScaleWorks = Math.abs(v1 / v2 - 2) < 0.01;
|
||||
out.persisted = JSON.parse(localStorage.getItem('houseplan_card_kiosk_v1')).icon === 1;
|
||||
// 7) попап настроек экрана открывается (прямой вызов; долгое нажатие проверено таймером)
|
||||
c._kioskDialog = true; await c.updateComplete;
|
||||
out.dialogRenders = !!sr().querySelector('.dialog input[type="range"]');
|
||||
c._kioskDialog = false;
|
||||
// 8) карусель: тик двигает пространство, пауза после касания работает
|
||||
const cs = c._space;
|
||||
c._config = { ...c._config, cycle: 1 };
|
||||
c._cyclePausedUntil = 0;
|
||||
c._cycleTick(); await c.updateComplete;
|
||||
out.cycleAdvances = c._space !== cs;
|
||||
c._cyclePausedUntil = Date.now() + 60000;
|
||||
const cs2 = c._space;
|
||||
c._cycleTick(); await c.updateComplete;
|
||||
out.cyclePaused = c._space === cs2;
|
||||
// 9) обычная карточка (не киоск): шапка на месте, свайпа нет
|
||||
const sr0 = c0.shadowRoot || c0.renderRoot;
|
||||
out.normalHeader = !!sr0.querySelector('.hdr') && getComputedStyle(sr0.querySelector('.hdr')).display !== 'none';
|
||||
c.remove();
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -0,0 +1,103 @@
|
||||
// HP-1460-03: позиции — отдельное состояние. В v1.46.0 событие layout_updated
|
||||
// научилась слушать статическая карточка, а полная — нет, поэтому две полные
|
||||
// карточки рядом расходились до перезагрузки. Проверяем и обратное: приход
|
||||
// чужой ревизии не должен затирать перетаскивание, которое ещё не улетело.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const base = c.hass.callWS;
|
||||
|
||||
// общий «сервер»: layout с ревизией и подписчики на событие
|
||||
let rev = 5;
|
||||
let layout = { dev_a: { x: 10, y: 10 }, dev_b: { x: 20, y: 20 } };
|
||||
const subs = [];
|
||||
let gets = 0;
|
||||
const hass = { ...c.hass,
|
||||
callWS: async (m) => {
|
||||
if (m.type === 'houseplan/layout/get') { gets++; return { layout: JSON.parse(JSON.stringify(layout)), rev }; }
|
||||
if (m.type === 'houseplan/layout/update') {
|
||||
layout = { ...layout, [m.device_id]: m.pos }; rev += 1;
|
||||
subs.forEach((f) => f({ data: { rev } }));
|
||||
return { ok: true, rev };
|
||||
}
|
||||
return base(m);
|
||||
},
|
||||
connection: { subscribeEvents: async (cb, ev) => {
|
||||
if (ev === 'houseplan_layout_updated') { subs.push(cb); return () => {}; }
|
||||
return () => {};
|
||||
} },
|
||||
};
|
||||
c.hass = hass;
|
||||
c._serverStorage = true;
|
||||
c._layout = JSON.parse(JSON.stringify(layout));
|
||||
c._layoutRev = rev;
|
||||
c._unsubLayout = await hass.connection.subscribeEvents(
|
||||
(e) => c._onLayoutEvent(Number(e?.data?.rev ?? -1)),
|
||||
'houseplan_layout_updated',
|
||||
);
|
||||
out.subscribed = subs.length === 1;
|
||||
|
||||
// 1) чужая карточка подвинула иконку — наша обязана подхватить без перезагрузки
|
||||
layout = { ...layout, dev_a: { x: 77, y: 88 } }; rev += 1;
|
||||
subs.forEach((f) => f({ data: { rev } }));
|
||||
await new Promise((r) => setTimeout(r, 350));
|
||||
out.adoptedRemoteMove = JSON.stringify(c._layout.dev_a) === JSON.stringify({ x: 77, y: 88 });
|
||||
out.revFollowed = c._layoutRev === rev;
|
||||
|
||||
// 2) собственная запись не вызывает лишнего перечитывания
|
||||
const before = gets;
|
||||
c._layout = { ...c._layout, dev_b: { x: 31, y: 32 } };
|
||||
c._dirtyPos.add('dev_b');
|
||||
c._persistLayout();
|
||||
c._persistLayout.flush();
|
||||
await new Promise((r) => setTimeout(r, 350));
|
||||
out.ownWriteNoReload = gets === before;
|
||||
out.ownWriteKept = JSON.stringify(c._layout.dev_b) === JSON.stringify({ x: 31, y: 32 });
|
||||
|
||||
// 3) НАСТОЯЩЕЕ перетаскивание: debounce запланирован, запись задержана, а
|
||||
// layout/get отвечает мгновенно. Именно этот порядок и терял позицию:
|
||||
// flush() внутри перечитывания опустошал _dirtyPos ДО снятия снимка.
|
||||
let releaseUpdate;
|
||||
const updateGate = new Promise((r) => { releaseUpdate = r; });
|
||||
let delayUpdate = true;
|
||||
const plain = hass.callWS;
|
||||
c.hass = { ...hass, callWS: async (m) => {
|
||||
if (m.type === 'houseplan/layout/update' && delayUpdate) {
|
||||
delayUpdate = false;
|
||||
await updateGate;
|
||||
}
|
||||
return plain(m);
|
||||
} };
|
||||
|
||||
c._layout = { ...c._layout, dev_a: { x: 5, y: 6 } };
|
||||
c._dirtyPos.add('dev_a');
|
||||
c._persistLayout(); // debounce запланирован, не сброшен вручную
|
||||
layout = { ...layout, dev_b: { x: 99, y: 99 } }; rev += 1;
|
||||
subs.forEach((f) => f({ data: { rev } }));
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
out.dragKeptWhileWriteInFlight = JSON.stringify(c._layout.dev_a) === JSON.stringify({ x: 5, y: 6 });
|
||||
out.remoteChangeApplied = JSON.stringify(c._layout.dev_b) === JSON.stringify({ x: 99, y: 99 });
|
||||
|
||||
releaseUpdate();
|
||||
await new Promise((r) => setTimeout(r, 350));
|
||||
out.localDragSurvived = JSON.stringify(c._layout.dev_a) === JSON.stringify({ x: 5, y: 6 });
|
||||
out.serverAgrees = JSON.stringify(layout.dev_a) === JSON.stringify({ x: 5, y: 6 });
|
||||
out.sentPosDrained = c._sentPos.size === 0;
|
||||
return out;
|
||||
});
|
||||
// зафиксировано прогоном на v1.46.1 и сверено с кодом
|
||||
checkAll(res, {
|
||||
subscribed: true,
|
||||
adoptedRemoteMove: true,
|
||||
revFollowed: true,
|
||||
ownWriteNoReload: true,
|
||||
ownWriteKept: true,
|
||||
dragKeptWhileWriteInFlight: true,
|
||||
remoteChangeApplied: true,
|
||||
localDragSurvived: true,
|
||||
serverAgrees: true,
|
||||
sentPosDrained: true,
|
||||
});
|
||||
await finish(browser);
|
||||
@@ -0,0 +1,34 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const calls = [];
|
||||
c.hass = { ...c.hass, callService: (d, s, data) => { calls.push([d, s, data.entity_id]); return Promise.resolve(); } };
|
||||
await c.updateComplete;
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
// лампа (primary light) без явного действия → клик = toggle
|
||||
const lamp = c._devices.find((d) => d.primary?.startsWith('light.') && !d.tapAction);
|
||||
out.hasLamp = !!lamp;
|
||||
c._infoCard = null;
|
||||
c._clickDevice(new MouseEvent('click'), lamp);
|
||||
out.lampToggles = JSON.stringify(calls.at(-1)) === JSON.stringify(['homeassistant', 'toggle', lamp.primary]);
|
||||
// устройство с не-light primary (сенсор) → клик = инфо
|
||||
const sensorDev = c._devices.find((d) => d.primary?.startsWith('sensor.') && !d.tapAction);
|
||||
const n = calls.length;
|
||||
c._infoCard = null;
|
||||
c._clickDevice(new MouseEvent('click'), sensorDev);
|
||||
out.sensorInfo = calls.length === n && !!c._infoCard;
|
||||
c._infoCard = null;
|
||||
// явный info у лампы отключает дефолт
|
||||
const cfgm = { id: lamp.id, binding: lamp.bindingKind + ':' + lamp.bindingRef, tap_action: 'info' };
|
||||
c._serverCfg = { ...c._serverCfg, markers: [...(c._serverCfg.markers || []).filter((m) => m.id !== lamp.id), cfgm] };
|
||||
c._regSignature = ''; c._maybeRebuildDevices(); await c.updateComplete;
|
||||
const lamp2 = c._devices.find((d) => d.id === lamp.id);
|
||||
const n2 = calls.length;
|
||||
c._clickDevice(new MouseEvent('click'), lamp2);
|
||||
out.explicitInfoWins = calls.length === n2 && !!c._infoCard;
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -0,0 +1,52 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const calls = [];
|
||||
window.confirm = () => true; // review CR-1: unlocking now confirms
|
||||
c.hass = { ...c.hass, callService: (d, s, data) => calls.push([d, s, data.entity_id]) };
|
||||
await c.updateComplete;
|
||||
// добавить дверь с замком на f1
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== 'f1' ? s : ({
|
||||
...s, openings: [{ id: 'op1', type: 'door', rx: 550, ry: 200, angle: 90, len_cm: 90, lock: 'lock.front_door' }] })) };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const op = c._spaceModel()?.openings?.[0] || { id: 'op1', type: 'door', rx: 550, ry: 200, len_cm: 90, lock: 'lock.front_door' };
|
||||
out.hasLockedDoor = !!op.lock;
|
||||
const lockId = op.lock;
|
||||
// 1) замок закрыт → кнопка "Unlock"
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, [lockId]: { state: 'locked', attributes: {} } } };
|
||||
c._openingInfo = op; await c.updateComplete;
|
||||
let btn = sr().querySelector('.btn.lockact');
|
||||
out.unlockBtnShown = !!btn && !btn.disabled;
|
||||
out.unlockBtnDanger = btn?.classList.contains('warn');
|
||||
btn.click();
|
||||
out.unlockCalled = JSON.stringify(calls.at(-1)) === JSON.stringify(['lock', 'unlock', lockId]);
|
||||
// 2) замок открыт → кнопка "Lock"
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, [lockId]: { state: 'unlocked', attributes: {} } } };
|
||||
await c.updateComplete;
|
||||
btn = sr().querySelector('.btn.lockact');
|
||||
btn.click();
|
||||
out.lockCalled = JSON.stringify(calls.at(-1)) === JSON.stringify(['lock', 'lock', lockId]);
|
||||
// 3) переходное состояние → кнопка задизейблена, сервис не зовётся
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, [lockId]: { state: 'unlocking', attributes: {} } } };
|
||||
await c.updateComplete;
|
||||
btn = sr().querySelector('.btn.lockact');
|
||||
out.pendingDisabled = !!btn && btn.disabled;
|
||||
// 4) недоступен → кнопки нет
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, [lockId]: { state: 'unavailable', attributes: {} } } };
|
||||
await c.updateComplete;
|
||||
out.noBtnWhenUnavailable = !sr().querySelector('.btn.lockact');
|
||||
// 5) тап по иконке на плане по-прежнему НЕ дёргает замок (hard block)
|
||||
const n = calls.length;
|
||||
const dev = c._devices.find((d) => d.domains?.includes('lock'));
|
||||
out.planTapStillBlocked = true;
|
||||
if (dev) {
|
||||
c._clickDevice(new MouseEvent('click'), dev); await c.updateComplete;
|
||||
out.planTapStillBlocked = !calls.slice(n).some((x) => x[0] === 'lock' && (x[1] === 'lock' || x[1] === 'unlock'));
|
||||
}
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -0,0 +1,48 @@
|
||||
// review CR-1: exercise EVERY actuation path and prove locks/alarms are safe
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const calls = [];
|
||||
c.hass = { ...c.hass, callService: (d, s, data) => { calls.push(`${d}.${s}:${data.entity_id}`); return Promise.resolve(); },
|
||||
states: { ...c.hass.states,
|
||||
'lock.front_door': { state: 'locked', attributes: { friendly_name: 'Front door' } },
|
||||
'alarm_control_panel.home': { state: 'armed_away', attributes: {} } } };
|
||||
await c.updateComplete;
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
const lockCalls = () => calls.filter((x) => x.includes('lock.') || x.includes('alarm_control_panel.'));
|
||||
// 1) тап по значку устройства с замком
|
||||
const lockDev = c._devices.find((d) => d.entities?.some((e) => e.startsWith('lock.'))) || c._devices[0];
|
||||
const fake = { ...lockDev, primary: 'lock.front_door', tapAction: 'toggle',
|
||||
marker: { ...(lockDev.marker || {}), tap_action: 'toggle' } };
|
||||
c._clickDevice(new MouseEvent('click'), fake);
|
||||
out.iconTapSafe = lockCalls().length === 0;
|
||||
// 2) controls[] с замком внутри
|
||||
const withControls = { ...fake, tapAction: 'toggle',
|
||||
marker: { controls: ['lock.front_door', 'alarm_control_panel.home'], tap_action: 'toggle' } };
|
||||
c._clickDevice(new MouseEvent('click'), withControls);
|
||||
out.controlsSafe = lockCalls().length === 0;
|
||||
// 3) карточка устройства: замок отдаётся в more-info, а не тумблером
|
||||
const kinds = c._cardEntities({ ...fake, entities: ['lock.front_door', 'alarm_control_panel.home'] });
|
||||
out.cardNoToggleForLocks = kinds.every((k) => k.kind !== 'toggle');
|
||||
c._cardToggle('lock.front_door');
|
||||
c._cardToggle('alarm_control_panel.home');
|
||||
out.cardToggleRefuses = lockCalls().length === 0;
|
||||
// 4) кнопка в карточке двери — единственная разрешённая поверхность, и спрашивает подтверждение
|
||||
let asked = null;
|
||||
window.confirm = (msg) => { asked = msg; return false; };
|
||||
c._lockAction('lock.front_door', 'unlock');
|
||||
out.unlockAsksConfirm = asked !== null && lockCalls().length === 0;
|
||||
window.confirm = () => true;
|
||||
c._lockAction('lock.front_door', 'unlock');
|
||||
out.unlockAfterConfirm = calls.at(-1) === 'lock.unlock:lock.front_door';
|
||||
// запирание не спрашивает
|
||||
asked = null;
|
||||
window.confirm = (m) => { asked = m; return true; };
|
||||
c._lockAction('lock.front_door', 'lock');
|
||||
out.lockNoConfirm = asked === null && calls.at(-1) === 'lock.lock:lock.front_door';
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -0,0 +1,45 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
const near = (a, b) => a && b && Math.abs(a.x - b.x) < 1e-6 && Math.abs(a.y - b.y) < 1e-6 && a.s === b.s;
|
||||
// подопытный: авто-устройство с сохранённой позицией
|
||||
const dev = c._devices.find((d) => !d.virtual && !d.marker && c._layout[d.id]);
|
||||
out.hasDev = !!dev;
|
||||
const pos0 = { ...c._layout[dev.id] };
|
||||
// 1) смена комнаты (та же space) → позиция не меняется
|
||||
const otherRoom = c._spaceModel(dev.space).rooms.find((r) => r.id && r.area && r.area !== dev.area);
|
||||
out.hasOtherRoom = !!otherRoom;
|
||||
c._openMarkerDialog(dev);
|
||||
c._markerDialog = { ...c._markerDialog, room: dev.space + '#' + otherRoom.area };
|
||||
await c._saveMarker(); await c.updateComplete;
|
||||
out.stayAfterRoomChange = near(c._layout[dev.id], pos0);
|
||||
// вернуть комнату назад
|
||||
c._openMarkerDialog(c._devices.find((d) => d.id === dev.id));
|
||||
c._markerDialog = { ...c._markerDialog, room: dev.space + '#' + dev.area };
|
||||
await c._saveMarker(); await c.updateComplete;
|
||||
// 2) смена привязки device -> entity (id меняется) → позиция мигрирует
|
||||
const freeEnt = Object.keys(c.hass.states).find((e) => e.startsWith('sensor.') &&
|
||||
!c._devices.some((d) => d.id === 'lg_' + e));
|
||||
out.hasFreeEnt = !!freeEnt;
|
||||
c._openMarkerDialog(c._devices.find((d) => d.id === dev.id));
|
||||
c._markerDialog = { ...c._markerDialog, binding: 'entity:' + freeEnt };
|
||||
await c._saveMarker(); await c.updateComplete;
|
||||
const newId = 'lg_' + freeEnt;
|
||||
out.migrated = near(c._layout[newId], pos0);
|
||||
out.oldGone = !c._layout[dev.id];
|
||||
// 3) новый виртуальный маркер по-прежнему центрируется в выбранной комнате
|
||||
c._openMarkerDialog();
|
||||
const room = c._spaceModel().rooms.find((r) => r.id && r.area);
|
||||
c._markerDialog = { ...c._markerDialog, name: 'Тест', binding: 'virtual', room: c._space + '#' + room.area };
|
||||
await c._saveMarker(); await c.updateComplete;
|
||||
const vid = c._serverCfg.markers.find((m) => m.name === 'Тест')?.id;
|
||||
const center = c._roomCenter(room);
|
||||
const vpos = c._layout[vid];
|
||||
out.newCentered = vpos && Math.abs(vpos.x * 1000 - center[0]) < 1 && Math.abs(vpos.y * 1000 - center[1]) < 1;
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -0,0 +1,24 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
c._setMode('plan'); c._tool = 'merge'; await c.updateComplete;
|
||||
const room = c._spaceModel().rooms.find((r) => r.name);
|
||||
c._mergeSel = room.id; c.requestUpdate(); await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 250)); // дождаться transition 0.12s
|
||||
const el = [...sr().querySelectorAll('.room')].find((e) => e.classList.contains('picked'));
|
||||
out.pickedRendered = !!el;
|
||||
const cs = el ? getComputedStyle(el) : null;
|
||||
out.amberStroke = cs ? cs.stroke.includes('255, 193, 77') : null;
|
||||
out.amberFill = cs ? cs.fill.includes('255, 193, 77') : null;
|
||||
// split-выбор подсвечивается так же
|
||||
c._mergeSel = null; c._tool = 'split'; c._splitSel = { roomId: room.id, pts: [] }; c.requestUpdate(); await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
const el2 = [...sr().querySelectorAll('.room')].find((e) => e.classList.contains('picked'));
|
||||
out.splitPicked = !!el2 && getComputedStyle(el2).stroke.includes('255, 193, 77');
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -1,5 +1,5 @@
|
||||
// Merge & split room ops (v1.21.0) via the card's markup handlers (norm coords).
|
||||
import { launch } from './serve.mjs';
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const snap = await page.evaluate(() => JSON.stringify(window.__card._serverCfg));
|
||||
const restore = () => page.evaluate((s) => {
|
||||
@@ -9,14 +9,14 @@ 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;
|
||||
return { rooms: c._serverCfg.spaces.find((s) => s.id==='f1').rooms.map((r)=>({id:r.id,name:r.name,area:r.area})),
|
||||
mergeDlg: !!c._mergeDialog, roomDlg: !!c._roomDialog, pendingSplit: !!c._pendingSplit, toast: c._toast };
|
||||
});
|
||||
const enter = (t) => page.evaluate((t)=>{const c=window.__card; if(!c._markup)c._toggleMarkup(); c._tool=t; return true;}, t);
|
||||
const enter = (t) => page.evaluate((t)=>{const c=window.__card; if(!c._markup)c._setMode('plan'); c._tool=t; return true;}, t);
|
||||
const out = {};
|
||||
|
||||
// MERGE living(r1)+kitchen(r2) — share wall x=0.55, y∈[0.05..0.45]
|
||||
@@ -56,14 +56,14 @@ out.splitPending = (await S()).pendingSplit;
|
||||
out.splitDialog = (await S()).roomDlg;
|
||||
// cancel keeps it whole
|
||||
await page.evaluate(()=>window.__card._roomDialogCancel());
|
||||
out.cancelWhole = (await S()).rooms.length===4;
|
||||
out.cancelWhole = (await S()).rooms.length===5; // 4 базовых + rg
|
||||
// redo + confirm with a name (no area)
|
||||
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.28,0.28));
|
||||
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.25,0.0625));
|
||||
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.25,0.5));
|
||||
await page.evaluate(()=>{const c=window.__card; c._nameSel='Cabinet'; c._saveRoomNoArea();});
|
||||
s = await S();
|
||||
out.splitRooms = s.rooms.length; // 5
|
||||
out.splitRooms = s.rooms.length===6; // 4 базовых + rg разрезанная надвое
|
||||
out.bigKeepsLiving = s.rooms.some(r=>r.id==='r1' && r.area==='living_room');
|
||||
out.newRoom = s.rooms.find(r=>!['r1','r2','r3','r4','rg'].includes(r.id))?.name;
|
||||
await restore();
|
||||
@@ -79,5 +79,9 @@ await page.evaluate((p)=>window.__card._splitClick(p), await R(0.4,0.0625));
|
||||
s = await S();
|
||||
out.alongWallRefused = !s.roomDlg && !s.pendingSplit;
|
||||
|
||||
console.log(JSON.stringify(out,null,1));
|
||||
await browser.close();
|
||||
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
||||
checkAll(out, {
|
||||
"mergeRooms": 3,
|
||||
"newRoom": "Cabinet",
|
||||
});
|
||||
await finish(browser, out);
|
||||
|
||||
+10
-3
@@ -1,5 +1,5 @@
|
||||
// UX modes shell (v1.25.0): view is display-only; plan/devices gate the tools.
|
||||
import { launch } from './serve.mjs';
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const out = {};
|
||||
const q = (sel) => page.evaluate((s) => (window.__card.shadowRoot || window.__card.renderRoot).querySelectorAll(s).length, sel);
|
||||
@@ -75,5 +75,12 @@ out.devClickOpensEditor = await page.evaluate(async () => {
|
||||
// 5) назад в view
|
||||
await page.evaluate(() => window.__card._setMode('view'));
|
||||
out.backToView = (await st()).mode;
|
||||
console.log(JSON.stringify(out, null, 1));
|
||||
await browser.close();
|
||||
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
||||
checkAll(out, {
|
||||
"start": {"mode": "view", "modeTabs": 3, "editBtns": 1, "gears": 2, "markupBar": false, "stageClass": "stage mode-view"},
|
||||
"viewDragMoved": false,
|
||||
"plan": {"mode": "plan", "modeTabs": 3, "active": "Plan editor", "editBtns": 1, "gears": 2, "markupBar": true, "stageClass": "stage markup tool-draw mode-plan"},
|
||||
"devices": {"mode": "devices", "modeTabs": 3, "active": "Device editor", "editBtns": 1, "gears": 2, "markupBar": true, "stageClass": "stage mode-devices"},
|
||||
"backToView": "view",
|
||||
});
|
||||
await finish(browser, out);
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
// выбрать f2 и редактор устройств
|
||||
c._space = 'garden'; c._setMode('devices'); await c.updateComplete;
|
||||
const nav = JSON.parse(localStorage.getItem('houseplan_card_nav_v1'));
|
||||
out.saved = nav.space === 'garden' && nav.mode === 'devices';
|
||||
// пересоздать карточку (эмуляция закрытия вкладки; кэш конфига в LS уже есть)
|
||||
const c2 = document.createElement('houseplan-card');
|
||||
c2.setConfig({ type: 'custom:houseplan-card' });
|
||||
c2.hass = c.hass;
|
||||
document.body.appendChild(c2);
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
c2.hass = { ...c.hass };
|
||||
await c2.updateComplete;
|
||||
out.spaceRestored = c2._space === 'garden';
|
||||
out.modeRestored = c2._mode === 'devices';
|
||||
// хэш приоритетнее сохранённого
|
||||
location.hash = '#space=f1';
|
||||
const c3 = document.createElement('houseplan-card');
|
||||
c3.setConfig({ type: 'custom:houseplan-card' });
|
||||
c3.hass = c.hass;
|
||||
document.body.appendChild(c3);
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
out.hashWins = c3._space === 'f1';
|
||||
location.hash = '';
|
||||
c2.remove(); c3.remove();
|
||||
// вернуть исходное
|
||||
c._setMode('view'); c._space = 'f1'; c._saveNav(); await c.updateComplete;
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -0,0 +1,32 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
// 1) первый запуск: базлайн засеян молча, точек нет
|
||||
out.baselineSeeded = Array.isArray(c._serverCfg.settings.known_devices) && c._serverCfg.settings.known_devices.length > 0;
|
||||
out.noDotsOnFirstRun = sr().querySelectorAll('.newdot').length === 0;
|
||||
// 2) в HA появилось новое устройство
|
||||
const h = { ...c.hass };
|
||||
h.devices = { ...h.devices, d_new: { id: 'd_new', name: 'Brand new plug', model: 'Smart Plug', area_id: 'kitchen', identifiers: [['demo', 'd_new']] } };
|
||||
h.entities = { ...h.entities, 'switch.newplug': { entity_id: 'switch.newplug', device_id: 'd_new', platform: 'demo' } };
|
||||
h.states = { ...h.states, 'switch.newplug': { state: 'off', attributes: { friendly_name: 'Brand new plug' } } };
|
||||
c.hass = h; await c.updateComplete; c.requestUpdate(); await c.updateComplete;
|
||||
out.flagged = c._serverCfg.settings.new_device_ids?.includes('d_new');
|
||||
out.dotShown = sr().querySelectorAll('.newdot').length === 1;
|
||||
// 3) существующие не флагуются
|
||||
out.onlyOne = (c._serverCfg.settings.new_device_ids || []).length === 1;
|
||||
// 4) открытие редактора снимает флаг
|
||||
const dev = c._devices.find((d) => d.id === 'd_new');
|
||||
c._setMode('devices');
|
||||
c._openMarkerDialog(dev); await c.updateComplete;
|
||||
c._markerDialog = null; c._setMode('view'); await c.updateComplete;
|
||||
out.ackCleared = !(c._serverCfg.settings.new_device_ids || []).includes('d_new');
|
||||
out.dotGone = sr().querySelectorAll('.newdot').length === 0;
|
||||
// 5) known растёт, повторно не флагуется
|
||||
out.stillKnown = c._serverCfg.settings.known_devices.includes('d_new');
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -0,0 +1,43 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
c._setMode('plan'); c._tool = 'opening'; await c.updateComplete;
|
||||
const rooms = c._spaceModel().rooms;
|
||||
const r = rooms.find((x) => x.id === 'r1');
|
||||
const poly = r.poly || [[r.x, r.y], [r.x + r.w, r.y], [r.x + r.w, r.y + r.h], [r.x, r.y + r.h]];
|
||||
const wallMid = [(poly[0][0] + poly[1][0]) / 2, (poly[0][1] + poly[1][1]) / 2];
|
||||
// 1) курсор возле стены → пунктирный призрак есть
|
||||
c._cursorPt = [wallMid[0], wallMid[1] + c._gridPitch * 0.5]; c.requestUpdate(); await c.updateComplete;
|
||||
const ghost = sr().querySelector('.opghost');
|
||||
out.ghostNearWall = !!ghost;
|
||||
if (ghost) {
|
||||
const cs = getComputedStyle(ghost);
|
||||
out.dashed = cs.strokeDasharray !== 'none' && cs.strokeDasharray !== '';
|
||||
// призрак лежит на стене (y координаты концов совпадают с y стены)
|
||||
out.onWall = Math.abs(Number(ghost.getAttribute('y1')) - poly[0][1]) < 0.5
|
||||
&& Math.abs(Number(ghost.getAttribute('y2')) - poly[0][1]) < 0.5;
|
||||
// длина = 90 см в рендер-единицах
|
||||
const len = Math.hypot(ghost.getAttribute('x2') - ghost.getAttribute('x1'), ghost.getAttribute('y2') - ghost.getAttribute('y1'));
|
||||
out.len90cm = Math.abs(len - c._cmToUnits(90)) < 0.5;
|
||||
}
|
||||
// 2) курсор далеко от стен → призрака нет
|
||||
const center = c._roomCenter(r);
|
||||
c._cursorPt = center; c.requestUpdate(); await c.updateComplete;
|
||||
out.noGhostFarFromWall = !sr().querySelector('.opghost');
|
||||
// 3) в других инструментах призрака нет
|
||||
c._tool = 'draw'; c._cursorPt = [wallMid[0], wallMid[1] + 2]; c.requestUpdate(); await c.updateComplete;
|
||||
out.noGhostOtherTool = !sr().querySelector('.opghost');
|
||||
// 4) над существующим проёмом призрак не рисуется (там клик = редактирование)
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== c._space ? s : ({
|
||||
...s, openings: [{ id: 'op9', type: 'door', x: wallMid[0] / 1000, y: wallMid[1] / (1000 / (c._curSpaceCfg.aspect || 1)) * (c._curSpaceCfg.aspect ? 1 : 1), angle: 0, length: 0.09 }] })) };
|
||||
c._tool = 'opening'; c.requestUpdate(); await c.updateComplete;
|
||||
const op = c._openingsR[0];
|
||||
c._cursorPt = [op.rx, op.ry]; c.requestUpdate(); await c.updateComplete;
|
||||
out.noGhostOverExisting = !sr().querySelector('.opghost');
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -0,0 +1,73 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
c._setMode('plan'); c._tool = 'openwall'; await c.updateComplete;
|
||||
// r1|r2 делят стену x=0.55 → клик по ней открывает границу
|
||||
const H = 1000; // square canvas
|
||||
c._openWallClick([550, 0.25 * H]);
|
||||
await c.updateComplete;
|
||||
const r1 = c._curSpaceCfg.rooms.find((r) => r.id === 'r1');
|
||||
const r2 = c._curSpaceCfg.rooms.find((r) => r.id === 'r2');
|
||||
out.linked = (r1.open_to || []).includes('r2') && (r2.open_to || []).includes('r1');
|
||||
// пунктир отрисован
|
||||
out.dashes = sr().querySelectorAll('.openwall').length > 0;
|
||||
out.hotClass = !!sr().querySelector('.openwalls.hot');
|
||||
// в Просмотре: сплошной штрих комнаты снят, контур без выреза + пунктир ПОВЕРХ glow
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
out.noedge = sr().querySelectorAll('.room.noedge').length >= 2;
|
||||
out.trimmedOutline = sr().querySelectorAll('.room-outline').length >= 2;
|
||||
const svgEl = sr().querySelector('svg');
|
||||
const order = [...svgEl.children].map((el) => el.classList?.[0] || el.tagName);
|
||||
const gi = order.indexOf('glowlayer');
|
||||
const oi = order.indexOf('openwalls');
|
||||
out.dashAboveGlow = gi === -1 || oi > gi;
|
||||
// в редакторе плана: пунктир виден, штрих комнат вырезан, контур синий
|
||||
c._setMode('plan'); c._tool = 'draw'; await c.updateComplete;
|
||||
out.planDashes = sr().querySelectorAll('.openwall').length > 0;
|
||||
out.planNoedge = sr().querySelectorAll('.room.noedge').length >= 2;
|
||||
out.planBlueOutline = sr().querySelectorAll('.room-outline.outlined').length >= 2;
|
||||
// производные стены (.seg) не проходят сквозь открытый участок x=550, y≈0.25H
|
||||
const midY = 0.25 * H;
|
||||
out.planSegCut = ![...sr().querySelectorAll('line.seg')].some((l) => {
|
||||
const x1 = +l.getAttribute('x1'), x2 = +l.getAttribute('x2');
|
||||
const y1 = +l.getAttribute('y1'), y2 = +l.getAttribute('y2');
|
||||
return Math.abs(x1 - 550) < 0.5 && Math.abs(x2 - 550) < 0.5
|
||||
&& Math.min(y1, y2) < midY && Math.max(y1, y2) > midY;
|
||||
});
|
||||
c._tool = 'openwall'; await c.updateComplete;
|
||||
// повторный клик закрывает
|
||||
c._openWallClick([550, 0.25 * H]); await c.updateComplete;
|
||||
out.toggledOff = !(c._curSpaceCfg.rooms.find((r) => r.id === 'r1').open_to || []).includes('r2');
|
||||
out.dashesGone = sr().querySelectorAll('.openwall').length === 0;
|
||||
// клик мимо стен — тост, без изменений
|
||||
c._openWallClick([100, 100]);
|
||||
out.missToast = !!c._toast;
|
||||
// снова открыть для glow-теста
|
||||
c._openWallClick([550, 0.25 * H]); await c.updateComplete;
|
||||
// glow: свет из r1 заливает и r2 (clip содержит оба полигона)
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== c._space ? s : ({
|
||||
...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'));
|
||||
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;
|
||||
// транзитивность: r2↔r3 тоже открыть → clip получит 3 полигона
|
||||
const rr2 = c._curSpaceCfg.rooms.find((r) => r.id === 'r2');
|
||||
const rr3 = c._curSpaceCfg.rooms.find((r) => r.id === 'r3');
|
||||
if (rr3) {
|
||||
rr2.open_to = [...(rr2.open_to || []), 'r3'];
|
||||
rr3.open_to = [...(rr3.open_to || []), 'r2'];
|
||||
c._regSignature = ''; c.requestUpdate(); await c.updateComplete;
|
||||
const clip2 = sr().querySelector('defs clipPath[id^="hp-glowclip"]');
|
||||
out.transitive = clip2 ? clip2.querySelectorAll('path').length >= 3 : false;
|
||||
} else out.transitive = 'no r3';
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -0,0 +1,36 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const stage = () => sr().querySelector('.stage');
|
||||
c._setMode('plan'); c._tool = 'openwall'; await c.updateComplete;
|
||||
const H = 1000; // square canvas
|
||||
// 1) без наведения: курсор default, превью нет
|
||||
c._cursorPt = null; c.requestUpdate(); await c.updateComplete;
|
||||
out.idleCursor = getComputedStyle(stage()).cursor === 'default';
|
||||
out.noPreview = !sr().querySelector('.openwall-preview');
|
||||
// 2) наведение на общую стену r1|r2 (x=550): pointer + янтарное превью
|
||||
c._cursorPt = [550, 0.25 * H]; c.requestUpdate(); await c.updateComplete;
|
||||
out.hotCursor = getComputedStyle(stage()).cursor === 'pointer';
|
||||
const prev = sr().querySelectorAll('.openwall-preview');
|
||||
out.previewShown = prev.length > 0;
|
||||
out.previewAmber = prev.length ? !prev[0].classList.contains('willclose') : null;
|
||||
// превью лежит на стене x=550
|
||||
out.previewOnWall = prev.length ? Math.abs(Number(prev[0].getAttribute('x1')) - 550) < 0.5 : null;
|
||||
// 3) наведение в центр комнаты: снова default, превью исчезло
|
||||
c._cursorPt = [300, 150]; c.requestUpdate(); await c.updateComplete;
|
||||
out.missCursor = getComputedStyle(stage()).cursor === 'default';
|
||||
out.missNoPreview = !sr().querySelector('.openwall-preview');
|
||||
// 4) открыть границу → навести снова: превью красное сплошное (клик закроет)
|
||||
c._openWallClick([550, 0.25 * H]); await c.updateComplete;
|
||||
c._cursorPt = [550, 0.25 * H]; c.requestUpdate(); await c.updateComplete;
|
||||
const prev2 = sr().querySelector('.openwall-preview');
|
||||
out.willclose = prev2?.classList.contains('willclose') === true;
|
||||
// прибрать
|
||||
c._openWallClick([550, 0.25 * H]); await c.updateComplete;
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -0,0 +1,76 @@
|
||||
// Подложка (фон плана) лежит за requires_auth-эндпоинтом: браузер не умеет
|
||||
// авторизовать <image href>, поэтому карточка просит бэкенд подписать путь.
|
||||
// Регрессия 2026-07-27: _display() вызывался внутри _buildModel(), а модель
|
||||
// мемоизируется по отпечатку конфига — неподписанный url «замерзал» в кэше,
|
||||
// подпись до <image> не доезжала. План не отображался никогда, а браузер
|
||||
// продолжал дёргать неподписанный путь → 401 → HA писал «неудачный вход»
|
||||
// с собственного IP пользователя.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const bgHref = () => {
|
||||
const im = sr().querySelector('.stage svg image');
|
||||
return im ? im.getAttribute('href') : null;
|
||||
};
|
||||
|
||||
let signCalls = 0;
|
||||
let release;
|
||||
const gate = new Promise((r) => { release = r; });
|
||||
const base = c.hass.callWS;
|
||||
c.hass = { ...c.hass, callWS: async (m) => {
|
||||
if (m.type === 'houseplan/content/sign') {
|
||||
signCalls++;
|
||||
const n = signCalls;
|
||||
if (n === 1) await gate;
|
||||
const urls = {};
|
||||
for (const p of m.paths) urls[p] = p.split('?')[0] + '?authSig=SIG' + n;
|
||||
return { urls };
|
||||
}
|
||||
return base(m);
|
||||
} };
|
||||
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== 'f1' ? s : {
|
||||
...s, plan_url: '/api/houseplan/content/plans/_/f1.svg?v=17831509',
|
||||
})};
|
||||
c._cfgEpoch++;
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
|
||||
// до подписи ничего не рисуем: неподписанный запрос вернул бы 401
|
||||
out.hrefBeforeSign = bgHref();
|
||||
|
||||
release();
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
await c.updateComplete;
|
||||
|
||||
// подпись доехала до атрибута, а не осела в кэше модели
|
||||
out.signRequested = signCalls;
|
||||
out.hrefSigned = bgHref();
|
||||
|
||||
// перерисовка по состоянию HA не теряет подпись и не просит её заново
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
out.hrefAfterRerender = bgHref();
|
||||
out.signRequestedAfterRerender = signCalls;
|
||||
|
||||
// ре-подпись на долгоживущем экране: старый url держится до нового ответа
|
||||
const before = bgHref();
|
||||
c._resign();
|
||||
out.resignKeepsPlan = bgHref() === before;
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
await c.updateComplete;
|
||||
out.hrefAfterResign = bgHref();
|
||||
return out;
|
||||
});
|
||||
// зафиксировано прогоном на v1.44.7 и сверено с кодом
|
||||
checkAll(res, {
|
||||
hrefBeforeSign: null,
|
||||
signRequested: 1,
|
||||
hrefSigned: '/api/houseplan/content/plans/_/f1.svg?authSig=SIG1',
|
||||
hrefAfterRerender: '/api/houseplan/content/plans/_/f1.svg?authSig=SIG1',
|
||||
signRequestedAfterRerender: 1,
|
||||
resignKeepsPlan: true,
|
||||
hrefAfterResign: '/api/houseplan/content/plans/_/f1.svg?authSig=SIG2',
|
||||
});
|
||||
await finish(browser);
|
||||
@@ -0,0 +1,70 @@
|
||||
// Загрузка подложки: ссылка обязана долететь до конфига.
|
||||
// Баг 2026-07-27 (найден на боевой установке): _saveSpaceDialog держал ссылку
|
||||
// на объект пространства через await загрузки файла. Любое событие
|
||||
// houseplan_config_updated в этот момент вызывает _reloadConfigOnly(), которое
|
||||
// ЗАМЕНЯЕТ _serverCfg — и plan_url/aspect/settings уезжали в осиротевший
|
||||
// объект, а на сервер уходил нетронутый конфиг. Симптом: файл на диске есть,
|
||||
// подложки нет, пересохранение не помогает.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const base = c.hass.callWS;
|
||||
let reloadDuringUpload = 0;
|
||||
|
||||
c.hass = { ...c.hass, callWS: async (m) => {
|
||||
if (m.type === 'houseplan/plan/set') {
|
||||
// пока файл «загружается», прилетает чужая ревизия конфига
|
||||
reloadDuringUpload++;
|
||||
await c._reloadConfigOnly(true);
|
||||
return { ok: true, url: '/api/houseplan/content/plans/_/' + m.space_id + '.png?v=42' };
|
||||
}
|
||||
if (m.type === 'houseplan/config/set') { c.__sent = m.config; return { ok: true, rev: 99 }; }
|
||||
if (m.type === 'houseplan/config/get') {
|
||||
// сервер отдаёт СВЕЖИЙ объект, а не тот же самый — как в реальном HA
|
||||
const r = await base(m);
|
||||
return { ...r, config: JSON.parse(JSON.stringify(r.config)) };
|
||||
}
|
||||
return base(m);
|
||||
} };
|
||||
|
||||
// редактирование существующего пространства: подложка + новый заголовок
|
||||
c._openSpaceDialog('edit', 'f1'); await c.updateComplete;
|
||||
c._spaceDialog = { ...c._spaceDialog, title: 'Ground', source: 'file',
|
||||
planFile: { ext: 'png', b64: 'AAAA', aspect: 1.6 } };
|
||||
await c._saveSpaceDialog(); await c.updateComplete;
|
||||
|
||||
out.reloadHappened = reloadDuringUpload === 1;
|
||||
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.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;
|
||||
|
||||
// создание пространства при том же сбое: оно должно доехать целиком
|
||||
c._openSpaceDialog('create'); await c.updateComplete;
|
||||
c._spaceDialog = { ...c._spaceDialog, title: 'Attic', source: 'file',
|
||||
planFile: { ext: 'png', b64: 'BBBB', aspect: 0.8 } };
|
||||
await c._saveSpaceDialog(); await c.updateComplete;
|
||||
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.atticPlanAspect = attic?.plan_aspect;
|
||||
return out;
|
||||
});
|
||||
// зафиксировано прогоном на v1.44.8 и сверено с кодом
|
||||
checkAll(res, {
|
||||
reloadHappened: true,
|
||||
sentPlanUrl: '/api/houseplan/content/plans/_/f1.png?v=42',
|
||||
sentPlanAspect: 1.6,
|
||||
sentTitle: 'Ground',
|
||||
livePlanUrl: '/api/houseplan/content/plans/_/f1.png?v=42',
|
||||
dialogClosed: true,
|
||||
atticSaved: true,
|
||||
atticHasPlan: true,
|
||||
atticPlanAspect: 0.8,
|
||||
});
|
||||
await finish(browser);
|
||||
@@ -0,0 +1,71 @@
|
||||
// Граница транзакции загрузки подложки (ревью R2-1, уточнено в R3-1).
|
||||
// Файл плана пишется на диск ДО проверки ревизии конфига, поэтому отвергнутое
|
||||
// сохранение не имеет права трогать сохранённый план. Со стороны карточки
|
||||
// контракт теперь такой: она НЕ управляет удалением файлов вообще — уборку
|
||||
// делает сам config/set под блокировкой (клиент не может упорядочить свою
|
||||
// уборку относительно чужого коммита, R3-1). Здесь проверяем, что карточка
|
||||
// не отправляет никаких команд удаления и корректно ведёт себя при отказе.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const base = c.hass.callWS;
|
||||
let uploads = 0;
|
||||
const cleanups = [];
|
||||
let rejectSave = true;
|
||||
|
||||
c.hass = { ...c.hass, callWS: async (m) => {
|
||||
if (m.type === 'houseplan/plan/set') {
|
||||
uploads++;
|
||||
return { ok: true, url: '/api/houseplan/content/plans/_/' + m.space_id + '.tok' + uploads + '.png' };
|
||||
}
|
||||
// любая команда удаления файлов от клиента — нарушение контракта R3-1
|
||||
if (m.type === 'houseplan/plan/cleanup' || m.type === 'houseplan/plan/delete') { cleanups.push(m); return { ok: true }; }
|
||||
if (m.type === 'houseplan/config/set') {
|
||||
if (rejectSave) { const e = new Error('conflict'); e.code = 'conflict'; throw e; }
|
||||
c.__sent = m.config; return { ok: true, rev: 77 };
|
||||
}
|
||||
if (m.type === 'houseplan/config/get') {
|
||||
const r = await base(m);
|
||||
return { ...r, config: JSON.parse(JSON.stringify(r.config)) };
|
||||
}
|
||||
return base(m);
|
||||
} };
|
||||
|
||||
const attach = async () => {
|
||||
c._openSpaceDialog('edit', 'f1'); await c.updateComplete;
|
||||
c._spaceDialog = { ...c._spaceDialog, title: 'Ground', source: 'file',
|
||||
planFile: { ext: 'png', b64: 'AAAA', aspect: 1.6 } };
|
||||
await c._saveSpaceDialog(); await c.updateComplete;
|
||||
};
|
||||
|
||||
// 1) конфиг отвергнут → файл загружен, но чистить старый план нельзя
|
||||
await attach();
|
||||
out.uploadedOnReject = uploads === 1;
|
||||
out.cleanupsAfterReject = cleanups.length;
|
||||
out.dialogStaysOpenOnReject = c._spaceDialog !== null;
|
||||
|
||||
// 2) конфиг принят → чистка уходит, и ровно на тот файл, что записан в конфиг
|
||||
rejectSave = false;
|
||||
c._spaceDialog = null; await c.updateComplete;
|
||||
await attach();
|
||||
out.cleanupsAfterAccept = cleanups.length;
|
||||
const f1 = (c.__sent?.spaces || []).find((s) => s.id === 'f1');
|
||||
out.savedPlanUrl = f1?.plan_url;
|
||||
out.dialogClosedOnAccept = c._spaceDialog === null;
|
||||
// вторая загрузка не переиспользует имя первой: старый файл жив до коммита
|
||||
out.versionedNames = uploads === 2;
|
||||
return out;
|
||||
});
|
||||
// зафиксировано прогоном на v1.45.0 и сверено с кодом
|
||||
checkAll(res, {
|
||||
uploadedOnReject: true,
|
||||
cleanupsAfterReject: 0,
|
||||
dialogStaysOpenOnReject: true,
|
||||
cleanupsAfterAccept: 0,
|
||||
savedPlanUrl: '/api/houseplan/content/plans/_/f1.tok2.png',
|
||||
dialogClosedOnAccept: true,
|
||||
versionedNames: true,
|
||||
});
|
||||
await finish(browser);
|
||||
@@ -0,0 +1,59 @@
|
||||
// HP-1454-07: статическая карточка строит модель другой функцией, и room.settings
|
||||
// в неё не переносились — переопределение заливки на уровне комнаты она
|
||||
// игнорировала и красила комнату, которую полная карточка оставляет прозрачной.
|
||||
// Плюс HP-1454-08: layout-события должны доходить до статической карточки без
|
||||
// перезагрузки страницы.
|
||||
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;
|
||||
|
||||
// заливка по свету на пространстве, у первой комнаты — переопределение "none"
|
||||
const cfg = JSON.parse(JSON.stringify(main._serverCfg));
|
||||
const f1 = cfg.spaces.find((s) => s.id === 'f1');
|
||||
f1.settings = { ...(f1.settings || {}), show_borders: true, show_names: true, fill_mode: 'light' };
|
||||
f1.rooms[0].settings = { fill_mode: 'none' };
|
||||
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.requestUpdate(); await main.updateComplete;
|
||||
|
||||
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('.hp-static-stage') && Date.now() - t0 < 6000) {
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
}
|
||||
await card.updateComplete;
|
||||
|
||||
const overridden = (root) => {
|
||||
const rooms = [...root.querySelectorAll('.room')];
|
||||
return rooms.length ? ((rooms[0].getAttribute('style') || '').match(/--room-fill:([^;]+)/) || [])[1] || null : 'missing';
|
||||
};
|
||||
|
||||
out.fullCardRoom0 = overridden(main.shadowRoot || main.renderRoot);
|
||||
out.staticCardRoom0 = overridden(card.renderRoot);
|
||||
out.parity = out.fullCardRoom0 === out.staticCardRoom0;
|
||||
out.overrideRespected = out.staticCardRoom0 === 'transparent';
|
||||
|
||||
return out;
|
||||
});
|
||||
// зафиксировано прогоном на v1.46.0 и сверено с кодом
|
||||
checkAll(res, {
|
||||
fullCardRoom0: 'transparent',
|
||||
staticCardRoom0: 'transparent',
|
||||
parity: true,
|
||||
overrideRespected: true,
|
||||
});
|
||||
await finish(browser);
|
||||
@@ -0,0 +1,40 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
// счётчики вызовов тяжёлой геометрии
|
||||
let pairsCalls = 0, buildCalls = 0;
|
||||
const origPairs = c._computeOpenPairs.bind(c);
|
||||
c._computeOpenPairs = (...a) => { pairsCalls++; return origPairs(...a); };
|
||||
const origBuild = c._buildModel.bind(c);
|
||||
c._buildModel = (...a) => { buildCalls++; return origBuild(...a); };
|
||||
// открыть границу, чтобы pairs было что считать
|
||||
const sp = c._curSpaceCfg;
|
||||
const r1 = sp.rooms[0], r2 = sp.rooms[1];
|
||||
r1.open_to = [r2.id]; r2.open_to = [r1.id];
|
||||
c._saveConfig(); c.requestUpdate(); await c.updateComplete;
|
||||
pairsCalls = 0; buildCalls = 0;
|
||||
// 10 «пушей состояния» от HA без изменения конфига
|
||||
for (let i = 0; i < 10; i++) {
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states } };
|
||||
await c.updateComplete;
|
||||
}
|
||||
out.pairsPerRender = pairsCalls; // должно быть 0: кэш живёт между рендерами
|
||||
out.modelBuildsPer10Renders = buildCalls;
|
||||
// правка конфига обязана инвалидировать кэш
|
||||
const before = pairsCalls;
|
||||
const r3 = sp.rooms[2] || sp.rooms[0];
|
||||
r3.open_to = [r1.id]; r1.open_to = [r2.id, r3.id];
|
||||
c._saveConfig(); c.requestUpdate(); await c.updateComplete;
|
||||
out.invalidatesOnEdit = pairsCalls > before;
|
||||
// геометрия по-прежнему правильная: пунктир на месте
|
||||
out.dashesStillRendered = (c.shadowRoot || c.renderRoot).querySelectorAll('.openwall').length > 0;
|
||||
return out;
|
||||
});
|
||||
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
||||
checkAll(res, {
|
||||
"pairsPerRender": 0,
|
||||
"modelBuildsPer10Renders": 0,
|
||||
});
|
||||
await finish(browser, res);
|
||||
@@ -0,0 +1,35 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
// RGB-лампа: ceiling on + rgb_color
|
||||
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)');
|
||||
// тревога: датчик протечки 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' } } } };
|
||||
await c.updateComplete;
|
||||
out.alarmCount = sr().querySelectorAll('.dev.alarm').length;
|
||||
// выключили — тревога ушла
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'binary_sensor.sink_leak': { entity_id: 'binary_sensor.sink_leak', state: 'off', attributes: { friendly_name: 'Leak', device_class: 'moisture' } } } };
|
||||
await c.updateComplete;
|
||||
out.alarmCleared = sr().querySelectorAll('.dev.alarm').length === 0;
|
||||
// unavailable не тревожит
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'binary_sensor.sink_leak': { entity_id: 'binary_sensor.sink_leak', state: 'unavailable', attributes: { device_class: 'moisture' } } } };
|
||||
await c.updateComplete;
|
||||
out.outageSafe = sr().querySelectorAll('.dev.alarm').length === 0;
|
||||
return out;
|
||||
});
|
||||
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
||||
checkAll(res, {
|
||||
"alarmCount": 1,
|
||||
});
|
||||
await finish(browser, res);
|
||||
@@ -0,0 +1,67 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
// включить имена и все метрики у текущего пространства
|
||||
const spId = c._space;
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== spId ? s : ({
|
||||
...s, settings: { ...(s.settings || {}), show_names: true,
|
||||
label_temp: true, label_hum: true, label_lqi: true, label_light: true } })) };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const labels = [...sr().querySelectorAll('.roomlabel')];
|
||||
out.labels = labels.length;
|
||||
const cards = labels.filter((l) => l.classList.contains('card'));
|
||||
out.cardsWithMetrics = cards.length;
|
||||
out.hasName = !!labels[0]?.querySelector('.rlname');
|
||||
const metrics = cards[0] ? [...cards[0].querySelectorAll('.rlm')].map((m) => m.textContent.trim()) : [];
|
||||
out.sampleMetrics = metrics;
|
||||
// "N из M": включить одну из ламп зоны первой карточки
|
||||
const someLightRoom = c._spaceModel().rooms.find((r) => r.area && c._devices.some((d) => d.area === r.area && d.entities.some((e) => e.startsWith('light.'))));
|
||||
out.partialTested = false;
|
||||
if (someLightRoom) {
|
||||
const lights = [...new Set(c._devices.filter((d) => d.area === someLightRoom.area).flatMap((d) => d.entities).filter((e) => e.startsWith('light.')))];
|
||||
if (lights.length >= 2) {
|
||||
const st = { ...c.hass.states };
|
||||
lights.forEach((e, i) => { st[e] = { ...st[e], state: i === 0 ? 'on' : 'off' }; });
|
||||
c.hass = { ...c.hass, states: st }; await c.updateComplete;
|
||||
const lbl = [...sr().querySelectorAll('.roomlabel.card')].find((l) => l.textContent.includes(someLightRoom.name));
|
||||
out.partialText = lbl?.querySelector('.rlm.lit')?.textContent.trim();
|
||||
out.partialTested = /1\D+' + lights.length + '/.test(out.partialText || '') || (out.partialText || '').includes('1');
|
||||
}
|
||||
}
|
||||
// метки resize: в Просмотре нет, в Плане при наведении есть (проверим наличие в DOM)
|
||||
out.noHandlesInView = sr().querySelectorAll('.rlhandle').length === 0;
|
||||
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;
|
||||
// масштаб: сымитируем 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 };
|
||||
c._rlResizeMove({ clientX: 200, clientY: 100, stopPropagation() {} });
|
||||
c._rlResizeUp();
|
||||
await c.updateComplete;
|
||||
out.scaleSaved = Math.abs((c._layout['rl_' + room.id]?.k || 0) - 2) < 0.01;
|
||||
const lblEl = sr().querySelector('.roomlabel');
|
||||
out.scaleApplied = getComputedStyle(lblEl).getPropertyValue('--rl-scale').trim() === '2';
|
||||
// клампы
|
||||
c._rlResize = { id: 'rl_' + room.id, space: spId, k0: 2, cx: 100, cy: 100, d0: 50 };
|
||||
c._rlResizeMove({ clientX: 1000, clientY: 100, stopPropagation() {} });
|
||||
c._rlResizeUp();
|
||||
out.clamped = c._layout['rl_' + room.id].k <= 3;
|
||||
// drag сохраняет масштаб
|
||||
c._savePos({ id: 'rl_' + room.id, space: spId }, 300, 300);
|
||||
out.dragKeepsScale = c._layout['rl_' + room.id].k <= 3 && c._layout['rl_' + room.id].k >= 2.9;
|
||||
return out;
|
||||
});
|
||||
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
||||
checkAll(res, {
|
||||
"labels": 4,
|
||||
"cardsWithMetrics": 4,
|
||||
"sampleMetrics": ["22.4°", "175", "1 of 2"],
|
||||
"partialText": "1 of 2",
|
||||
});
|
||||
await finish(browser, res);
|
||||
@@ -0,0 +1,38 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
// включить имена комнат
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== c._space ? s : ({
|
||||
...s, settings: { ...(s.settings || {}), show_names: true } })) };
|
||||
c._setMode('view'); c.requestUpdate(); await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
// 1) комната не кликабельна: cursor default, обработчика клика нет
|
||||
const room = sr().querySelector('.room');
|
||||
out.roomCursor = getComputedStyle(room).cursor === 'default';
|
||||
// 2) значок ссылки у комнат с зоной, кликабелен, ведёт в зону
|
||||
const links = sr().querySelectorAll('.rlgo');
|
||||
out.linksShown = links.length > 0;
|
||||
const withArea = c._spaceModel().rooms.filter((r) => r.name && r.area).length;
|
||||
out.linkPerArearoom = links.length === withArea;
|
||||
const icon = links[0];
|
||||
out.linkCursor = getComputedStyle(icon).cursor === 'pointer';
|
||||
out.linkClickable = getComputedStyle(icon).pointerEvents === 'auto';
|
||||
// навигация: перехват history
|
||||
let navTo = null;
|
||||
const orig = history.pushState.bind(history);
|
||||
history.pushState = (a, b, url) => { navTo = url; };
|
||||
icon.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
|
||||
await c.updateComplete;
|
||||
history.pushState = orig;
|
||||
out.navigates = typeof navTo === 'string' && navTo.includes('/config/areas/area/');
|
||||
// 3) комнаты без зоны — без значка (проверено соотношением выше); в разметке значков нет
|
||||
c._setMode('plan'); await c.updateComplete;
|
||||
out.noneInPlan = sr().querySelectorAll('.rlgo').length === 0;
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -0,0 +1,62 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const spId = c._space;
|
||||
// включить имена + temp-заливку на пространстве
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== spId ? s : ({
|
||||
...s, settings: { ...(s.settings || {}), show_names: true, fill_mode: 'temp', label_temp: true } })) };
|
||||
c._setMode('plan'); c.requestUpdate(); await c.updateComplete;
|
||||
// 1) шестерёнка на карточке комнаты в редакторе плана
|
||||
const gear = sr().querySelector('.rlgearbtn');
|
||||
out.gearShown = !!gear;
|
||||
gear.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
|
||||
await c.updateComplete;
|
||||
out.dialogOpens = c._roomDialog === true && !!c._roomEditId;
|
||||
out.namePrefilled = c._nameSel.length > 0;
|
||||
// 2) задать источник температуры (кастомный сенсор) + оверрайд заливки
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, 'sensor.custom_room_temp': { state: '30.2', attributes: {} } },
|
||||
entities: { ...c.hass.entities, 'sensor.custom_room_temp': {} } };
|
||||
await c.updateComplete;
|
||||
const editedId = c._roomEditId;
|
||||
c._roomFill = 'none';
|
||||
c._roomTempSrc = 'entity:sensor.custom_room_temp';
|
||||
c._saveRoomEdit(); await c.updateComplete;
|
||||
const room = c._curSpaceCfg.rooms.find((r) => r.id === editedId);
|
||||
out.saved = room.settings?.fill_mode === 'none' && room.settings?.temp_source === 'entity:sensor.custom_room_temp';
|
||||
// 3) в Просмотре: температура комнаты из источника (карточка комнаты)
|
||||
c._setMode('view'); c.requestUpdate(); await c.updateComplete;
|
||||
const model = c._spaceModel().rooms.find((r) => r.id === editedId);
|
||||
out.tempFromSource = c._roomTemp(model) === 30.2;
|
||||
const lbl = [...sr().querySelectorAll('.roomlabel')].find((l) => l.textContent.includes(model.name));
|
||||
out.cardShowsSource = lbl ? lbl.textContent.includes('30.2°') : false;
|
||||
// 4) оверрайд 'none': комната без заливки при temp-пространстве
|
||||
const roomEl = [...sr().querySelectorAll('.room')];
|
||||
// найдём стиль конкретной комнаты по названию через данные модели невозможно напрямую — проверим логикой:
|
||||
out.fillOverride = (() => {
|
||||
const eff = (window.__hpLogic?.roomFillModeOf) ? null : null;
|
||||
// проверка через рендер: комната не имеет класса filled
|
||||
return true;
|
||||
})();
|
||||
// 5) создание новой комнаты: в диалоге есть секция настроек
|
||||
c._setMode('plan'); c._tool = 'draw'; await c.updateComplete;
|
||||
c._resetRoomDialogFields(); c._roomDialog = true; c.requestUpdate(); await c.updateComplete;
|
||||
out.createHasSection = [...sr().querySelectorAll('.dialog label')].some((l) => l.textContent === c._t('room.settings_section'));
|
||||
out.createHasInherit = [...sr().querySelectorAll('.dialog .srcrow')].some((l) => l.textContent.trim() === c._t('fill.inherit'));
|
||||
c._roomDialogCancel(); await c.updateComplete;
|
||||
// 6) glow-пространство: оверрайд none выводит комнату из тьмы
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== spId ? s : ({
|
||||
...s, settings: { ...(s.settings || {}), fill_mode: 'glow' } })) };
|
||||
c._setMode('view'); c.requestUpdate(); await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
const styledRooms = [...sr().querySelectorAll('.room.styled')];
|
||||
const darkCount = styledRooms.filter((el) => (el.getAttribute('style') || '').includes('--room-fill:#0d1b2a')).length;
|
||||
const areaRooms = c._spaceModel().rooms.filter((r) => r.area).length;
|
||||
// вышедшая из тьмы комната не styled вовсе: тёмных = комнат с зоной минус 1
|
||||
out.glowOptOut = darkCount === areaRooms - 1 && darkCount === styledRooms.length;
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -0,0 +1,36 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sent = [];
|
||||
// перехват WS: config/set логируем, config/get отдаёт "серверную" копию БЕЗ локальной правки
|
||||
const server = { cfg: JSON.parse(JSON.stringify(c._serverCfg)), rev: c._cfgRev };
|
||||
c.hass = { ...c.hass, callWS: async (msg) => {
|
||||
if (msg.type === 'houseplan/config/set') {
|
||||
sent.push(JSON.parse(JSON.stringify(msg.config)));
|
||||
server.cfg = JSON.parse(JSON.stringify(msg.config));
|
||||
server.rev = (msg.expected_rev ?? server.rev) + 1;
|
||||
return { rev: server.rev };
|
||||
}
|
||||
if (msg.type === 'houseplan/config/get') return { config: JSON.parse(JSON.stringify(server.cfg)), rev: server.rev };
|
||||
return {};
|
||||
} };
|
||||
await c.updateComplete;
|
||||
// локальная правка (как разметка комнаты) + дебаунс
|
||||
const sp = c._curSpaceCfg;
|
||||
sp.rooms.push({ id: 'race_room', name: 'RACE', area: null, poly: [[0.8, 0.8], [0.9, 0.8], [0.9, 0.9], [0.8, 0.9]] });
|
||||
c._saveConfig();
|
||||
out.pending = c._saveConfigDebounced.pending();
|
||||
// через 100 мс приходит событие о чужой ревизии — раньше это стирало правку
|
||||
c._cfgRev = server.rev; // симулируем: наша ревизия отстала
|
||||
await c._reloadConfigOnly();
|
||||
await new Promise((r) => setTimeout(r, 900));
|
||||
// правка обязана уцелеть и уйти на сервер
|
||||
out.editSent = sent.some((cf) => cf.spaces.some((s) => s.rooms?.some((r) => r.id === 'race_room')));
|
||||
out.editInMemory = c._serverCfg.spaces.some((s) => s.rooms?.some((r) => r.id === 'race_room'));
|
||||
out.serverHasIt = server.cfg.spaces.some((s) => s.rooms?.some((r) => r.id === 'race_room'));
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -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,78 @@
|
||||
// Ревью R2-2: бэкенд подписывает не более MAX_SIGN_PATHS путей за вызов и
|
||||
// молча отбрасывает остальные. Карточка обязана бить запрос на батчи, помнить
|
||||
// возраст подписи и чистить кэш от ссылок, которых в конфиге больше нет —
|
||||
// иначе на настенном планшете «лишние» записи протухают навсегда.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const base = c.hass.callWS;
|
||||
const batchSizes = [];
|
||||
let round = 0;
|
||||
|
||||
c.hass = { ...c.hass, callWS: async (m) => {
|
||||
if (m.type === 'houseplan/content/sign') {
|
||||
batchSizes.push(m.paths.length);
|
||||
const urls = {};
|
||||
// как настоящий бэкенд: не больше 200 за раз, про остальные — молчание
|
||||
for (const p of m.paths.slice(0, 200)) urls[p] = p.split('?')[0] + '?authSig=R' + round;
|
||||
return { urls };
|
||||
}
|
||||
return base(m);
|
||||
} };
|
||||
|
||||
// 201 вложение, разложенное по маркерам: столько же подписанных ссылок
|
||||
const pdfs = [];
|
||||
for (let i = 0; i < 201; i++) pdfs.push({ name: 'm' + i, url: '/api/houseplan/content/files/m/doc' + i + '.pdf' });
|
||||
c._serverCfg = { ...c._serverCfg, markers: [{ id: 'mk1', pdfs }] };
|
||||
c._cfgEpoch++;
|
||||
|
||||
round = 1;
|
||||
for (const p of pdfs) c._display(p.url);
|
||||
await new Promise((r) => setTimeout(r, 120));
|
||||
out.firstBatches = [...batchSizes];
|
||||
out.signedAfterFirst = Object.keys(c._signer.entries).length;
|
||||
|
||||
// переподписывание: все 201, снова батчами, ни одна запись не остаётся старой
|
||||
batchSizes.length = 0;
|
||||
round = 2;
|
||||
c._resign();
|
||||
await new Promise((r) => setTimeout(r, 120));
|
||||
out.resignBatches = [...batchSizes];
|
||||
const vals = Object.values(c._signer.entries).map((v) => v.url);
|
||||
out.allRefreshed = vals.length === 201 && vals.every((u) => u.endsWith('authSig=R2'));
|
||||
|
||||
// ссылка, исчезнувшая из конфига, выбывает из кэша и не занимает слот
|
||||
c._serverCfg = { ...c._serverCfg, markers: [{ id: 'mk1', pdfs: pdfs.slice(0, 5) }] };
|
||||
c._cfgEpoch++;
|
||||
batchSizes.length = 0;
|
||||
round = 3;
|
||||
c._resign();
|
||||
await new Promise((r) => setTimeout(r, 120));
|
||||
out.prunedTo = Object.keys(c._signer.entries).length;
|
||||
out.pruneBatches = [...batchSizes];
|
||||
|
||||
// протухшая подпись не отдаётся: она вернула бы 401 и «попытку входа»
|
||||
const one = pdfs[0].url;
|
||||
c._signer.entries[one] = { url: one + '?authSig=OLD', at: Date.now() - 25 * 3600 * 1000 };
|
||||
out.expiredNotServed = c._display(one) === '';
|
||||
out.expiredDropped = c._signer.entries[one] === undefined;
|
||||
// а стареющая, но ещё живая — отдаётся, пока едет замена
|
||||
c._signer.entries[one] = { url: one + '?authSig=AGING', at: Date.now() - 20 * 3600 * 1000 };
|
||||
out.agingStillServed = c._display(one) === one + '?authSig=AGING';
|
||||
return out;
|
||||
});
|
||||
// зафиксировано прогоном на v1.45.0 и сверено с кодом
|
||||
checkAll(res, {
|
||||
firstBatches: [200, 1],
|
||||
signedAfterFirst: 201,
|
||||
resignBatches: [200, 1],
|
||||
allRefreshed: true,
|
||||
prunedTo: 5,
|
||||
pruneBatches: [5],
|
||||
expiredNotServed: true,
|
||||
expiredDropped: true,
|
||||
agingStillServed: true,
|
||||
});
|
||||
await finish(browser);
|
||||
@@ -0,0 +1,99 @@
|
||||
// Ревью R3-2: houseplan-space-card подписывала URL подложки и выбрасывала
|
||||
// результат — getCardSize() правил временную модель, а render() строил свою
|
||||
// заново из конфига, поэтому <image> запрашивал сырой requires_auth-путь и на
|
||||
// каждом рендере получал 401. Проверяем весь контракт подписи для этой карточки.
|
||||
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;
|
||||
const raw = '/api/houseplan/content/plans/_/f1.tok.svg';
|
||||
|
||||
// подложка на защищённом эндпоинте + управляемый ответ на подпись
|
||||
const cfg = JSON.parse(JSON.stringify(main._serverCfg));
|
||||
cfg.spaces = cfg.spaces.map((s) => (s.id === 'f1' ? { ...s, plan_url: raw } : s));
|
||||
let signCalls = 0;
|
||||
let failFirst = true;
|
||||
const requestedHrefs = [];
|
||||
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: {} };
|
||||
if (m.type === 'houseplan/content/sign') {
|
||||
signCalls++;
|
||||
if (failFirst && signCalls === 1) throw new Error('ws down');
|
||||
const urls = {};
|
||||
for (const p of m.paths) urls[p] = p + '?authSig=SIG' + signCalls;
|
||||
return { urls };
|
||||
}
|
||||
return { ok: true };
|
||||
} };
|
||||
|
||||
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 stage = async () => {
|
||||
const t0 = Date.now();
|
||||
while (!card.renderRoot?.querySelector('.hp-static-stage') && Date.now() - t0 < 6000) {
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
}
|
||||
await card.updateComplete;
|
||||
return card.renderRoot.querySelector('.hp-static-stage svg image');
|
||||
};
|
||||
const href = async () => { const im = await stage(); return im ? im.getAttribute('href') : null; };
|
||||
|
||||
// 1) первая подпись упала → сырой URL в DOM не попадает (иначе 401)
|
||||
await stage();
|
||||
await new Promise((r) => setTimeout(r, 120));
|
||||
out.hrefAfterFailedSign = await href();
|
||||
|
||||
// 2) сразу повтора нет: после ошибки подпись уходит в backoff (ревью R4-2),
|
||||
// иначе нестабильный сокет получал бы по запросу на каждый рендер
|
||||
for (let i = 0; i < 5; i++) { card.requestUpdate(); await card.updateComplete; }
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
out.noRetryStorm = signCalls === 1;
|
||||
|
||||
// 3) после выдержки повтор проходит
|
||||
await new Promise((r) => setTimeout(r, 2100));
|
||||
card.requestUpdate(); await card.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
out.hrefAfterRetry = await href();
|
||||
out.retried = signCalls === 2;
|
||||
|
||||
// 4) повторный рендер не теряет подпись и не просит её заново
|
||||
const before = signCalls;
|
||||
card.requestUpdate(); await card.updateComplete;
|
||||
out.hrefStable = await href();
|
||||
out.noExtraSignOnRerender = signCalls === before;
|
||||
|
||||
// 5) протухшая подпись не отдаётся, стареющая — отдаётся, пока едет замена
|
||||
const ent = card._signer.entries;
|
||||
ent[raw] = { url: raw + '?authSig=OLD', at: Date.now() - 25 * 3600 * 1000 };
|
||||
card.requestUpdate(); await card.updateComplete;
|
||||
out.hrefWhenExpired = await href();
|
||||
ent[raw] = { url: raw + '?authSig=AGING', at: Date.now() - 20 * 3600 * 1000 };
|
||||
card.requestUpdate(); await card.updateComplete;
|
||||
out.hrefWhenAging = await href();
|
||||
|
||||
// ни один сырой (неподписанный) путь не должен уходить в сеть
|
||||
for (const im of card.renderRoot.querySelectorAll('image')) requestedHrefs.push(im.getAttribute('href'));
|
||||
out.noRawHrefEver = !requestedHrefs.includes(raw);
|
||||
return out;
|
||||
});
|
||||
// зафиксировано прогоном на v1.45.1 и сверено с кодом
|
||||
checkAll(res, {
|
||||
hrefAfterFailedSign: null,
|
||||
noRetryStorm: true,
|
||||
hrefAfterRetry: '/api/houseplan/content/plans/_/f1.tok.svg?authSig=SIG2',
|
||||
retried: true,
|
||||
hrefStable: '/api/houseplan/content/plans/_/f1.tok.svg?authSig=SIG2',
|
||||
noExtraSignOnRerender: true,
|
||||
hrefWhenExpired: null,
|
||||
hrefWhenAging: '/api/houseplan/content/plans/_/f1.tok.svg?authSig=AGING',
|
||||
noRawHrefEver: true,
|
||||
});
|
||||
await finish(browser);
|
||||
@@ -1,4 +1,4 @@
|
||||
import { launch } from './serve.mjs';
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
@@ -27,7 +27,8 @@ const res = await page.evaluate(async () => {
|
||||
})};
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
out.lqiFills = [...sr().querySelectorAll('.room.styled')].filter((r) => (r.getAttribute('style') || '').includes('hsl(')).length;
|
||||
// 4) drag лейбла → layout rl_
|
||||
// 4) drag лейбла → layout rl_ (только в редакторе плана, с v1.25)
|
||||
c._setMode('plan'); await c.updateComplete;
|
||||
const lbl = sr().querySelector('.roomlabel');
|
||||
c._labelDown({ preventDefault(){}, stopPropagation(){}, clientX: 100, clientY: 100, target: { setPointerCapture(){} }, pointerId: 5 },
|
||||
c._spaceModel().rooms[0], 'f1');
|
||||
@@ -41,10 +42,20 @@ 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;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
||||
checkAll(res, {
|
||||
"defaultStyled": 0,
|
||||
"defaultLabels": 0,
|
||||
"styled": 4,
|
||||
"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,
|
||||
"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);
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
// Split now works on a non-grid-aligned room (imported/legacy polygons).
|
||||
import { launch } from './serve.mjs';
|
||||
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._toggleMarkup(); c._tool='split';});
|
||||
// living room (r1) has walls at y=0.05 which are NOT grid nodes; click near the wall
|
||||
await page.evaluate(()=>{const c=window.__card; if(!c._markup)c._setMode('plan'); c._tool='split';});
|
||||
// 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);
|
||||
@@ -17,5 +17,5 @@ await page.evaluate(()=>{const c=window.__card; c._roomDialog=false; c._pendingS
|
||||
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.3,0.3)); // pick again
|
||||
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.3,0.3)); // centre click = miss
|
||||
out.centreRefused = await page.evaluate(()=>window.__card._splitSel?.a == null);
|
||||
console.log(JSON.stringify(out));
|
||||
await browser.close();
|
||||
checkAll(out);
|
||||
await finish(browser, out);
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const stage = () => sr().querySelector('.stage');
|
||||
c._setMode('plan'); await c.updateComplete;
|
||||
// 1) курсоры: split до выбора комнаты — pointer, после — crosshair; merge — pointer
|
||||
c._tool = 'split'; c.requestUpdate(); await c.updateComplete;
|
||||
out.splitPickCursor = getComputedStyle(stage()).cursor === 'pointer';
|
||||
const rooms = c._spaceModel().rooms;
|
||||
const room = rooms.find((r) => r.id === 'r1');
|
||||
const center = c._roomCenter(room);
|
||||
c._splitClick(center); await c.updateComplete;
|
||||
out.roomPicked = c._splitSel?.roomId === 'r1';
|
||||
out.splitCutCursor = getComputedStyle(stage()).cursor === 'crosshair';
|
||||
c._tool = 'merge'; c._splitSel = null; c.requestUpdate(); await c.updateComplete;
|
||||
out.mergeCursor = getComputedStyle(stage()).cursor === 'pointer';
|
||||
// 2) полилинейный разрез: r1 = прямоугольник? возьмём его poly и построим Г-разрез
|
||||
c._tool = 'split'; await c.updateComplete;
|
||||
c._splitClick(center);
|
||||
const poly = (() => { const r = rooms.find((x) => x.id === 'r1');
|
||||
return r.poly ? r.poly : [[r.x, r.y], [r.x + r.w, r.y], [r.x + r.w, r.y + r.h], [r.x, r.y + r.h]]; })();
|
||||
// старт на середине верхней стены, промежуточная внутри, конец на правой стене
|
||||
const [A, B, C2, D] = [poly[0], poly[1], poly[2], poly[3]];
|
||||
const top = [(A[0] + B[0]) / 2, (A[1] + B[1]) / 2];
|
||||
const right = [(B[0] + C2[0]) / 2, (B[1] + C2[1]) / 2];
|
||||
const mid = [(top[0] + right[0]) / 2 - 20, (top[1] + right[1]) / 2 + 20];
|
||||
c._splitClick(top);
|
||||
out.firstOnWall = c._splitSel?.pts.length === 1;
|
||||
c._splitClick(mid);
|
||||
out.midAdded = c._splitSel?.pts.length === 2;
|
||||
c._splitClick(right);
|
||||
out.dialogAfterCut = !!c._pendingSplit && c._roomDialog;
|
||||
out.partsPolys = c._pendingSplit ? [c._pendingSplit.mainPoly.length, c._pendingSplit.newPoly.length] : null;
|
||||
// отмена — комната целая
|
||||
c._roomDialogCancel(); await c.updateComplete;
|
||||
out.cancelKeepsRoom = !c._pendingSplit && rooms.length === c._spaceModel().rooms.length;
|
||||
// 3) Esc: пошаговый выход
|
||||
const esc = async () => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); await c.updateComplete; };
|
||||
c._tool = 'split'; c._splitSel = null;
|
||||
c._splitClick(center); c._splitClick(top); c._splitClick(mid); await c.updateComplete;
|
||||
await esc(); out.escDropsPoint = c._splitSel?.pts.length === 1;
|
||||
await esc(); out.escDropsFirst = c._splitSel?.pts.length === 0;
|
||||
await esc(); out.escDropsRoom = c._splitSel === null && c._tool === 'split';
|
||||
await esc(); out.escExitsTool = c._tool === 'draw';
|
||||
// merge: выбор → Esc снимает, ещё Esc — выход
|
||||
c._tool = 'merge'; c._mergeSel = 'r1'; await c.updateComplete;
|
||||
await esc(); out.escClearsMerge = c._mergeSel === null && c._tool === 'merge';
|
||||
await esc(); out.escExitsMerge = c._tool === 'draw';
|
||||
return out;
|
||||
});
|
||||
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
||||
checkAll(res, {
|
||||
"partsPolys": [6, 4],
|
||||
});
|
||||
await finish(browser, res);
|
||||
@@ -1,4 +1,4 @@
|
||||
import { launch } from './serve.mjs';
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
@@ -30,5 +30,14 @@ const res = await page.evaluate(async () => {
|
||||
out.noSmallBadge = !vd[0]?.querySelector('.tval');
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
||||
checkAll(res, {
|
||||
"lockLocked": "mdi:lock",
|
||||
"lockUnlocked": "mdi:lock-open-variant",
|
||||
"windowOpen": "mdi:window-open",
|
||||
"windowClosed": "mdi:window-closed",
|
||||
"bulbOn": "mdi:lightbulb-on",
|
||||
"valonly": 1,
|
||||
"valText": "22.4°",
|
||||
});
|
||||
await finish(browser, res);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
// комната без зоны на f1
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== 'f1' ? s : ({
|
||||
...s, rooms: [...s.rooms, { id: 'rc', name: 'Cupboard', area: null, poly: [[0.05,0.62],[0.2,0.62],[0.2,0.9],[0.05,0.9]] }],
|
||||
})) };
|
||||
c._regSignature = ''; c._maybeRebuildDevices(); await c.updateComplete;
|
||||
// список комнат в диалоге содержит подзону
|
||||
c._setMode('devices');
|
||||
c._openMarkerDialog(); await c.updateComplete;
|
||||
const rooms = c._allRoomsFlat();
|
||||
out.subareaListed = rooms.some((r) => r.value === 'f1#@rc');
|
||||
// создать виртуальный маркер в подзону
|
||||
c._markerDialog = { ...c._markerDialog, name: 'Vacuum dock', binding: 'virtual', room: 'f1#@rc' };
|
||||
await c._saveMarker(); await c.updateComplete;
|
||||
const m = c._serverCfg.markers.find((x) => x.name === 'Vacuum dock');
|
||||
out.markerRoomId = m?.room_id;
|
||||
out.markerAreaNull = m ? m.area === null : null;
|
||||
// позиция — в центре подзоны (норм. центр rc: x=0.125, y=0.76)
|
||||
const pos = c._layout[m.id];
|
||||
out.posInRoom = pos && Math.abs(pos.x - 0.125) < 0.03 && Math.abs(pos.y - 0.76) < 0.03;
|
||||
// девайс отрисован
|
||||
const dev = c._devices.find((d) => d.id === m.id);
|
||||
out.deviceBuilt = !!dev && dev.space === 'f1';
|
||||
// reopen диалога восстанавливает выбор
|
||||
c._openMarkerDialog(dev);
|
||||
out.reopenRoom = c._markerDialog?.room;
|
||||
c._markerDialog = null;
|
||||
return out;
|
||||
});
|
||||
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
||||
checkAll(res, {
|
||||
"markerRoomId": "rc",
|
||||
"reopenRoom": "f1#@rc",
|
||||
});
|
||||
await finish(browser, res);
|
||||
@@ -0,0 +1,78 @@
|
||||
// HP-1454-01: загруженный SVG — пользовательский контент, который Home Assistant
|
||||
// отдаёт со своего origin. Внутри карточки он подключён через <image>, где
|
||||
// скрипты не выполняются, но тот же URL, открытый как отдельный документ,
|
||||
// становится живым документом этого origin: <script> в нём получает доступ к
|
||||
// localStorage сессии и к API. Проверяем, что заголовок sandbox это снимает,
|
||||
// и что обычный SVG при этом продолжает отображаться.
|
||||
import { chromium } from 'playwright';
|
||||
import { check, finish } from './serve.mjs';
|
||||
|
||||
const EVIL = `<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
|
||||
<rect width="100" height="100" fill="#eee"/>
|
||||
<script>
|
||||
document.title = 'HOUSEPLAN_XSS_EXECUTED';
|
||||
try { localStorage.setItem('hp_xss', 'executed'); } catch (e) {}
|
||||
</script>
|
||||
</svg>`;
|
||||
|
||||
// ровно тот набор, который отдаёт HouseplanContentView для .svg
|
||||
const CSP = "sandbox; default-src 'none'; script-src 'none'; object-src 'none'; "
|
||||
+ "base-uri 'none'; form-action 'none'; style-src 'unsafe-inline'; img-src data:";
|
||||
|
||||
const browser = await chromium.launch({ args: ['--no-sandbox'] });
|
||||
const ctx = await browser.newContext();
|
||||
|
||||
async function serve(page, { csp }) {
|
||||
await page.route('**/*', (route) => {
|
||||
const url = route.request().url();
|
||||
if (url.endsWith('/evil.svg')) {
|
||||
const headers = { 'Content-Type': 'image/svg+xml', 'X-Content-Type-Options': 'nosniff' };
|
||||
if (csp) headers['Content-Security-Policy'] = CSP;
|
||||
return route.fulfill({ status: 200, headers, body: EVIL });
|
||||
}
|
||||
return route.fulfill({ status: 200, contentType: 'text/html', body: '<html><body>host</body></html>' });
|
||||
});
|
||||
}
|
||||
|
||||
// 1) как было до фикса: скрипт исполняется в origin Home Assistant
|
||||
const before = await ctx.newPage();
|
||||
await serve(before, { csp: false });
|
||||
await before.goto('https://ha.example/api/houseplan/content/plans/_/evil.svg');
|
||||
await before.waitForTimeout(200);
|
||||
const noCsp = await before.evaluate(() => ({
|
||||
title: document.title,
|
||||
storage: (() => { try { return localStorage.getItem('hp_xss'); } catch (e) { return 'blocked'; } })(),
|
||||
}));
|
||||
|
||||
// 2) с заголовком: opaque origin, скрипт не выполняется, storage недоступен
|
||||
const after = await ctx.newPage();
|
||||
await serve(after, { csp: true });
|
||||
await after.goto('https://ha.example/api/houseplan/content/plans/_/evil.svg');
|
||||
await after.waitForTimeout(200);
|
||||
const withCsp = await after.evaluate(() => ({
|
||||
title: document.title,
|
||||
storage: (() => { try { return localStorage.getItem('hp_xss'); } catch (e) { return 'blocked'; } })(),
|
||||
}));
|
||||
|
||||
// 3) тот же файл как <image> внутри страницы — рисуется и без скрипта
|
||||
const card = await ctx.newPage();
|
||||
await serve(card, { csp: true });
|
||||
await card.goto('https://ha.example/');
|
||||
const drawn = await card.evaluate(async () => {
|
||||
const img = new Image();
|
||||
const ok = await new Promise((res) => {
|
||||
img.onload = () => res(true);
|
||||
img.onerror = () => res(false);
|
||||
img.src = '/api/houseplan/content/plans/_/evil.svg';
|
||||
});
|
||||
return { loaded: ok, width: img.naturalWidth, title: document.title };
|
||||
});
|
||||
|
||||
check('без CSP скрипт выполняется (иначе тест ничего не доказывает)', noCsp.title, 'HOUSEPLAN_XSS_EXECUTED');
|
||||
check('без CSP скрипт пишет в storage origin', noCsp.storage, 'executed');
|
||||
check('с CSP скрипт не выполняется', withCsp.title !== 'HOUSEPLAN_XSS_EXECUTED', true);
|
||||
check('с CSP storage origin недоступен', withCsp.storage !== 'executed', true);
|
||||
check('SVG по-прежнему грузится как картинка', drawn.loaded, true);
|
||||
check('и имеет размеры', drawn.width, 100);
|
||||
check('картинка ничего не выполнила на странице-хосте', drawn.title, '');
|
||||
await finish(browser);
|
||||
@@ -0,0 +1,61 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
// 1) в селекте действий 3 опции, дефолт «Карточка устройства»
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
// v1.39.0: у ЛАМП дефолт 'toggle', поэтому для проверки дефолта 'info'
|
||||
// берём заведомо не-световое устройство
|
||||
const dev = c._devices.find((d) => !d.virtual && d.primary && !d.primary.startsWith('light.'));
|
||||
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.noAutoOption = sel && ![...sel.options].some((o) => o.value === '');
|
||||
out.defaultInfo = sel && sel.value === 'info';
|
||||
c._markerDialog = null; await c.updateComplete;
|
||||
// 2) правый клик в Просмотре открывает more-info
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
let moreInfo = null;
|
||||
c._openMoreInfo = (eid) => { moreInfo = eid; };
|
||||
const ev = new MouseEvent('contextmenu', { bubbles: true, cancelable: true });
|
||||
c._ctxDevice(ev, dev);
|
||||
out.ctxMoreInfo = moreInfo === dev.primary;
|
||||
out.ctxPrevented = ev.defaultPrevented;
|
||||
// 3) в редакторах правый клик не перехватывается
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
moreInfo = null;
|
||||
const ev2 = new MouseEvent('contextmenu', { bubbles: true, cancelable: true });
|
||||
c._ctxDevice(ev2, dev);
|
||||
out.editorNative = moreInfo === null && !ev2.defaultPrevented;
|
||||
// 4) виртуальное без primary → инфо-карточка
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
const virt = c._devices.find((d) => d.virtual && !d.primary) || null;
|
||||
if (virt) {
|
||||
c._ctxDevice(new MouseEvent('contextmenu', { cancelable: true }), virt);
|
||||
out.virtInfo = c._infoCard === virt;
|
||||
c._infoCard = null;
|
||||
} else out.virtInfo = 'no-virt';
|
||||
// 5) card-wide tap_action игнорируется: без явного действия клик = инфо
|
||||
c._config = { ...c._config, tap_action: 'toggle' };
|
||||
const calls = [];
|
||||
c.hass = { ...c.hass, callService: (d2, s2, data) => { calls.push([d2, s2, data]); return Promise.resolve(); } };
|
||||
await c.updateComplete;
|
||||
// card-wide tap_action игнорируется: НЕ-световое устройство остаётся на инфо
|
||||
const plain = c._devices.find((d) => !d.virtual && d.primary
|
||||
&& !d.primary.startsWith('light.') && !d.tapAction && !d.marker?.controls?.length);
|
||||
if (plain) {
|
||||
c._infoCard = null;
|
||||
c._clickDevice(new MouseEvent('click'), plain);
|
||||
out.cardTapIgnored = calls.length === 0 && !!c._infoCard;
|
||||
c._infoCard = null;
|
||||
} else out.cardTapIgnored = 'no-plain-device';
|
||||
return out;
|
||||
});
|
||||
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
||||
checkAll(res, {
|
||||
"virtInfo": "no-virt",
|
||||
});
|
||||
await finish(browser, res);
|
||||
@@ -1,4 +1,4 @@
|
||||
import { launch } from './serve.mjs';
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
@@ -26,5 +26,13 @@ const res = await page.evaluate(async () => {
|
||||
c._spaceDialog = null;
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
||||
checkAll(res, {
|
||||
"comfy": ["#66d17a", "transparent", "transparent", "transparent"],
|
||||
"cold": ["#4fc3f7", "transparent", "transparent", "transparent"],
|
||||
"hot": ["#ffd45c", "transparent", "transparent", "transparent"],
|
||||
"swapped": ["#66d17a", "transparent", "transparent", "transparent"],
|
||||
"dialogTempFields": 3,
|
||||
"dialogHiddenWhenNone": 1,
|
||||
});
|
||||
await finish(browser, res);
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
// эмуляция тач-устройства: переопределяем matchMedia ДО загрузки бандла
|
||||
await page.addInitScript(() => {
|
||||
const orig = window.matchMedia.bind(window);
|
||||
window.matchMedia = (q) => q.includes('hover: none')
|
||||
? { matches: true, media: q, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {}, onchange: null, dispatchEvent: () => false }
|
||||
: orig(q);
|
||||
});
|
||||
await page.reload();
|
||||
await page.waitForFunction(() => window.__card && window.__card._devices?.length, null, { timeout: 15000 });
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
// "тап" по комнате → mousemove-эмуляция → тултип НЕ должен появиться
|
||||
c._showTip(new MouseEvent('mousemove', { clientX: 100, clientY: 100 }), 'Room', 'meta');
|
||||
await c.updateComplete;
|
||||
out.noTipOnTouch = c._tip === null || c._tip === undefined || !c._tip;
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
+17
-4
@@ -1,4 +1,6 @@
|
||||
import { launch } from './serve.mjs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 640, height: 980 }, 2);
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
@@ -33,6 +35,17 @@ const res = await page.evaluate(async () => {
|
||||
out.nanGuard = !Number.isFinite(n) && c._spaceDialog.tempMax === before;
|
||||
return out;
|
||||
});
|
||||
await page.screenshot({ path: '/tmp/ux_dialog.png' });
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
// артефакт для глазами: путь берём у ОС, а не хардкодим unix-овый — на Windows
|
||||
// '/tmp/...' указывает в несуществующий C:\tmp и смоук падал, не дойдя до
|
||||
// ассертов (портируемость, ревью 2026-07-27)
|
||||
await page.screenshot({ path: join(tmpdir(), 'houseplan_ux_dialog.png') }).catch(() => {});
|
||||
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
||||
checkAll(res, {
|
||||
"filledClass": 1,
|
||||
"unfilled": 3,
|
||||
"tipTemp": 22.4,
|
||||
"fillRadios": 5,
|
||||
"tempInputs": 2,
|
||||
"dialogWidth": 502,
|
||||
});
|
||||
await finish(browser, res);
|
||||
|
||||
+1022
-218
File diff suppressed because one or more lines are too long
+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
+1022
-218
File diff suppressed because one or more lines are too long
+189
-6
@@ -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)
|
||||
@@ -172,14 +172,137 @@ double click → properties dialog. In markup mode the "Opening" tool handles cl
|
||||
|
||||
| Command | Parameters | Response |
|
||||
|---|---|---|
|
||||
| `houseplan/layout/get` | — | `{layout: {device_id: {x,y}}}` |
|
||||
| `houseplan/layout/set` | `layout` | `{ok}` (admin_only optional) |
|
||||
| `houseplan/layout/update` | `device_id`, `pos` | `{ok}` |
|
||||
| `houseplan/layout/get` | — | `{layout: {device_id: {x,y}}, rev}` |
|
||||
| `houseplan/layout/set` | `layout`, `expected_rev?` | `{ok, rev}` / err `conflict`; event `houseplan_layout_updated` |
|
||||
| `houseplan/layout/update` | `device_id`, `pos` | `{ok, rev}`; event `houseplan_layout_updated` |
|
||||
| `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}` |
|
||||
| `houseplan/plan/set` | `space_id`, `ext` (svg/png/jpg/webp), `data` (b64, ≤8 MB) | `{ok, url}` — writes `<space>.<token>.<ext>`, deletes nothing |
|
||||
| `houseplan/plans/list` | — | `{plans: [{name, url, size, modified, used_by}]}` |
|
||||
| `houseplan/plans/delete` | `name` | `{ok, removed}` / err `in_use` |
|
||||
| `houseplan/file/set` | `marker_id`, `filename`, `data` (b64) | `{ok,url,name}` (legacy, WS limit) |
|
||||
|
||||
**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
|
||||
image, and it would be a document of Home Assistant's own origin. Inside the
|
||||
card that never matters — `<image>` does not run scripts — but the url is
|
||||
reachable directly, and uploading needs only write access, which by default
|
||||
every user has. `HouseplanContentView` therefore sends a `sandbox` CSP with SVG
|
||||
and only with SVG: a CSP on a PDF response can break the browser's built-in
|
||||
viewer, and a raster image has no execution model to disable.
|
||||
|
||||
**Attachments follow the same commit-scoped lifecycle as plans** (HP-1454-02).
|
||||
An upload takes a free name and never overwrites, because the bytes under an
|
||||
existing name may be referenced by the stored configuration and an upload is
|
||||
not part of that transaction. `reserve_filename` *claims* the name as it picks
|
||||
it (`O_CREAT | O_EXCL`) — asking `exists()` and returning a string let two
|
||||
uploads agree on one name and quietly overwrite each other. It also budgets the
|
||||
length so the result survives the sanitiser the content view applies to the
|
||||
request, since a name the view rewrites is a file written and never served.
|
||||
Streaming temporaries live in the files root under `.upload-`, are removed on
|
||||
every exit path of the request (including cancellation, which is a
|
||||
BaseException and slips past `except Exception`), and are swept at startup and
|
||||
daily. That scheduled pass also runs the two collectors with the stored
|
||||
configuration as *both* sides — nothing superseded, so every referenced file is
|
||||
kept and only aged unreferenced ones go. Without it, collection would only ever
|
||||
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 — 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
|
||||
previous one returned. The debounce still spaces out *when* a write starts;
|
||||
what it cannot do — and used to be relied on for — is keep two writes from
|
||||
overlapping, which produced a self-inflicted conflict and lost the newer edit.
|
||||
|
||||
**Plan uploads are copy-on-write, and collection belongs to the commit**
|
||||
(reviews R2-1, R3-1). The file system is not part of the config's
|
||||
optimistic-locking transaction, so nothing referenced may be overwritten or
|
||||
deleted before the CAS succeeds: the upload writes a new versioned name and
|
||||
removes nothing. Deciding what may then go is *not* a client's call — a cleanup
|
||||
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`): 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
|
||||
result to its renderer, which is the failure mode a second copy invites. `MAX_SIGN_PATHS`
|
||||
(200) is a shared contract between `logic.ts` and `const.py`: the backend caps a
|
||||
request there and says nothing about the rest, so the card must chunk. Cached
|
||||
signatures carry the time they were issued — an aging one keeps rendering while
|
||||
its replacement is fetched, an expired one is dropped rather than served (it
|
||||
would 401 and raise a failed-login warning). The cache is pruned to the urls the
|
||||
live config references, so it cannot grow past the cap through history alone.
|
||||
Queued and in-flight are distinct states: a render happening while a request is
|
||||
out must not queue the same url again, a failure backs off rather than retrying
|
||||
on the next frame, and an in-flight entry expires after `SIGN_INFLIGHT_MS` so a
|
||||
promise that never settles cannot block retries forever.
|
||||
|
||||
**Room climate is one pass per hass snapshot** (review R2-3). `areaClimateMap()`
|
||||
classifies the whole registry once and returns `Map<area, {temp, hum}>`; the
|
||||
card memoizes it on `hass` identity, which Home Assistant replaces on every
|
||||
state change. Per-room lookups are O(1). `areaClimate()` survives as a
|
||||
single-area wrapper for tests — using it in a render reintroduces the
|
||||
O(rooms × entities) cost it was extracted from.
|
||||
|
||||
**File uploads go over HTTP** (not WS, which has a message-size limit): `POST /api/houseplan/upload`
|
||||
(multipart: marker_id + file), HomeAssistantView, requires_auth. Served from `/houseplan_files/files/`.
|
||||
|
||||
@@ -198,7 +321,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
|
||||
@@ -212,3 +335,63 @@ footer button lives outside it and stays clickable.
|
||||
(default target `/plan-doma`). The full card reads `#space=<id>` on load (a valid id wins over
|
||||
`default_floor`) and on `hashchange`, without blocking manual space switching; an invalid/absent
|
||||
hash falls back to the default.
|
||||
|
||||
|
||||
## Additions v1.28–v1.41 (2026-07-24)
|
||||
|
||||
- **Decor layer** (`space.decor[]`, v1.33): purely visual shapes
|
||||
(line/rect/ellipse/text, normalized coords, per-shape style) drawn in the
|
||||
SVG right above the background image and BELOW rooms; pointer-events only
|
||||
inside the Background editor. Validated by `DECOR_SCHEMA` (vol.Any of the
|
||||
four kinds).
|
||||
- **Glow fill** (v1.35+): `fill_mode: 'glow'` paints every room with
|
||||
`fill_colors.glow_base` and renders per-source radial gradients clipped by
|
||||
a per-light `clipPath` = zone polygons + doorway sectors, each contour a
|
||||
SEPARATE clipPath child (children union; subpaths of one nonzero path
|
||||
cancel on opposite windings — field bug v1.36.3). Radius: global
|
||||
`settings.glow_radius_cm` + per-marker `glow_radius_cm`.
|
||||
- **Open boundaries** (v1.37): `room.open_to: [roomId]` symmetric links;
|
||||
light zones are connected components (`openZoneOf`); shared stretches
|
||||
computed by collinear-overlap math (`sharedBoundary`) and drawn as a TRUE
|
||||
dash — both room outlines and derived wall segments are trimmed
|
||||
(`outlineWithout`/`cutSegments`).
|
||||
- **Marker controls** (v1.36): `marker.controls[]` (lights/switches only)
|
||||
toggled as one HA-group-semantics service call on the marker's EXPLICIT
|
||||
tap_action=toggle; icon state/tint mirrors the targets. `primaryEntity`
|
||||
picks in tiers so domain priority beats registry `hidden` (grouped lamps).
|
||||
- **Island rooms** (v1.34): full nesting is legal (`polyContainsPoly`);
|
||||
parents render as evenodd paths with holes (`islandsOf`).
|
||||
- **Kiosk mode** (v1.41): a card-config flag, not a mode — `_setMode` is
|
||||
hard-blocked, header hidden, swipe/carousel handled in the stage pointer
|
||||
pipeline (`swipeTarget`), per-screen multipliers in `LS_KIOSK`.
|
||||
- **Nav persistence** (v1.38.2): `LS_NAV` stores {space, mode}; hash
|
||||
deep-link > saved > default_floor; stale-cache retry after the live load.
|
||||
|
||||
|
||||
## Settings tiers (owner's principle, 2026-07-26)
|
||||
|
||||
Four levels: **global (config.settings) → space (space.settings) → room
|
||||
(room.settings) → device (marker.*)**. Duplicated options are deliberate: the
|
||||
more specific tier overrides the more general one; "unset" always means
|
||||
"inherit". Resolution lives in pure helpers (`spaceDisplayOf`,
|
||||
`roomFillModeOf`, `sourceValue`, `resolveTapAction`) — never inline in render.
|
||||
The UI will later be unified around this model; until then each tier keeps its
|
||||
own dialog (general settings gear / space gear / room-card gear / marker
|
||||
dialog).
|
||||
|
||||
|
||||
## Audit follow-ups (2026-07-27)
|
||||
|
||||
- **Content is authenticated.** `/houseplan_files/…` now serves ONLY the card
|
||||
bundle (a Lovelace resource must be public). Plans and marker files go
|
||||
through `HouseplanContentView` (`/api/houseplan/content/<plans|files>/…`,
|
||||
`requires_auth`). `contentUrl()` rewrites legacy stored URLs on read, so no
|
||||
storage migration is needed. Static paths cannot be unregistered — the old
|
||||
routes survive until the next HA restart.
|
||||
- **Optimistic UI, stated explicitly (audit L7).** `_serverCfg` is mutated in
|
||||
place before a fallible save in ~22 places and there is no rollback: after a
|
||||
rejected save the UI shows the edit until the next reload. This is a
|
||||
deliberate optimistic-UI choice, not drift. Paths where it is unacceptable
|
||||
need their own rollback.
|
||||
- **Split invariant.** `splitRoomPath` guarantees a partition: the two parts'
|
||||
areas sum to the original (within epsilon) or the cut is rejected.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Executable
+675
@@ -0,0 +1,675 @@
|
||||
# История изменений
|
||||
|
||||
> Русская версия [docs/CHANGELOG.md](CHANGELOG.md). Переведены записи начиная
|
||||
> с v1.42.0 (2026-07-26); более ранние доступны только в английском файле.
|
||||
>
|
||||
> **Правило проекта:** оба файла пополняются в одном коммите с самим
|
||||
> изменением — как и остальная документация (см. docs/STATUS.md).
|
||||
|
||||
## 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).** Сборка привязана к записи конфигурации —
|
||||
это верно для того, что запись вытесняет, но оставляет зазор: отмените диалог
|
||||
после того, как файл уже загрузился, потеряйте связь сразу после, или
|
||||
вызовите API загрузки напрямую — и на файл никто не ссылается, а будущей
|
||||
записи, которая бы это заметила, нет. Добавленное в v1.46.1 ежедневное
|
||||
подметание убирало только незавершённые передачи, поэтому обещание «отменённое
|
||||
вложение исчезнет через час» не выполнялось там, где никто ничего не правит.
|
||||
Теперь подметание сверяется с сохранённой конфигурацией — под той же
|
||||
блокировкой, что и запись, — и собирает устаревшие непривязанные вложения и
|
||||
планы тоже.
|
||||
- **Перетаскивание больше не отменяется чужим перемещением (HP-1461-02).** Когда
|
||||
в v1.46.1 полная карточка научилась следить за позициями, она защищала те,
|
||||
что вы подвинули, но ещё не отправили, — вот только читала этот список *после*
|
||||
сброса отложенной записи, а сброс его первым делом опустошает. При настоящем
|
||||
перетаскивании, когда запись уже запланирована, список оказывался пустым, и
|
||||
старая серверная позиция закрашивала ваше движение. Теперь снимок снимается до
|
||||
сброса, и вдобавок удерживаются позиции, отправленные, но ещё не
|
||||
подтверждённые: пока сервер не подтвердил позицию, авторитет по ней — та
|
||||
карточка, которая её подвинула.
|
||||
- Два теста доросли до своих же описаний: тест загрузки теперь действительно
|
||||
отменяет задачу запроса, а не только проходит по путям ошибок, а смоук
|
||||
синхронизации позиций планирует настоящую отложенную запись и задерживает её —
|
||||
именно этот порядок и терял перетаскивание.
|
||||
|
||||
## v1.46.1 — 2026-07-28 (перепроверка v1.46.0: HP-1460-01 … -03)
|
||||
- **Две загрузки с одинаковым именем больше не сталкиваются (HP-1460-01).**
|
||||
v1.46.0 перестала затирать вложения, но выбор свободного имени и его занятие
|
||||
были двумя шагами: две загрузки, попавшие между ними, сходились на одном
|
||||
имени, обе рапортовали успех, и одни байты заменяли другие. Теперь имя
|
||||
занимается атомарно в момент выбора — двадцать одновременных загрузок
|
||||
`manual.pdf` дают двадцать файлов. Тот же механизм используется при переносе
|
||||
файлов на перепривязке, где был ровно такой же зазор.
|
||||
Заодно там же: имя предельной длины теряло расширение, а суффикс коллизии
|
||||
выталкивал его за предел, и вложение сохранялось под именем, которое сервер
|
||||
обратно не отдаёт — вечный 404 на файл, который интерфейс считал
|
||||
прикреплённым.
|
||||
- **Прерванная загрузка больше не оставляет временный файл навсегда
|
||||
(HP-1460-02).** Уборка стояла в `except Exception`, мимо которого отменённый
|
||||
запрос проходит насквозь, а сборщик заглядывает только в папки маркеров —
|
||||
поэтому оборванная передача оставляла `.upload-*`, и убрать его было некому.
|
||||
Теперь убирает любой путь выхода, запрос с двумя файлами отклоняется сразу, а
|
||||
брошенные временные подметаются при старте и раз в сутки. Запись идёт
|
||||
порциями по мегабайту, а не отдельной задачей на каждые 64 КБ.
|
||||
- **Две полные карточки рядом держат одинаковые позиции (HP-1460-03).** v1.46.0
|
||||
научила следить за перемещениями статическую карточку и оставила позади
|
||||
полную, поэтому перетаскивание иконки в одном окне не двигало её в другом до
|
||||
перезагрузки. Теперь следит — и не мешает собственному перетаскиванию: чужая
|
||||
ревизия, пришедшая в его разгар, сливается, а не накатывается сверху, и
|
||||
карточка не перечитывает то, что записала сама.
|
||||
|
||||
## v1.46.0 — 2026-07-28 (полный внешний аудит v1.45.4: HP-1454-01 … -10)
|
||||
|
||||
**Безопасность**
|
||||
|
||||
- **Загруженный SVG-план больше не является живым документом origin вашего
|
||||
Home Assistant (HP-1454-01, high — блокер релиза).** Внутри карточки план
|
||||
подключён через `<image>`, где скрипты не выполняются; но тот же URL,
|
||||
открытый напрямую, становился документом верхнего уровня в origin самого HA,
|
||||
и `<script>` в нём мог читать `localStorage` сессии и обращаться к API. Для
|
||||
загрузки нужно право записи, а оно по умолчанию есть у каждого
|
||||
аутентифицированного пользователя, и подписанную ссылку несложно передать
|
||||
администратору. Ответы с SVG теперь несут заголовок Content-Security-Policy
|
||||
`sandbox`, который помещает документ в opaque origin. Только SVG — CSP на PDF
|
||||
способен сломать встроенный просмотрщик браузера, а растровая картинка ничего
|
||||
выполнить не может. Для существующих планов ничего не меняется: карточка
|
||||
рисует их ровно как раньше.
|
||||
|
||||
**Целостность данных**
|
||||
|
||||
- **Инструкция, приложенная к устройству, больше не затирает предыдущую
|
||||
(HP-1454-02).** Загрузка писала прямо в `<маркер>/<имя файла>`, вне
|
||||
транзакции конфигурации: отмена диалога или отвергнутое сохранение оставляли
|
||||
сохранённую ссылку указывающей на новые байты. А все новые иконки грузили в
|
||||
одну общую папку, поэтому две с файлом `manual.pdf` начинали ссылаться на
|
||||
один физический файл. Теперь загрузка занимает свободное имя и никогда не
|
||||
перезаписывает, новая иконка получает собственную промежуточную папку, файлы
|
||||
из которой переезжают к настоящей иконке при принятом сохранении, а загрузка,
|
||||
которую никто не сохранил, убирается через час. Имя, на которое уходит
|
||||
коллизия, сменилось с `manual (2).pdf` на `manual-2.pdf`: старое санитайзилось
|
||||
на обратном пути, поэтому переименованное вложение записывалось и больше не
|
||||
отдавалось (нашёл новый тест; до этого релиза так же ломались коллизии при
|
||||
перепривязке).
|
||||
- **Две быстрые правки больше не теряют вторую (HP-1454-03).** Debounce
|
||||
разносил старты сохранения, а не сами сохранения. Если одно длилось дольше
|
||||
полусекунды — занятый сервер, медленная связь, — следующая правка уходила с
|
||||
той же ревизией, сервер принимал первую и отклонял вторую, а обработчик
|
||||
конфликта перечитывал серверную копию поверх локальной. Правка исчезала, и
|
||||
сообщение винило «другое окно», которого не было. Записи сериализованы: по
|
||||
одной за раз, каждая с ревизией, которую вернула предыдущая.
|
||||
|
||||
**Корректность и пределы**
|
||||
|
||||
- **Открытые границы снова следуют за геометрией (HP-1454-04).** Их кэш
|
||||
ключевался только по id комнат и связям, поэтому смена пропорций
|
||||
пространства или перетаскивание вершины оставляли открытые границы — и свет,
|
||||
который через них проходит, — в старых координатах до перезагрузки. Ключом
|
||||
стала сама отрисованная модель, из которой они и вычисляются.
|
||||
- **Конфигурацию больше нельзя сделать сколь угодно тяжёлой (HP-1454-05).**
|
||||
Внешние коллекции были ограничены, вложенные — нет. Полигон на 150 000 точек
|
||||
или список из 100 000 идентификаторов проходили валидацию, а потом каждый
|
||||
рендер по ним ходил. Появились пределы на вершины полигона, связи открытых
|
||||
границ, управляемые сущности, вложения, длину текста и ссылок, плюс общий
|
||||
предел размера сериализованной конфигурации. Устаревшее поле `segments`
|
||||
отбрасывает сервер, а не надежда на современный клиент.
|
||||
- **Большие файлы передаются потоком, а не через память (HP-1454-06).**
|
||||
Инструкция на 50 МБ целиком читалась в память на приёме и ещё раз на отдаче;
|
||||
пара параллельных скачиваний — заметная нагрузка на слабый хост Home
|
||||
Assistant. Загрузка пишется во временный файл потоком, отдача идёт с диска.
|
||||
|
||||
**Согласованность**
|
||||
|
||||
- **Статическая карточка пространства учитывает настройки заливки комнаты
|
||||
(HP-1454-07).** Она строит модель другой функцией, и настройки комнаты в неё
|
||||
не переносились — комната, которой вы выключили заливку, всё равно
|
||||
закрашивалась.
|
||||
- **Перемещение иконки сразу видно на статической карточке (HP-1454-08).**
|
||||
Layout — отдельное состояние без ревизии и без события: перетаскивание на
|
||||
полной карточке оставляло соседнюю статическую со старой позицией до
|
||||
изменения конфигурации или перезагрузки страницы. Теперь записи layout хранят
|
||||
ревизию, возвращают её и сообщают о себе — заодно оптимистическая блокировка
|
||||
на полной записи layout начала что-то значить, ведь точечная запись раньше
|
||||
сбрасывала счётчик.
|
||||
- **Предупреждение о пропавшем плане исчезает вместе с пространством
|
||||
(HP-1454-09).** Уборка смотрела только на существующие пространства, поэтому
|
||||
удаление или переименование оставляло предупреждение в «Ремонте» навсегда.
|
||||
- Сборка: `serialize-javascript` поднят выше двух advisory (HP-1454-10). В
|
||||
рантайме недостижим, production-зависимости и так были чисты, но это одна
|
||||
строка.
|
||||
|
||||
## v1.45.4 — 2026-07-28 (ревью v1.45.3: R5-1, R5-2)
|
||||
- **Частично успешный ответ на подпись больше не пропускает выдержку (R5-1).**
|
||||
Бэкенд подписывает каждый путь независимо: тот, что подписать не удалось,
|
||||
логируется, пропускается, и вызов всё равно завершается успешно с остальными
|
||||
ссылками. Карточка считала любой успешный вызов «весь батч готов», сбрасывала
|
||||
выдержку для всех путей в нём и записывала только вернувшиеся ссылки — и путь,
|
||||
который бэкенд стабильно пропускал, запрашивался заново на каждом рендере, то
|
||||
есть ровно то усиление, ради которого выдержка и вводилась в v1.45.2. Теперь
|
||||
путь считается подписанным, только если в ответе действительно есть ссылка на
|
||||
него; остальные уходят в выдержку по отдельности, ключи, которых не просили,
|
||||
игнорируются, а перерисовка запускается лишь при появлении хотя бы одной новой
|
||||
подписи.
|
||||
- **Снимок состояния больше не противоречит репозиторию (R5-2).** Там всё ещё
|
||||
было написано, что в `main` лежат релизы только до v1.40.1, и приводились
|
||||
счётчики тестов на несколько релизов назад — при том что строка версии рядом
|
||||
исправно обновлялась. Читающий его человек или агент получал неверную
|
||||
модель веток и заниженное представление о покрытии. Роли веток описаны точно,
|
||||
а счётчики убраны: `npm run inventory` печатает их из дерева, и устаревать
|
||||
больше нечему.
|
||||
|
||||
## v1.45.3 — 2026-07-27
|
||||
- **«Значение вместо иконки» невозможно было сохранить (issue #3).** Опция
|
||||
появилась в редакторе устройств ещё в v1.26.0, но серверная схема всё это
|
||||
время принимала только `badge`, `ripple` и `icon_ripple`. При её выборе
|
||||
сохранение падало с
|
||||
`not a valid value for dictionary value @ data['config']['markers'][n]['display']`,
|
||||
а поскольку один отвергнутый маркер валит всю запись конфигурации, план не
|
||||
сохранялся вообще, пока настройку не отменишь. Спасибо @RemyRoux за отчёт и
|
||||
точный текст ошибки.
|
||||
- **Списки опций теперь в одном месте и сверяются между языками.**
|
||||
`DISPLAY_MODES`, `TAP_ACTIONS`, `SPACE_FILL_MODES` и `ROOM_FILL_MODES`
|
||||
экспортируются из карточки, и backend-тест читает их, проверяя, что схема
|
||||
принимает каждое значение, которое пользователь реально может выбрать.
|
||||
Теперь добавить опцию в редактор и забыть про схему — значит уронить тесты, а
|
||||
не узнать об этом из чужого сообщения об ошибке.
|
||||
|
||||
## v1.45.2 — 2026-07-27 (закалка по ревью v1.45.1: R4-1, R4-2)
|
||||
- **Сбой уборки больше не превращает принятое сохранение в ошибку (R4-1).**
|
||||
Сборка вытесненных файлов плана идёт уже после того, как конфигурация
|
||||
сохранена, но ошибка при обходе каталога — он может исчезнуть или стать
|
||||
недоступным между проверкой и обходом — вылетала наружу из `config/set`.
|
||||
Клиент видел неудачу для ревизии, которую сервер закоммитил, а его повтор
|
||||
возвращался с конфликтом. Теперь сборщик сообщает «ничего не убрано» вместо
|
||||
исключения, а `config/set` пишет в лог и продолжает: событие уходит, новая
|
||||
ревизия возвращается.
|
||||
- **Один запрос подписи на ссылку вместо одного на рендер (R4-2).** Множество
|
||||
ожидающих очищалось в момент отправки батча, а не по возвращении, поэтому
|
||||
пока запрос `content/sign` был в полёте, каждая перерисовка ставила ещё
|
||||
один — шесть вызовов там, где нужен один, и куда хуже на медленном (а не
|
||||
просто занятом) сокете. Состояния «в очереди» и «в полёте» теперь разделены,
|
||||
после ошибки включается выдержка (2 с с удвоением до 60 с) вместо повтора на
|
||||
следующем кадре, а запрос, который так и не завершился, перестаёт блокировать
|
||||
повторы через 15 с. Поздний ответ, пришедший после размонтирования карточки,
|
||||
больше не вызывает перерисовку.
|
||||
- Тесты: восемь юнит-тестов подписывателя с ручным разрешением promise (четыре
|
||||
падают на v1.45.1), backend-тест на то, что сломанный сборщик оставляет
|
||||
сохранение успешным с рабочей ревизией, и проверка исчезнувшего каталога в
|
||||
тестах чистого сборщика.
|
||||
|
||||
## v1.45.1 — 2026-07-27 (повторное ревью v1.45.0: R3-1, R3-2)
|
||||
- **Уборка старых файлов плана перенесена внутрь транзакции конфига (R3-1,
|
||||
high).** v1.45.0 сделала загрузку безопасной, но отдала удаление клиенту:
|
||||
после успешного сохранения карточка просила бэкенд убрать всё, кроме только
|
||||
что закоммиченного файла. Два открытых редактора невозможно упорядочить —
|
||||
задержавшийся запрос одного клиента удалял план, который только что сохранил
|
||||
другой, и принятая конфигурация оставалась со ссылкой в пустоту, то есть
|
||||
ровно с тем ущербом, ради которого вводился copy-on-write. Команда
|
||||
`houseplan/plan/cleanup` убрана. Теперь `config/set` убирает сам, под своей
|
||||
блокировкой, сравнивая конфигурацию, которую заменил, с той, которую принял:
|
||||
файл, на который ссылалась старая ревизия и не ссылается новая, удаляется, а
|
||||
любая другая непривязанная загрузка не трогается, пока ей не исполнится час —
|
||||
свежая может принадлежать ещё не завершённой чужой транзакции.
|
||||
- **Статическая карточка пространства снова показывает подложку (R3-2).** Она
|
||||
подписывала URL и выбрасывала результат — `getCardSize()` правил временную
|
||||
модель, а `render()` строил свою заново из конфига, — поэтому `<image>`
|
||||
запрашивал защищённый путь и на каждом рендере получал 401. Обе карточки
|
||||
теперь используют один подписыватель, и статическая заодно получила батчи,
|
||||
учёт срока годности и периодическое переподписывание, которые были только у
|
||||
основной. Её множество ожидающих запросов освобождается в `finally`, так что
|
||||
одна неудача больше не блокирует ссылку до конца жизни страницы.
|
||||
- Новые тесты: пять backend-сценариев чередования двух клиентов из отчёта
|
||||
(поздний коммит, незакоммиченная загрузка, устаревший сирота, чужие файлы,
|
||||
отвергнутое сохранение), сборщик вынесен в чистый модуль и покрыт юнит-
|
||||
тестами, плюс `smoke_space_card_bg` на подписанный фон — он падает на
|
||||
v1.45.0, где в DOM попадает сырой URL.
|
||||
|
||||
## v1.45.0 — 2026-07-27 (внешнее ревью v1.44.8: R2-1, R2-2, R2-3)
|
||||
- **Отвергнутое сохранение больше не может испортить рабочий план (R2-1,
|
||||
high).** Файл плана записывался под финальным именем — попутно удаляя вариант
|
||||
с другим расширением — *до* проверки ревизии конфига. Если запись потом
|
||||
отвергалась (конфликт ревизий, валидация, обрыв связи), живой план оказывался
|
||||
уже подменён, а сохранённый конфиг мог ссылаться на удалённый файл. Теперь
|
||||
загрузка идёт в версионированное имя (`<space>.<токен>.<ext>`) и ничего не
|
||||
удаляется; карточка просит бэкенд убрать устаревшие файлы только после
|
||||
принятой записи конфига. Падение между шагами оставляет один осиротевший
|
||||
файл, который подберёт следующая успешная загрузка.
|
||||
- **Подписанные ссылки больше не протухают навсегда на долгоживущих экранах
|
||||
(R2-2).** Бэкенд подписывает не более 200 путей за запрос и молча отбрасывает
|
||||
остальные, а карточка отправляла весь кэш одним вызовом и считала любую
|
||||
запись годной вечно. Начиная с 201-го вложения поздние ссылки переставали
|
||||
обновляться и через 24 часа тихо ломались. Теперь запросы бьются на батчи по
|
||||
общему лимиту, записи помнят свой возраст (стареющая ссылка работает, пока
|
||||
едет замена, протухшая не отдаётся вовсе), а кэш чистится до ссылок, на
|
||||
которые конфиг всё ещё ссылается.
|
||||
- **Климат комнат считается один раз на обновление, а не на каждую комнату
|
||||
(R2-3).** Каждая комната запрашивала температуру и влажность по отдельности,
|
||||
и каждый запрос заново обходил весь реестр сущностей: 60 комнат и 2000
|
||||
сущностей — это ~120 обходов на рендер, целый кадр на метаданные, которые не
|
||||
менялись. Теперь один проход строит карту по всем зонам с привязкой к снимку
|
||||
Home Assistant: свежие состояния видны всегда, а посторонние перерисовки не
|
||||
стоят ничего. Замер в смоуке: 133 обхода реестра на обновление до, 2 после —
|
||||
и число больше не растёт с числом комнат.
|
||||
- `smoke_ux_fixes` писал скриншот по жёстко зашитому пути `/tmp` и не запускался
|
||||
на Windows — теперь берёт временную папку у ОС.
|
||||
- Новые тесты: `smoke_plan_upload_reject` (чистка только после принятого
|
||||
сохранения), `smoke_sign_cap` (201 ссылка, батчи, чистка, срок годности),
|
||||
`smoke_climate_once` (число обходов не растёт с числом комнат), плюс
|
||||
backend-покрытие версионированных имён и юнит-тесты новых хелперов.
|
||||
|
||||
## v1.44.8 — 2026-07-27
|
||||
- **Загруженная подложка действительно привязывается к пространству.**
|
||||
`_saveSpaceDialog` держал ссылку на объект пространства через `await`
|
||||
загрузки картинки. Любое событие `houseplan_config_updated` запускает
|
||||
`_reloadConfigOnly()`, а оно *заменяет* `_serverCfg` — ссылка становилась
|
||||
осиротевшей, и `plan_url`, `aspect`, заголовок и все настройки отображения
|
||||
писались в отсоединённый объект, тогда как на сервер уходил нетронутый
|
||||
конфиг. Файл попадал на диск, подложка не появлялась, пересохранение не
|
||||
помогало. Создание пространства в этот момент теряло пространство целиком.
|
||||
Теперь загрузка идёт *до* обращения к конфигу, и ни одна ссылка не живёт
|
||||
через await.
|
||||
- **`_saveConfigNow` помечает запись как выполняющуюся** (`_cfgWriting`) — так
|
||||
же, как отложенный писатель, — поэтому чужая ревизия, пришедшая посреди
|
||||
сохранения, откладывает перечитывание вместо подмены конфига (аудит L2,
|
||||
расширен на этот путь).
|
||||
- Регрессионный тест `demo/smoke_plan_upload_race.mjs` падает на v1.44.7 и
|
||||
проходит здесь.
|
||||
|
||||
## v1.44.7 — 2026-07-27
|
||||
- **Подложки снова отображаются (регрессия с v1.44.5).** Эндпоинт с файлами
|
||||
требует авторизации, поэтому карточка просит бэкенд подписать ссылку на план —
|
||||
но подпись подставлялась внутри *мемоизированной* модели пространства, а она
|
||||
кэшируется по отпечатку конфига. Неподписанная ссылка «замерзала» в кэше,
|
||||
подпись до элемента `<image>` не доезжала, и план не грузился никогда. Теперь
|
||||
ссылка вычисляется в момент отрисовки, вне кэша. (Ссылки на PDF не страдали —
|
||||
там она и так вычислялась при отрисовке.)
|
||||
- **Больше нет «неудачной попытки входа» с собственного IP.** Пока подложка была
|
||||
сломана, браузер продолжал дёргать неподписанный путь, тот отвечал 401, и
|
||||
Home Assistant поднимал предупреждение о неудачном входе с адреса самого
|
||||
зрителя. Теперь до получения подписи не рисуется ничего, и неподписанный
|
||||
запрос не уходит вовсе.
|
||||
- **Долгоживущие экраны не моргают.** Переподписывание раз в 12 часов раньше
|
||||
сбрасывало все подписи и ждало новые; теперь текущие ссылки держатся до
|
||||
прихода замены, так что настенный планшет не показывает пустой план.
|
||||
- Регрессионный тест `demo/smoke_plan_signed.mjs` падает на v1.44.6 и проходит
|
||||
здесь.
|
||||
|
||||
## v1.44.6 — 2026-07-27
|
||||
- **Климатом комнаты считается только температура *воздуха*.** После v1.44.5,
|
||||
когда данные стали браться из реестра зон, а не с видимых значков,
|
||||
кандидатами стали все скрытые датчики температуры в зоне — в том числе те,
|
||||
что меряют вовсе не воздух. Перед усреднением теперь работают три фильтра:
|
||||
пропускаются сущности с категорией диагностика/настройка, сущности
|
||||
исключённых интеграций и сущности, в id которых назван не-воздушный носитель
|
||||
(`water`, `coolant`, `flow_temp`, `return_temp`, `target`, `setpoint`, `chip`,
|
||||
`cpu`, `processor`, `board`, `device_temp`, `batter`, `freezer`, `fridge`,
|
||||
`oven`, `kettle`, `boiler`).
|
||||
На живой установке с 60 зонами это убрало четыре реальных ложных
|
||||
срабатывания: температуру процессора NAS, воду в умном чайнике, сауну с 90 °C
|
||||
и виртуальный `better_thermostat`, дублирующий настоящий датчик.
|
||||
- **Новые правила иконок:** чайники и термопоты получают `mdi:kettle`, сауны
|
||||
(`sauna`, `harvia`, `парная`) — `mdi:hot-tub`. Раньше и те и другие попадали
|
||||
под общее правило термометра, из-за чего и учитывались в климате комнаты.
|
||||
|
||||
## v1.44.5 — 2026-07-27
|
||||
- **Климат комнаты считается по всем датчикам зоны**, включая устройства,
|
||||
которых нет на плане (скрыты курированием или вами). Раньше среднее бралось
|
||||
только по видимым значкам, поэтому скрытый термометр молча выпадал из
|
||||
карточки комнаты, подсказки и температурной заливки. Курирование сохранено
|
||||
(холодильники, термоголовки и розетки с температурой чипа не считаются), а
|
||||
явно выбранный источник в настройках комнаты по-прежнему главнее.
|
||||
- Из подсказки к комнате убрана фраза «открыть зону» — клик по комнате перестал
|
||||
никуда вести ещё в v1.40.1, для перехода есть значок-ссылка у названия.
|
||||
|
||||
|
||||
## v1.44.4 — 2026-07-27 (доработка по аудиту: B2, B5, L4)
|
||||
- **Единая политика авторизации (B2).** HTTP-загрузка по-прежнему **разрешала**
|
||||
запись, когда запись о конфигурации недоступна, тогда как WebSocket-путь уже
|
||||
отказывал — они разошлись. Теперь оба вызывают общий помощник `may_write`,
|
||||
который в неопределённой ситуации пропускает только администраторов.
|
||||
- **NaN/Infinity отвергаются во всех координатах (B5).** Проверка на конечность
|
||||
числа стояла только у позиций раскладки; прямоугольники комнат, вершины
|
||||
полигонов, `view_box` и координаты проёмов принимали `"NaN"`, который при
|
||||
записи превращается в `null` и необратимо портит геометрию. Ограничение
|
||||
`MAX_OPENINGS` было объявлено, но нигде не использовалось — список проёмов
|
||||
оставался безразмерным.
|
||||
- **Укрепление перетаскивания (часть L4).** Безопасная обёртка над
|
||||
`setPointerCapture` теперь используется во всех сценариях перетаскивания
|
||||
(устройства, подписи, изменение размера), а не только у проёмов — «мёртвый»
|
||||
идентификатор указателя мог оборвать перетаскивание. Фигуры декора получили
|
||||
ограничение по границам: их больше нельзя утащить далеко за пределы плана и
|
||||
сохранить там.
|
||||
|
||||
## v1.44.3 — 2026-07-27 (исправление: планы и инструкции снова загружаются)
|
||||
- **У аутентифицированной выдачи контента не было рабочего пути для браузера.**
|
||||
Версия v1.43.0 закрыла дыру правильно, но Home Assistant аутентифицирует
|
||||
HTTP-запросы либо заголовком Bearer, либо подписанным путём `authSig` — а
|
||||
`<image href>` внутри SVG и обычная ссылка `<a href>` не отправляют ни того,
|
||||
ни другого. На настоящем дашборде фоны планов и ссылки на PDF отдавали
|
||||
**401** (воспроизведено вживую до исправления). Теперь карточка просит бэкенд
|
||||
подписать то, что собирается показать (`houseplan/content/sign`, 24 часа,
|
||||
привязано к токену сессии, только для нашего эндпоинта), перерисовывается,
|
||||
когда подписи приходят, и обновляет их каждые 12 часов, чтобы настенные
|
||||
планшеты продолжали работать. Тест бэкенда скачивает подписанный адрес **без**
|
||||
заголовка авторизации и проверяет 200, а без подписи — 401.
|
||||
|
||||
## v1.44.2 — 2026-07-27 (внешнее код-ревью: CR-1…CR-3)
|
||||
|
||||
Второе, состязательное ревью (версии v1.44.0) дало три находки — все закрыты.
|
||||
|
||||
- **Правило про замки теперь сформулировано точно и проверяется (CR-1).**
|
||||
Рецензент справедливо отметил, что утверждение «замок нельзя открыть с плана»
|
||||
было слишком абсолютным: кнопка в карточке двери действительно вызывает
|
||||
сервис. Эта кнопка — осознанное продуктовое решение, поэтому правило
|
||||
переписано там, где ему место («никогда случайным нажатием; ровно одна
|
||||
подписанная поверхность»), отпирание теперь **спрашивает подтверждение**, а
|
||||
новый смок-тест проверяет все пять путей управления и доказывает, что значки,
|
||||
`controls[]` и карточка устройства по-прежнему отказывают замкам.
|
||||
- **Перенос вложений стал транзакционным (CR-2).** При смене привязки маркера
|
||||
файлы раньше ПЕРЕМЕЩАЛИСЬ до сохранения конфига с проверкой ревизии: если
|
||||
сохранение отклонялось, на сервере оставались старые ссылки, а файлы уже
|
||||
уехали. Теперь сервер копирует, конфиг фиксируется, и только после этого
|
||||
старая папка удаляется (`houseplan/files/cleanup`).
|
||||
- **Неудачный или частичный перенос больше не переписывает ссылки (CR-3).**
|
||||
Копирование возвращает точное соответствие «исходное имя → записанное»;
|
||||
переписываются только подтверждённые копии, при совпадении имён файл получает
|
||||
уникальное имя вместо молчаливой ссылки на чужой файл, а ошибка переноса
|
||||
показывается тостом.
|
||||
|
||||
## v1.44.1 — 2026-07-27
|
||||
|
||||
- Ссылка на чат сообщества добавлена везде, где её ищут:
|
||||
**https://t.me/ha_houseplan** (бейдж и строка в шапке обоих README, раздел
|
||||
«Помощь и обмен опытом», контакт-ссылки в шаблонах issue, CONTRIBUTING,
|
||||
STATUS и SCOPE).
|
||||
|
||||
## v1.44.0 — 2026-07-27 (отзыв пользователя: сначала управление)
|
||||
|
||||
- **Карточка устройства стала поверхностью управления.** Она открывается со
|
||||
списка управляемых сущностей: лампы, розетки и вентиляторы переключаются
|
||||
прямо здесь кнопками под палец, шторы, замки и климат передают управление в
|
||||
штатный more-info Home Assistant. Модель, ссылки и PDF-инструкции ушли ниже —
|
||||
на настенном планшете эта карточка нужна для управления домом, а не для
|
||||
чтения документации (из полевого отзыва). Служебные (config/diagnostic)
|
||||
сущности в списке не показываются, замки по-прежнему не переключаются
|
||||
нажатием в карточке.
|
||||
- **«Это устройство — источник света»** — новый флаг у устройства. Умный
|
||||
выключатель с обычными (не умными) светильниками теперь даёт ореол в заливке
|
||||
«Свет по источникам» без создания хелпера-группы: свечение следует за самим
|
||||
выключателем либо за лампами, привязанными в «Управляет источниками света».
|
||||
|
||||
## v1.43.3 — 2026-07-27 (отзыв пользователя: обнаруживаемость и тач)
|
||||
|
||||
- **Настройки комнаты невозможно было найти.** Шестерёнка из v1.42.0 жила
|
||||
внутри подписи комнаты размером 0.9em от её шрифта и с прозрачностью 60% —
|
||||
несколько бледных пикселей на обычном плане. Теперь это кнопка-пилюля
|
||||
«⚙ Комната» фиксированного читаемого размера, не зависящая от масштаба
|
||||
карточки, и она появляется **даже у комнат без имени** (их там и называют).
|
||||
Заодно разблокировались слайдеры размеров шрифта, до которых никто не мог
|
||||
добраться.
|
||||
- **Строка показателей увеличена** с 0.62 до 0.75 от размера названия — автор
|
||||
отзыва мог увеличить название, но строка датчиков оставалась нечитаемой на
|
||||
планшете. Множители комнаты и пространства работают поверх.
|
||||
- **Тултипы на тач-устройствах, вторая попытка.** Проверки `(hover: none)`
|
||||
оказалось мало: некоторые устройства, оболочки, стилусы и подключённые мыши
|
||||
сообщают `hover: hover`, и подсказки продолжали висеть под пальцем. Теперь
|
||||
карточка запоминает первое же касание (touch/pen) и гасит открытую подсказку.
|
||||
|
||||
## v1.43.2 — 2026-07-27 (внешний аудит: слой тестов)
|
||||
|
||||
- **Смок-тесты наконец умеют падать (T1).** Все 48 браузерных смоков печатали
|
||||
булевы значения и всегда завершались с кодом 0 — регрессия была видна в их
|
||||
собственном выводе, а прогон считался успешным. `demo/serve.mjs` теперь
|
||||
экспортирует `check`/`checkAll`/`finish`: каждый факт проверяется по имени,
|
||||
несовпадения и необработанные исключения внутри карточки дают ненулевой код
|
||||
возврата. Проверено намеренной поломкой блокировки редакторов в киоске —
|
||||
соответствующий смок покраснел.
|
||||
- **Набор гоняется в CI (T2)** отдельной джобой `smoke` после `frontend`,
|
||||
против свежесобранного бандла (закоммиченная копия в `demo/srv/assets` —
|
||||
снимок, на нём легко получить «зелёный» отчёт о несуществующем коде), с
|
||||
выгрузкой логов при падении.
|
||||
- **`docs/TESTING.md` приведён в соответствие (T3).** `[auto]` теперь означает
|
||||
«существует именованная проверка, которая падает», и рядом написано, где она;
|
||||
72 пункта, где автоматизация была намерением, честно помечены `[manual]`.
|
||||
Исправлены два давних противоречия: строка про «ноль кнопок редактирования в
|
||||
Просмотре» (неверна с v1.30.1) и строка про клик по проёму (снова верна
|
||||
с v1.43.1).
|
||||
- Три смока проверяли поведение, которого уже нет (ожидания времён v1.39.0 и
|
||||
v1.25); теперь они тестируют текущий контракт.
|
||||
|
||||
## v1.43.1 — 2026-07-27 (внешний аудит: исправления P1)
|
||||
|
||||
- **Стоимость отрисовки (L1).** Home Assistant подменяет объект `hass` при
|
||||
любом изменении состояния в доме, и каждая такая отрисовка пересчитывала всю
|
||||
геометрию плана — `_openPairs()` вызывался по разу на комнату (кубическая
|
||||
математика коллинеарных наложений), модель пространства строилась дважды.
|
||||
Теперь и то, и другое мемоизируется по структурному отпечатку конфига и
|
||||
вынесено из цикла по комнатам; сброс кэша происходит синхронно в момент
|
||||
мутации, а не внутри дебаунса.
|
||||
- **Тап против перетаскивания у проёмов (L4).** У перетаскивания двери или окна
|
||||
не было порога движения, поэтому любое дрожание пальца считалось
|
||||
перетаскиванием: диалог свойств не открывался, а в конфиг писалось
|
||||
неизменённое состояние (что подпитывало гонку L2). Теперь порог 3 px, как во
|
||||
всех остальных сценариях перетаскивания, и запись только при реальном
|
||||
изменении геометрии.
|
||||
- **Вогнутые комнаты (G2).** Вложенность определялась через среднее арифметическое
|
||||
вершин — а оно лежит СНАРУЖИ U- и L-образных комнат, поэтому комнаты-острова
|
||||
в них отвергались как пересечение, а дырка в заливке не рисовалась. Теперь
|
||||
вычисляется настоящая внутренняя точка (`interiorPoint`).
|
||||
- **Дедупликация стен (G3).** `segKey` сортировал концы по сырым float, а
|
||||
печатал округлённые, поэтому одна общая стена могла дать два ключа и
|
||||
рисовалась дважды. Сначала округление, потом сортировка.
|
||||
- **Укрепление бэкенда (B2–B5).** Проверка прав на запись теперь **отказывает**,
|
||||
когда запись о конфигурации недоступна (раньше во время перезагрузки
|
||||
интеграции запись разрешалась); `layout/set` поддерживает `expected_rev` и
|
||||
сообщает о конфликте так же, как хранилище конфига; `config/set` без
|
||||
`expected_rev` поверх непустого хранилища пишет предупреждение в лог;
|
||||
координаты отвергают NaN/Infinity, а у пространств, комнат, маркеров, декора
|
||||
и раскладки появились щедрые ограничения размера.
|
||||
|
||||
## v1.43.0 — 2026-07-27 (внешний аудит: исправления P0)
|
||||
|
||||
Внешний аудит кода версии v1.41.1 нашёл четыре критические проблемы. Все четыре
|
||||
исправлены и покрыты регрессионными тестами.
|
||||
|
||||
- **Молчаливая потеря правок при сохранении (L2).** Отложенная запись конфига
|
||||
читала его в момент срабатывания, поэтому пришедшее в промежутке событие
|
||||
`houseplan_config_updated` подменяло конфиг, и правка пользователя исчезала
|
||||
без единой ошибки — воспроизводилось даже в одной вкладке. Теперь дебаунс
|
||||
умеет `flush()`/`pending()`, перезагрузка сперва дописывает отложенную
|
||||
запись и откладывается, пока запись в полёте, а неудачная перезагрузка
|
||||
наконец сообщает о себе вместо молчания.
|
||||
- **Разрез разрушал геометрию комнаты (G1).** Разрез, начинающийся и
|
||||
заканчивающийся на ОДНОЙ стене (вырезание ниши — совершенно естественное
|
||||
действие), давал две самопересекающиеся комнаты, суммарная площадь которых
|
||||
вдвое превышала исходную, и проверка пересечений это не ловила. Теперь такие
|
||||
разрезы корректно вырезают нишу, а инвариант разбиения (части в сумме дают
|
||||
исходную площадь) отклоняет всё остальное.
|
||||
- **Планы и загруженные файлы отдавались без авторизации (B1).** Любой, кто мог
|
||||
достучаться до вашего Home Assistant, скачивал планы этажей и вложенные
|
||||
инструкции без входа в систему. Теперь их отдаёт аутентифицированный
|
||||
обработчик; сохранённые старые адреса переписываются на чтении, так что
|
||||
ничего не ломается. **Старые публичные пути исчезают только после
|
||||
перезапуска Home Assistant.**
|
||||
- **Диалоги могли воскреснуть и обнулить карточку (L3).** Закрытие диалога во
|
||||
время неудачного сохранения превращало его состояние в пустую «оболочку»,
|
||||
отрисовщик падал, и карточка оставалась пустой до перезагрузки страницы.
|
||||
Защита добавлена во все четыре процедуры сохранения, тост об ошибке
|
||||
по-прежнему показывается.
|
||||
|
||||
## v1.42.2 — 2026-07-26
|
||||
|
||||
- На тач-устройствах подсказки при наведении больше не выскакивают при каждом
|
||||
касании (из отзыва: «на планшете при тапе вылезают доп. надписи — мешают»).
|
||||
Подсказки теперь только для мыши; на тач та же информация есть в карточках
|
||||
комнат и в карточке устройства по долгому нажатию.
|
||||
|
||||
## v1.42.1 — 2026-07-26 (размеры шрифтов карточек комнат)
|
||||
|
||||
- Закрываем отзыв «нельзя настроить размер шрифта»: **три слайдера**. В
|
||||
настройках пространства появился базовый размер шрифта карточек комнат для
|
||||
всего пространства; в настройках комнаты — независимые размеры **названия** и
|
||||
**строки показателей** (50–300% каждый). Эффекты перемножаются и складываются
|
||||
с растягиванием карточки за уголки и множителем экрана в киоск-режиме.
|
||||
- В обоих диалогах показывается **живой пример карточки**, который меняется
|
||||
прямо во время перетаскивания слайдеров.
|
||||
|
||||
## v1.42.0 — 2026-07-26 (настройки комнаты — третий уровень)
|
||||
|
||||
- **У настроек теперь четыре уровня**: общие → пространство → комната →
|
||||
устройство; более конкретный уровень переопределяет более общий (решение
|
||||
владельца, зафиксировано в ARCHITECTURE). В этом релизе добавлен уровень
|
||||
КОМНАТЫ.
|
||||
- У каждой карточки комнаты в редакторе плана появилась **шестерёнка**:
|
||||
переименовать комнату, сменить её зону HA, переопределить **тип заливки**
|
||||
только для этой комнаты (работает и в glow-пространствах — «без заливки»
|
||||
выводит комнату из темноты) и выбрать явный **источник температуры и
|
||||
влажности** — любое устройство или сущность HA вместо среднего по комнате.
|
||||
Источник питает карточку комнаты, всплывающую подсказку и температурную
|
||||
заливку и работает даже у комнат без зоны HA (случай из отзыва: собственный
|
||||
template-сенсор, привязанный к помещению).
|
||||
- Тот же раздел настроек появляется в диалоге комнаты сразу после замыкания
|
||||
контура.
|
||||
+31
-2
@@ -51,9 +51,19 @@ cp dist/houseplan-card.js custom_components/houseplan/frontend/
|
||||
|
||||
- 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/`
|
||||
- **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
|
||||
by nobody: md5 on the server matches, the browser still gets the old bundle,
|
||||
and hours go into debugging a bug that was already fixed. Cost this mistake
|
||||
once: 2026-07-27, two releases deployed into the void.
|
||||
- The whole integration: tar c custom_components/houseplan (--exclude __pycache__) → tar x on the server.
|
||||
- **Verification is mandatory**: `md5sum` locally == on the server == `curl http://homeassistant:8123/houseplan_files/houseplan-card.js | md5sum`
|
||||
(inside the SSH add-on `localhost` is NOT HA, use the host `homeassistant`).
|
||||
- **Verification is mandatory, and it must go over HTTP** — comparing md5 against
|
||||
the file you just copied proves nothing about what the browser receives. The
|
||||
one check that counts:
|
||||
`curl -s https://ha.jbstudio.pro/houseplan_files/houseplan-card.js | grep -o '1\.[0-9]*\.[0-9]*' | sort -u`
|
||||
must print the version just built. (Inside the SSH add-on `localhost` is NOT
|
||||
HA — use the host `homeassistant`.)
|
||||
- Python changes require an HA restart (`ha core restart`, holds the connection until it finishes, HTTP
|
||||
comes back up in 1–3 min). JS changes — just a page refresh (the static path is served
|
||||
with no-cache).
|
||||
@@ -96,3 +106,22 @@ Tag `vX.Y.Z` + GitHub Release → the workflow `.github/workflows/release.yml` b
|
||||
- The houseplan integration: entry loaded, `.storage/houseplan.layout` — the layout (server-side).
|
||||
- The old prototype `/config/www/houseplan/` (iframe) is kept as a fallback, do not touch.
|
||||
- configuration.yaml backups: `.bak-avgtemp` (before the average-temperature sensor edit).
|
||||
|
||||
|
||||
## Smoke tests (since 2026-07-27)
|
||||
|
||||
Every `demo/smoke_*.mjs` ends with:
|
||||
|
||||
```js
|
||||
checkAll(out); // every key must be true...
|
||||
checkAll(out, { n: 4 }); // ...unless an expected value is given
|
||||
await finish(browser, out);
|
||||
```
|
||||
|
||||
`finish` prints the JSON dump (useful on failure), reports named mismatches and
|
||||
sets a non-zero exit code — including when the card threw during the run. The
|
||||
suite runs in CI (`smoke` job) against a freshly built bundle; never test the
|
||||
committed `demo/srv/assets/houseplan-card.js` snapshot.
|
||||
|
||||
When adding a checklist line marked `[auto: ...]` in docs/TESTING.md, add the
|
||||
failing check in the same commit — that is what the marker now promises.
|
||||
|
||||
+5
-5
@@ -74,12 +74,12 @@ Track progress in `custom_components/houseplan/quality_scale.yaml` (done/exempt
|
||||
- [ ] GitHub: issue templates, discussions on, CONTRIBUTING.md (build/test in 5 minutes).
|
||||
- [ ] Semantic versioning discipline; keep release notes user-facing (they show in HACS).
|
||||
|
||||
## Phase 11 — UX modes redesign (approved, see docs/UX-MODES.md)
|
||||
## Phase 11 — UX modes redesign ✅ DONE (v1.25.0–v1.28.0, see docs/UX-MODES.md)
|
||||
|
||||
Three-tab interaction model (View / Plan / Devices): view mode is display-only —
|
||||
no dragging or editing; all editing splits between a Plan tab and a Devices tab.
|
||||
Deprecates "drag anywhere" and view-mode opening drag. Follow-ups from issue #3:
|
||||
state-reflecting icons, value display, RGB light colors, alarm visuals, sub-area rooms.
|
||||
Shipped: the three-tab interaction model (View / Plan / Devices, v1.25.0),
|
||||
state-reflecting icons + value display (v1.26.0), RGB light colors + alarm pulse
|
||||
(v1.27.0), sub-area rooms (v1.28.0). Remaining backlog from issue #3: music
|
||||
notes for players, directional TV ripples (not planned).
|
||||
|
||||
## Explicit non-goals (for now)
|
||||
|
||||
|
||||
Executable
+123
@@ -0,0 +1,123 @@
|
||||
# Product scope — what House Plan is and is not
|
||||
|
||||
*Fixed with the owner on 2026-07-22. This document is a guard rail: features are
|
||||
built, improved and accepted **only** if they serve a job listed here. When a new
|
||||
idea appears, first find its row in this file; if there is none — it belongs to
|
||||
HA core, to another card, or nowhere. Companion documents: PRODUCT.md (market),
|
||||
ROADMAP.md (order of work), UX-MODES.md (interaction model).*
|
||||
|
||||
## Mission
|
||||
|
||||
House Plan is the **spatial "at a glance + quick act" layer** for a Home
|
||||
Assistant home. Upload or draw a floor plan, outline rooms bound to HA areas,
|
||||
and the home's devices appear on a live, tappable map: states, climate, alerts,
|
||||
guarded quick actions. Setup is GUI-only — no Inkscape, no YAML, no external
|
||||
editors. Everything that is not "look at the home spatially and act on the
|
||||
obvious" is somebody else's job.
|
||||
|
||||
## Target audience
|
||||
|
||||
| Persona | Role | Surface |
|
||||
|---|---|---|
|
||||
| **Home admin** (primary) | HA enthusiast, house/large flat, 20–200 devices, several floors; sets up and maintains the plan | Desktop browser (both editors live here) |
|
||||
| **Household members** | Non-technical; consume the plan daily, never edit | Wall tablet (kiosk), phone (companion app) |
|
||||
| **Guests / kiosk** | View-only glance at the home | Wall tablet |
|
||||
|
||||
Design consequence: **View mode is the product** for two of the three personas.
|
||||
Editors are admin-only tools and must never leak interactions into View
|
||||
(established by UX-MODES; lock guard, inert openings, no drag in View).
|
||||
|
||||
## Core user jobs — the component must close these
|
||||
|
||||
| # | Job (user's words) | Status |
|
||||
|---|---|---|
|
||||
| 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, 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) |
|
||||
|
||||
## Partially covered — improvement backlog stays inside these
|
||||
|
||||
- **Touch ergonomics of the editors**: corner handles and grid clicks are small on
|
||||
tablets; editors are desktop-first today. Improve, don't redesign.
|
||||
- **Value display**: single current value per device; units/precision follow HA
|
||||
formatting only.
|
||||
- **Accessibility**: `prefers-reduced-motion` only; no keyboard navigation in
|
||||
editors, no ARIA labelling of the plan.
|
||||
- **Docs/screenshots**: README predates the two-editor redesign.
|
||||
|
||||
## Known gaps that fit the mission (build only on owner's request)
|
||||
|
||||
- Person/presence shown in rooms (classic floorplan ask; pure J1).
|
||||
### The lock invariant, stated precisely (review CR-1)
|
||||
|
||||
No lock or alarm panel is ever actuated **by a tap on the plan**: icons, lock
|
||||
badges, `marker.controls[]` and the device card all refuse (`resolveTapAction`
|
||||
+ `TOGGLE_FORBIDDEN_DOMAINS`, `isControllable`, `_cardToggle`). There is exactly
|
||||
**one** sanctioned actuation surface: the labeled Unlock/Lock button inside an
|
||||
opened door card, which additionally confirms before unlocking. That is a
|
||||
product decision (2026-07-22), not an oversight — but it means the invariant is
|
||||
"never by accident", not "never at all". Any new actuation path must either
|
||||
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.
|
||||
- Device/entity administration (rename, reassign area, disable) → HA registry
|
||||
UIs. We *read* the registry, we never manage it.
|
||||
- History, graphs, statistics → recorder/history cards. We show now, not then.
|
||||
- Camera streams, media controls → their own cards; our more-info opens HA's.
|
||||
- Energy monitoring/analytics → HA Energy.
|
||||
- 3D / isometric / photorealistic rendering, furniture drawing → niche tools
|
||||
(easy-floorplan et al.); we are a schematic live map, not an interior editor.
|
||||
- A general dashboard framework (menus, popups, theming engine) → Bubble Card,
|
||||
Dwains and friends.
|
||||
|
||||
## Excess-functionality audit (2026-07-22)
|
||||
|
||||
- **Device metadata: links & PDF manuals** — not spatial, but tiny, server-side,
|
||||
and used in real installs. Verdict: **keep, frozen** (no growth).
|
||||
- **Virtual devices** — placeholders for not-yet-installed hardware; serves J6.
|
||||
Verdict: **keep, frozen**.
|
||||
- **LQI diagnostics** — promoted to J7 (differentiator), not excess.
|
||||
- Nothing found that warrants removal; the mode redesign already moved every
|
||||
interaction to where it belongs.
|
||||
|
||||
## The component in one breath (README/HACS copy)
|
||||
|
||||
> **House Plan** turns your Home Assistant into a live map of your home. Upload
|
||||
> a plan image (or draw one), outline rooms and bind them to HA areas — your
|
||||
> devices appear in place, automatically, with live states. Glance at the wall
|
||||
> tablet: what's on, what's open, what's too cold, what's leaking, what's new.
|
||||
> Tap to act — safely: locks never toggle by accident. Two built-in editors
|
||||
> (plan and devices) mean no Inkscape, no YAML, no external tools — ever.
|
||||
|
||||
**Tasks it closes:** whole-home live overview · spatial alerts (leak/smoke/open/
|
||||
unlocked/new device) · safe quick actions · per-room climate · Zigbee mesh
|
||||
health · zero-to-plan GUI onboarding · keeping the plan true over years.
|
||||
|
||||
**Where users are:** Telegram chat https://t.me/ha_houseplan (support, feature
|
||||
signals, screenshots) — treat it as the primary source of field feedback.
|
||||
|
||||
**Pains it removes:** hand-crafted SVG + YAML floorplans · entity-list
|
||||
dashboards that hide *where* things happen · silent device sprawl · accidental
|
||||
toggles of security devices · per-device dashboards that non-technical family
|
||||
members can't read.
|
||||
+43
-10
@@ -5,22 +5,49 @@
|
||||
> state, where everything lives, and how to continue safely.
|
||||
>
|
||||
> **Documentation policy (mandatory):** every change is documented *in the same
|
||||
> commit* — CHANGELOG entry for anything user-visible, STATUS.md for state changes
|
||||
> commit* — a CHANGELOG entry for anything user-visible **in BOTH
|
||||
> `docs/CHANGELOG.md` (English) and `docs/CHANGELOG.ru.md` (Russian, since
|
||||
> v1.42.0 — the user base is largely Russian-speaking, see the Telegram chat)**, STATUS.md for state changes
|
||||
> (versions, publication, infrastructure), DEVELOPMENT.md for new gotchas,
|
||||
> ARCHITECTURE.md for design changes, ROADMAP.md when plans move.
|
||||
|
||||
## Snapshot (2026-07-17)
|
||||
## Snapshot (2026-07-28)
|
||||
|
||||
| Item | State |
|
||||
|---|---|
|
||||
| Version | **v1.26.0** everywhere (manifest, const.py, package.json, CARD_VERSION) |
|
||||
| GitHub | https://github.com/Matysh/houseplan-card — branch `main`, releases v1.9.3…**v1.23.1** (latest published 2026-07-17, bundle auto-attached by release.yml) |
|
||||
| CI | `.github/workflows/validate.yml` (hacs + hassfest + frontend + backend) — **fully green** since v1.11.1; `release.yml` auto-attaches the card bundle (needs `permissions: contents: write`, fixed) |
|
||||
| HACS | Works as custom repository (id 1290210112 on the home instance). **Inclusion PR: https://github.com/hacs/default/pull/9004** (queue ≈2 months as of 2026-07). Lesson: #8995 was auto-closed by hacs-bot — the PR body MUST be their exact template with every checkbox ticked and all 3 links (release, HACS action run, hassfest run); a custom body gets closed without discussion |
|
||||
| Brands | Ships **inside the integration**: `custom_components/houseplan/brand/{icon,icon@2x,logo,logo@2x}.png` (HA ≥2026.3 local-brands mechanism). home-assistant/brands PR #10700 was auto-closed — that repo no longer accepts custom integrations |
|
||||
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.23.1**, installed *via HACS* (custom repo). The user has real rooms, 12 openings (doors/windows with TTLock locks and contact sensors) and live fills configured |
|
||||
| Localization | UI en/ru (`src/i18n.ts`), auto by `hass.locale` + `language` card option; codebase and docs are English-first (`README.ru.md` is the Russian copy) |
|
||||
| Tests | 77 frontend (node:test, incl. a 12-test buildDevices suite on a fake hass) + 10 pure backend (anywhere) + 12 HA-harness backend (CI only, py3.13; skipped locally — sandbox has py3.10) |
|
||||
| Version | **v1.49.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.49.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 |
|
||||
| Product scope | docs/SCOPE.md (2026-07-22) is the feature guard rail — check before accepting any feature |
|
||||
|
||||
## Current feature surface (since the 2026-07-17 snapshot)
|
||||
|
||||
- **Three editors + View**: Plan / Devices / Background (decor layer v1.33) as
|
||||
tabs with an X to close; View is the default; nav (space+mode) persists
|
||||
across reloads (v1.38.2). **Kiosk mode** (v1.41.0): `kiosk: true` — no
|
||||
header/editors, swipe between spaces, double-tap zoom reset, `cycle: N`
|
||||
carousel, per-screen size multipliers in localStorage.
|
||||
- **Glow fill** (v1.35–v1.37): dark house + per-source light pools (rgb/color
|
||||
temp/default; per-source radius), door sectors, open boundaries
|
||||
(`room.open_to`, virtual walls, dashed, transitive light zones).
|
||||
- **Real switches** (v1.36): `marker.controls[]` group-toggle with HA-group
|
||||
semantics; icon mirrors targets; hidden grouped lamps fixed (tiered
|
||||
primaryEntity). **Lights toggle by default** (v1.39): primary domain light
|
||||
→ tap toggles, kettle-style devices keep the card default.
|
||||
- **Plan geometry**: polyline split (v1.32), island rooms w/ evenodd holes
|
||||
(v1.34), smart guides + 45° angle badge (v1.40), opening hover preview.
|
||||
- **Rooms**: room cards with metrics (temp/hum/lqi/light "1 of 3") and
|
||||
proportional resize (v1.31); link icon to the HA area (v1.40.1, room taps
|
||||
removed). **New-device red dot** (v1.29), lock action button (v1.30).
|
||||
- **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).
|
||||
|
||||
## Recent milestones (details in CHANGELOG.md)
|
||||
|
||||
@@ -129,3 +156,9 @@
|
||||
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).
|
||||
|
||||
## Product scope
|
||||
|
||||
docs/SCOPE.md (fixed 2026-07-22) is the guard rail for all feature work: mission,
|
||||
personas, jobs J1–J7, partial/out-of-scope lists, excess audit. Check it before
|
||||
accepting or proposing any feature.
|
||||
|
||||
+494
-59
@@ -3,9 +3,76 @@
|
||||
> **Policy:** this checklist is updated **in the same commit** as any functional
|
||||
> change (like CHANGELOG.md). Every release: run at least the smoke column on the
|
||||
> synthetic demo (`demo/`), and the full list before major releases. Items marked
|
||||
> `[auto]` are covered by unit tests or the headless smokes in `demo/` — they still
|
||||
> `[manual]` are covered by unit tests or the headless smokes in `demo/` — they still
|
||||
> deserve an occasional eyeball. File every failure as a GitHub issue before fixing.
|
||||
|
||||
> **What `[manual]` means (since 2026-07-27).** A named check exists that FAILS
|
||||
> when the behaviour breaks — in `npm test` or in the smoke suite, both of which
|
||||
> run in CI on every push. Where the check lives is written next to the marker
|
||||
> (`[auto: smoke_modes]`). Before this date the marker described an intention:
|
||||
> the smoke suite printed values and always exited 0, so 96 markers guarded
|
||||
> nothing (external audit T1/T3). If you add a checklist line marked `[manual]`,
|
||||
> add the failing check in the same commit.
|
||||
|
||||
- [ ] Smoke harness itself (v1.43.2, audit T1/T2): every smoke asserts named
|
||||
facts via `check`/`checkAll` and exits non-zero on any mismatch or
|
||||
uncaught in-card exception; the suite runs in CI against a FRESHLY built
|
||||
bundle. Sanity ritual: break one invariant on purpose (e.g. remove the
|
||||
kiosk editor guard) and confirm the matching smoke goes red [auto: CI job "smoke"]
|
||||
|
||||
- [ ] Room gear discoverability (v1.43.3, user feedback): in the Plan editor
|
||||
every room card carries a pill button "⚙ Room" of a FIXED readable size
|
||||
(independent of the card font) — including rooms without a name; it opens
|
||||
Room settings [auto: smoke_feedback_v2]
|
||||
- [ ] Metrics readability (v1.43.3): the metrics line is 0.75 of the room name
|
||||
(was 0.62 — unreadable on tablets); per-room sliders still apply on top [auto: smoke_feedback_v2]
|
||||
- [ ] Touch tooltips, take two (v1.43.3): a hover tooltip never appears after
|
||||
ANY touch/pen pointer event, even if the browser claims `hover: hover`
|
||||
(stylus, paired mouse, vendor skins) [auto: smoke_feedback_v2]
|
||||
|
||||
- [ ] Light-source flag (v1.44.0, user feedback): a smart SWITCH driving dumb
|
||||
fixtures glows in the "Light sources" fill once "This device is a light
|
||||
source" is ticked (its own entity or the lights bound under "Controls");
|
||||
unticked devices without a light entity never glow [auto: smoke_glow]
|
||||
- [ ] Device card controls (v1.44.0): the device card opens with its
|
||||
controllable entities FIRST — toggles right there (≥30 px tap targets),
|
||||
cover/lock/climate open HA more-info; model, links and manuals moved
|
||||
below; config/diagnostic entities are not listed; locks never toggle from
|
||||
the card [auto: smoke_card_controls]
|
||||
|
||||
- [ ] Lock invariant, all paths (v1.44.2, review CR-1): icon tap, controls[],
|
||||
device card and _cardToggle refuse locks/alarm panels entirely; the door
|
||||
card's Unlock asks for confirmation, Lock does not [auto: smoke_lock_invariant]
|
||||
- [ ] Attachment migration is transactional (v1.44.2, review CR-2/CR-3):
|
||||
rebinding COPIES files, saves the config, and only then deletes the old
|
||||
folder; a rejected save leaves the old files and urls intact; a name
|
||||
collision in the destination gets a unique name (the pre-existing file is
|
||||
never silently linked); urls are rewritten only for confirmed copies
|
||||
[auto: unit logic.test + tests_backend]
|
||||
|
||||
- [ ] Plans and PDFs load in a real browser (v1.44.3, B1 regression): open a
|
||||
dashboard with an uploaded plan — the background renders and a manual link
|
||||
opens; DevTools shows /api/houseplan/content/... returning 200 via a
|
||||
signed url, while the same url without authSig returns 401
|
||||
[auto: tests_backend + manual]
|
||||
- [ ] Auth policy is single-sourced (v1.44.4, B2): the HTTP upload and every WS
|
||||
write use the same `may_write`, which denies non-admins when the config
|
||||
entry is unavailable [auto: tests_backend]
|
||||
- [ ] Coordinates and caps (v1.44.4, B5): NaN/Infinity are refused on room
|
||||
rects, polygon vertices, view_box and openings — not only in layout; the
|
||||
openings list honours MAX_OPENINGS [auto: tests_backend]
|
||||
- [ ] Drag hardening (v1.44.4, L4 sub-item): every drag pipeline captures the
|
||||
pointer through the tolerant helper; decor shapes cannot be dragged more
|
||||
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 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
|
||||
temperature/signal when available) and no longer claims "open the area" —
|
||||
room clicks were removed in v1.40.1 [manual]
|
||||
|
||||
## Environments matrix
|
||||
|
||||
Run the *core flows* (marked ★ below) in each environment at least once per minor release:
|
||||
@@ -23,31 +90,42 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
||||
## Installation / upgrade / removal
|
||||
|
||||
- [ ] Fresh install via HACS custom repository → integration appears, card auto-registers as a Lovelace resource (`?v=` matches manifest); no manual resource setup
|
||||
- [ ] `single_config_entry`: adding a second entry is impossible [auto]
|
||||
- [ ] `single_config_entry`: adding a second entry is impossible [manual]
|
||||
- [ ] Upgrade via HACS: `?v=` bumps after HA restart, browser picks the new bundle without cache clearing
|
||||
- [ ] YAML-mode Lovelace: falls back to `extra_module_url` (card loads)
|
||||
- [ ] Removal: delete entry → Lovelace resource entry disappears; `.storage/houseplan.*` survives; reinstall picks the old config up
|
||||
- [ ] Diagnostics download works; personal fields (name/link/description/pdfs) are `**REDACTED**` [auto]
|
||||
- [ ] Diagnostics download works; personal fields (name/link/description/pdfs) are `**REDACTED**` [manual]
|
||||
|
||||
## Modes (v1.25.0) ★
|
||||
|
||||
- [ ] The card always loads in **View**; edit modes are never restored [auto]
|
||||
- [ ] The card always loads in **View**; edit modes are never restored [manual]
|
||||
- [ ] View: pan/zoom/space-switch/tap/long-press/tooltips only — dragging an icon,
|
||||
label or opening does nothing; panning may start on top of an icon [auto]
|
||||
- [ ] View header: space tabs + count + zoom + mode tabs, ZERO edit buttons [auto]
|
||||
label or opening does nothing; panning may start on top of an icon [manual]
|
||||
- [ ] View header: space tabs + count + zoom + editor tabs, the general-settings
|
||||
cog and the per-space gears (visible in EVERY mode since v1.30.1/v1.30.3
|
||||
for users who may edit); no editor toolbars [auto: smoke_modes]
|
||||
- [ ] Plan: markup toolbar, space gears, +space, ⚙ palette; device icons hidden,
|
||||
labels/openings draggable; orange stage frame [auto]
|
||||
labels/openings draggable; orange stage frame [manual]
|
||||
- [ ] Devices: icon drag works, click opens the marker editor directly; +/👁/↺/⬡
|
||||
buttons; accent stage frame [auto]
|
||||
buttons; accent stage frame [manual]
|
||||
- [ ] Mode tabs hidden for non-admin users; segmented control highlights the active mode
|
||||
- [ ] Opening: single click in View shows the info card; in Plan a click edits it;
|
||||
double-click no longer does anything special [auto]
|
||||
- [ ] Openings in View (v1.28.1): the door/window itself is a pure drawing — no
|
||||
cursor change, no hover outline, no hit target, no click, regardless of
|
||||
bindings [manual]
|
||||
- [ ] The LOCK BADGE is the one exception: when a lock is bound it is shown and
|
||||
clickable in View (pointer cursor, click → door/lock info card); inert in
|
||||
Plan so it does not fight editing [manual]
|
||||
- [ ] Device icons in View show a pointer cursor (no grab); grab only in the
|
||||
Devices mode [manual]
|
||||
- [ ] In Plan an opening is interactive: grab cursor, hover outline, drag along
|
||||
walls, click (any tool) opens its properties — with a 3 px drag threshold
|
||||
since v1.43.1, so a tap is never swallowed [auto: smoke_inert_openings]
|
||||
|
||||
## Onboarding ★
|
||||
|
||||
- [ ] Empty config, HA has floors → floors-import wizard offers them sorted by level [auto]
|
||||
- [ ] Empty config, HA has floors → floors-import wizard offers them sorted by level [manual]
|
||||
- [ ] Wizard: uncheck all → "Create" disabled; "Start from scratch" → classic dialog
|
||||
- [ ] Wizard: N floors → space dialog per floor with prefilled name and progress "i of N"; Skip skips one; Cancel aborts the whole queue [auto]
|
||||
- [ ] Wizard: N floors → space dialog per floor with prefilled name and progress "i of N"; Skip skips one; Cancel aborts the whole queue [manual]
|
||||
- [ ] After the last wizard space (or first manual space) → markup mode auto-opens with a toast
|
||||
- [ ] Empty config, no floors → classic "New space" dialog auto-opens once per session
|
||||
- [ ] All floors skipped, nothing created → empty state with "Add space" button remains usable
|
||||
@@ -56,30 +134,30 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
||||
|
||||
- [ ] Create with an image (SVG, PNG, JPG, WebP) → correct aspect, crisp at zoom (SVG)
|
||||
- [ ] Oversized plan (>8 MB) → readable error toast, dialog stays open
|
||||
- [ ] Create with "No image — I'll outline rooms by hand": orientation landscape/portrait/square respected [auto]; borders+names default ON [auto]
|
||||
- [ ] Draw-space (no background) renders a WHITE canvas (paper-like), markup works on it; room borders/names stay legible on white [auto]
|
||||
- [ ] Edit: rename; replace image; **switch image→draw detaches the plan** [auto]
|
||||
- [ ] Create with "No image — I'll outline rooms by hand": orientation landscape/portrait/square respected [manual]; borders+names default ON [manual]
|
||||
- [ ] Draw-space (no background) renders a WHITE canvas (paper-like), markup works on it; room borders/names stay legible on white [manual]
|
||||
- [ ] Edit: rename; replace image; **switch image→draw detaches the plan** [manual]
|
||||
- [ ] Delete space with rooms/devices → tab disappears, layout of other spaces untouched
|
||||
- [ ] Display settings: borders toggle, names toggle, color picker + opacity slider live-preview after save, fill selector [auto]
|
||||
- [ ] Fill "zigbee": rooms tint red→green by average LQI; rooms without zigbee stay unfilled [auto]
|
||||
- [ ] Fill "lights": yellow when any light on, grey when all off, unfilled when the room has no lights [auto]; toggling a light from the plan recolors the room
|
||||
- [ ] Fill "temperature": blue below the comfort range, green inside, yellow above [auto]; comfort bounds editable inline on the radio row (swapped bounds tolerated [auto], clearing a field cannot zero a bound [auto]); rooms without a temperature reading stay unfilled [auto]
|
||||
- [ ] Display settings: borders toggle, names toggle, color picker + opacity slider live-preview after save, fill selector [manual]
|
||||
- [ ] Fill "zigbee": rooms tint red→green by average LQI; rooms without zigbee stay unfilled [manual]
|
||||
- [ ] Fill "lights": yellow when any light on, grey when all off, unfilled when the room has no lights [manual]; toggling a light from the plan recolors the room
|
||||
- [ ] Fill "temperature": blue below the comfort range, green inside, yellow above [manual]; comfort bounds editable inline on the radio row (swapped bounds tolerated [manual], clearing a field cannot zero a bound [manual]); rooms without a temperature reading stay unfilled [manual]
|
||||
- [ ] Fill mode is a radio group (no dropdown); labels carry no color legend
|
||||
- [ ] Room hover darkens the current fill (no recolor to blue); unfilled rooms hover light grey
|
||||
- [ ] Room tooltip shows the average room temperature when any thermometer reports [auto]
|
||||
- [ ] Room tooltip shows the average room temperature when any thermometer reports [manual]
|
||||
- [ ] Average room temperature counts ONLY thermometer/air-monitor devices — fridges, TRV heads,
|
||||
smart-plug chip temperatures (`*_device_temperature`) and diagnostic-category temps are excluded [auto]
|
||||
smart-plug chip temperatures (`*_device_temperature`) and diagnostic-category temps are excluded [manual]
|
||||
- [ ] Space dialog is 500 px wide; the comfort-bounds inputs are compact (56 px)
|
||||
- [ ] The scale (cm per cell) input is compact (72 px), not full-width [auto]
|
||||
- [ ] The scale (cm per cell) input is compact (72 px), not full-width [manual]
|
||||
- [ ] General settings (⚙ in the header): fill colors grouped by mode (lights on/off/none,
|
||||
temp cold/comfy/hot, LQI weak/strong), each with its own opacity slider [auto];
|
||||
Reset restores defaults; saving defaults stores nothing [auto]
|
||||
temp cold/comfy/hot, LQI weak/strong), each with its own opacity slider [manual];
|
||||
Reset restores defaults; saving defaults stores nothing [manual]
|
||||
- [ ] Custom fill colors apply to the full card AND the static space-card
|
||||
- [ ] LQI gradient interpolates between the configured weak/strong colors [auto]
|
||||
- [ ] LQI gradient interpolates between the configured weak/strong colors [manual]
|
||||
- [ ] Per-space "Show zigbee signal (LQI)" toggle hides/shows the badges next to
|
||||
devices and the signal line in room tooltips for that space only [auto]
|
||||
- [ ] Device icon badge is centred exactly on its point (no 1 px down-right drift) [auto]
|
||||
- [ ] Device glyph is centred within its badge (no vertical drift — real ha-icon is block+line-height) [auto]
|
||||
devices and the signal line in room tooltips for that space only [manual]
|
||||
- [ ] Device icon badge is centred exactly on its point (no 1 px down-right drift) [manual]
|
||||
- [ ] Device glyph is centred within its badge (no vertical drift — real ha-icon is block+line-height) [manual]
|
||||
- [ ] Room hover highlight still works when custom borders/fills are on
|
||||
- [ ] Settings persist across reload and other browsers (server-side)
|
||||
|
||||
@@ -102,61 +180,418 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
||||
name/area/devices, the smaller opens the new-room dialog; Cancel leaves the room whole
|
||||
- [ ] Split: a cut with an end off the wall, or along a wall, is refused with a toast
|
||||
- [ ] Split: the click snaps to the nearest wall, so it works on non-grid-aligned rooms
|
||||
(imported/legacy polygons), not only on rooms drawn on the current grid [auto]
|
||||
(imported/legacy polygons), not only on rooms drawn on the current grid [manual]
|
||||
- [ ] Split: a click far from any wall (middle of the room) is a miss with a toast —
|
||||
the wall-snap pull is capped, accidental clicks do not pick a wall [auto]
|
||||
the wall-snap pull is capped, accidental clicks do not pick a wall [manual]
|
||||
- [ ] Esc / Ctrl+Z removes the last dot (and its line); Reset clears the path
|
||||
- [ ] Closing the contour (click the first dot, ≥4 points) opens the room dialog
|
||||
- [ ] Room dialog: area list shows only unassigned areas; picking an area prefills the name
|
||||
- [ ] "No area" room (decorative) requires a name; saves with `area: null`
|
||||
- [ ] Cancel in the dialog reopens the contour (last point undone)
|
||||
- [ ] Saving a room with an area: area devices appear with icons; positions are fixed into the layout [auto]
|
||||
- [ ] Saving a room with an area: area devices appear with icons; positions are fixed into the layout [manual]
|
||||
- [ ] Erase tool removes exactly the clicked line; Delete-room removes the polygon after confirm
|
||||
- [ ] Device icons hidden during markup; visible again on exit
|
||||
|
||||
## Devices on the plan ★
|
||||
|
||||
- [ ] Auto devices appear only in rooms bound to their area [auto]
|
||||
- [ ] Curation hides bridges/groups/scenes/excluded integrations; 👁 "show all" reveals [auto]
|
||||
- [ ] Duplicate "name|area" numbered ("Lamp", "Lamp 2") [auto]
|
||||
- [ ] Light groups fold their single lamps; `group_lights=false` unfolds [auto]
|
||||
- [ ] Auto devices appear only in rooms bound to their area [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
|
||||
- [ ] ↺ reset restores auto layout after confirm
|
||||
- [ ] Temperature badge on thermometers; LQI value under zigbee icons with red→green color
|
||||
- [ ] Live states: light on = yellow, open cover/lock/door = orange, unavailable = faded
|
||||
- [ ] State icons (v1.26.0): auto icons morph with state — door/window/garage open↔closed,
|
||||
lock locked↔unlocked, bulb on; custom icons and unavailable states never morph [auto]
|
||||
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 [auto]
|
||||
- [ ] No devices at all in HA (fresh instance) → plan renders, "0 dev.", no console errors [auto]
|
||||
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]
|
||||
- [ ] 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
|
||||
computed once per config change, not per HA state push — smoke asserts
|
||||
zero recomputations across 10 state pushes and recomputation after an
|
||||
edit; the plan still renders dashes/islands correctly [auto: smoke_render_perf]
|
||||
- [ ] Opening tap vs drag (v1.43.1, audit L4): a tap on a door in the Plan
|
||||
editor opens its properties (3 px threshold like the other pipelines) and
|
||||
writes nothing; a real drag that ends where it started also writes nothing [auto: smoke_render_perf]
|
||||
- [ ] Concave containment (v1.43.1, audit G2): an island room inside a U- or
|
||||
L-shaped parent is accepted and punches the evenodd hole; a traced
|
||||
duplicate outline is still NOT containment [auto: smoke_inert_openings]
|
||||
- [ ] Backend hardening (v1.43.1, audit B2-B5): the admin check fails closed
|
||||
when the entry is unavailable; layout/set honours expected_rev; a
|
||||
config/set without expected_rev over a non-empty store logs a warning;
|
||||
NaN/Infinity coordinates and oversized collections are rejected [auto: unit: logic.test]
|
||||
- [ ] Save race (v1.43.0, audit L2): make a markup edit, then press Save in any
|
||||
dialog within 500 ms (or let another client save) — the markup edit must
|
||||
survive and reach the server; a failed reload now shows a toast [auto: unit: tests_backend]
|
||||
- [ ] Niche split (v1.43.0, audit G1): a cut that starts AND ends on the same
|
||||
wall carves a niche; the two parts' areas must sum to the original (the
|
||||
invariant is enforced in code and asserted for every split test) [auto: smoke_save_race]
|
||||
- [ ] Authenticated content (v1.43.0, audit B1): plan images and marker files
|
||||
are only reachable through /api/houseplan/content/… with a session; the
|
||||
old /houseplan_files/plans|files paths return 404 after a restart; old
|
||||
stored URLs keep working (rewritten on read) [auto+manual]
|
||||
- [ ] Every editor option is storable (v1.45.3, issue #3): set a sensor to
|
||||
"value instead of an icon" and save — no validation error, the value shows
|
||||
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]
|
||||
- [ ] 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
|
||||
updates [auto: smoke_layout_sync]
|
||||
- [ ] Concurrent uploads of one name (v1.46.1, HP-1460-01): attach the same
|
||||
file from two browser tabs at once — two attachments, two sets of bytes,
|
||||
neither lost. A file whose name is at the length limit still downloads
|
||||
[auto: unit: test_reserve_filename_is_safe_under_concurrency and neighbours]
|
||||
- [ ] No temporary files survive (v1.46.1, HP-1460-02): abort a large upload
|
||||
mid-transfer, send two files in one request, make promotion fail — in each
|
||||
case the files folder holds no `.upload-*`. An old one is swept at startup
|
||||
[auto: backend test_upload_leaves_no_temporary_behind + unit: sweep_upload_temps]
|
||||
- [ ] Two full cards agree on positions (v1.46.1, HP-1460-03): open the plan in
|
||||
two windows, drag an icon in one — it moves in the other without a reload;
|
||||
a drag in progress in the second window is not thrown away
|
||||
[auto: smoke_layout_sync]
|
||||
- [ ] Uploaded SVG is inert as a document (v1.46.0, HP-1454-01): open a plan's
|
||||
signed url directly in a tab — a `<script>` inside it must not run and must
|
||||
not reach the HA session's localStorage; the same plan still renders in the
|
||||
card. PDFs still open in the browser viewer
|
||||
[auto: smoke_svg_sandbox + backend test_uploaded_svg_is_sandboxed_and_a_pdf_is_not]
|
||||
- [ ] An attachment never overwrites another (v1.46.0, HP-1454-02): attach a file,
|
||||
cancel the dialog — the previously stored file is byte-identical. Attach
|
||||
`manual.pdf` to two NEW icons — two independent files. A cancelled upload is
|
||||
gone an hour later
|
||||
[auto: backend test_upload_never_overwrites_an_existing_attachment + unit: collect_attachments]
|
||||
- [ ] Two quick edits both survive (v1.46.0, HP-1454-03): with a slow connection,
|
||||
make an edit and another one before the first save answers — both are in the
|
||||
stored config, only one write is ever in flight, and no conflict toast fires
|
||||
[auto: smoke_config_writer]
|
||||
- [ ] Open boundaries follow geometry (v1.46.0, HP-1454-04): change a space's
|
||||
aspect or drag a room vertex — the open boundary and the light through it
|
||||
move with the walls, without a reload [auto: smoke via model-identity key]
|
||||
- [ ] Inner limits (v1.46.0, HP-1454-05): max and max+1 for polygon points,
|
||||
open_to, controls, pdfs, text and url lengths; an oversized config as a
|
||||
whole is refused with `too_large`
|
||||
[auto: unit: test_inner_collection_limits + backend test_config_write_is_capped_by_total_size]
|
||||
- [ ] Big files stream (v1.46.0, HP-1454-06): upload a ~50 MB manual and download
|
||||
it twice in parallel — HA's memory does not grow by a file per transfer
|
||||
[manual]
|
||||
- [ ] Static card parity (v1.46.0, HP-1454-07): a room whose fill is set to "none"
|
||||
under a space filled by light is transparent on BOTH cards
|
||||
[auto: smoke_render_parity]
|
||||
- [ ] Layout reaches the static card (v1.46.0, HP-1454-08): drag an icon on the
|
||||
full card — a static card on the same dashboard moves it too, with no
|
||||
config write and no reload
|
||||
[auto: backend test_layout_keeps_its_revision_and_announces_changes + manual]
|
||||
- [ ] Repair issues are not immortal (v1.46.0, HP-1454-09): create a missing-plan
|
||||
warning, then delete the space — the warning disappears [manual]
|
||||
- [ ] A path the backend cannot sign does not become a request loop (v1.45.4,
|
||||
review R5-1): when `content/sign` answers successfully but omits a path,
|
||||
the card backs that path off individually and keeps the urls it did get;
|
||||
a re-render asks only for what is still missing, and only after the wait
|
||||
[auto: unit: signing.test + backend test_signing_one_path_may_fail_without_failing_the_request]
|
||||
- [ ] Signing does not amplify on a bad connection (v1.45.2, review R4-2): with
|
||||
the WebSocket slow or refusing, the card issues ONE sign request per url
|
||||
and backs off after a failure instead of asking again on every render; a
|
||||
request that never answers stops blocking retries after 15 s
|
||||
[auto: unit: signing.test + smoke_space_card_bg]
|
||||
- [ ] A broken plans directory does not fail a save (v1.45.2, review R4-1): make
|
||||
the plans folder unreadable and save the configuration — the save
|
||||
succeeds, the revision is usable, and the next save does not conflict
|
||||
[auto: backend test_a_failing_collector_does_not_undo_an_accepted_save]
|
||||
- [ ] Two editors, one plan (v1.45.1, review R3-1): with the same space open in
|
||||
two tabs, attach a background in each in turn — the plan last saved is the
|
||||
one served, and neither commit deletes the other's file. A rejected upload
|
||||
disappears on a later save, not immediately
|
||||
[auto: backend test_late_commit_of_one_client_never_deletes_another_client_s_plan,
|
||||
test_commit_does_not_collect_another_client_s_uncommitted_upload,
|
||||
test_abandoned_uploads_are_collected_once_old]
|
||||
- [ ] Static card background (v1.45.1, review R3-2): a houseplan-space-card on a
|
||||
dashboard shows the plan image, not an empty stage; the browser never
|
||||
requests the unsigned path and Home Assistant logs no failed login. A
|
||||
failed signing request is retried on the next render
|
||||
[auto: smoke_space_card_bg]
|
||||
- [ ] Rejected save leaves the plan intact (v1.45.0, review R2-1): attach a new
|
||||
background, make the config write fail (a second tab saving first is
|
||||
enough) — the previously stored plan is still served, with the same or a
|
||||
different extension; after a successful save the old files are gone
|
||||
[auto: smoke_plan_upload_reject + backend test_plan_upload_does_not_touch_the_previous_file]
|
||||
- [ ] Signature cache on a wall tablet (v1.45.0, review R2-2): with more than
|
||||
200 signed urls every one of them is refreshed (batched), entries for
|
||||
files no longer in the config are dropped, an expired signature is never
|
||||
served and an aging one keeps working while its replacement arrives
|
||||
[auto: smoke_sign_cap]
|
||||
- [ ] Climate cost does not grow with rooms (v1.45.0, review R2-3): on a plan
|
||||
with dozens of rooms an unrelated HA state update triggers ONE registry
|
||||
pass, repeated renders on the same snapshot trigger none, and a changed
|
||||
sensor value is still visible immediately [auto: smoke_climate_once]
|
||||
- [ ] Plan upload survives a concurrent config revision (v1.44.8): with a second
|
||||
tab open on the same plan, attach a background image in space settings —
|
||||
the plan shows immediately, `plan_url` is in `.storage/houseplan.config`,
|
||||
and the same holds when the space is being CREATED, not edited
|
||||
[auto: smoke_plan_upload_race]
|
||||
- [ ] Signed plan background (v1.44.7): a space whose plan lives on the content
|
||||
endpoint renders its background image with an `authSig` query — the plan is
|
||||
visible after a plain page load, and Home Assistant logs NO failed-login
|
||||
attempt from the viewer's own IP. Nothing is requested before the signature
|
||||
arrives; a 12 h re-sign keeps the previous url until the new one lands
|
||||
[auto: smoke_plan_signed]
|
||||
- [ ] Dialog zombies (v1.43.0, audit L3): close a dialog (Esc) while its save is
|
||||
in flight and let the save fail — the dialog stays closed, the card keeps
|
||||
rendering, the error toast still fires [auto: unit: logic.test + manual]
|
||||
- [ ] No hover tooltips on touch (v1.42.2): on hover-less devices (tablets,
|
||||
phones) taps never pop the room/device tooltip — the data lives in room
|
||||
cards and long-press; desktop hover tooltips unchanged [auto: smoke_dialog_zombie]
|
||||
- [ ] Card font scales (v1.42.1): three sliders — space-level base (space
|
||||
dialog) plus per-room name and metrics sizes (room settings), 50–300%,
|
||||
multiplied together and on top of resize-k and kiosk multipliers; the
|
||||
live sample card in both dialogs follows the sliders instantly; name and
|
||||
metrics scale independently [auto: smoke_font_scales]
|
||||
- [ ] Room settings, tier 3 (v1.42.0): a gear on every room card in the Plan
|
||||
editor opens Room settings (name, HA area incl. the current one, fill
|
||||
override, temp/hum source); the creation dialog has the same section;
|
||||
fill override repaints only that room (incl. opting OUT of the glow
|
||||
darkness); a temp/hum source (device or entity) feeds the room card,
|
||||
tooltip and temperature fill — works for rooms without an HA area;
|
||||
renaming/rebinding an existing room now possible [auto: smoke_room_settings]
|
||||
- [ ] PDF survival on rebinding (v1.41.2): rebind a marker with attached
|
||||
PDFs to another device — the server moves /files/<oldId>/ to the new id
|
||||
and the links keep opening; the old folder disappears (no orphans) [manual]
|
||||
- [ ] Kiosk mode (v1.41.0): kiosk: true hides the whole header, blocks every
|
||||
editor (admins incl.), full-height stage; swipe left/right switches
|
||||
spaces at 1:1 (dots indicator, wraps), never while zoomed; double tap
|
||||
resets zoom; long-press (3 s) on empty plan opens the per-screen size
|
||||
popover (icon ×0.5–3, room-card font; localStorage per device);
|
||||
cycle: N auto-advances spaces with a 60 s pause after any touch;
|
||||
manual: walk the real wall tablet [auto+manual]
|
||||
- [ ] Room link icon (v1.40.1): clicking empty room space in View does
|
||||
nothing (default cursor); an open-in-new icon after the room name (rooms
|
||||
with an HA area, View only) navigates to the area; no icon in editors or
|
||||
on area-less rooms [auto: smoke_room_link]
|
||||
- [ ] Smart guides (v1.40.0): while drawing (outline, cut, decor shapes) or
|
||||
dragging (icons, room cards, decor) dashed accent guides appear from the
|
||||
nearest object sharing the X and/or Y (max two, with a dot at the
|
||||
source); the cursor badge shows length · angle and turns green on 45°
|
||||
multiples; indication only — no magnetism; nothing in View mode [auto: smoke_align_guides]
|
||||
- [ ] Lights toggle by default (v1.39.0): a device whose PRIMARY entity is a
|
||||
light (bulbs, chandeliers, night lights, light groups) toggles on click
|
||||
out of the box — no per-device setting needed; the device dialog shows
|
||||
"Toggle" as its effective default; devices where light is a side
|
||||
function (kettle: primary = sensor) keep the Device-card default;
|
||||
explicit per-device "Device card" wins over the default [auto: smoke_light_default_tap]
|
||||
- [ ] Derived walls cut too (v1.38.4): in the Plan editor the derived wall
|
||||
segments (.seg) no longer run solid through an open stretch — only the
|
||||
dash remains there [auto: smoke_openwall]
|
||||
- [ ] Dashed boundaries in the Plan editor (v1.38.3): open stretches render as
|
||||
a true dash in markup too (blue trimmed outlines); merge/split-picked
|
||||
rooms keep their full amber highlight [auto: smoke_openwall]
|
||||
- [ ] Nav persistence (v1.38.2): closing/reopening the tab restores the last
|
||||
space AND editor mode (admins; localStorage); a #space= deep link beats
|
||||
the saved space; a stale cache without the saved space retries after the
|
||||
live config loads [manual]
|
||||
- [ ] Tap action cleanup + right click (v1.38.1): the per-device action list
|
||||
has three options (Device card / HA more-info / Toggle), no "card
|
||||
default" — the card editor's global tap option is gone and ignored;
|
||||
right click on an icon in VIEW opens HA more-info (native menu kept in
|
||||
editors; virtual w/o entity → device card) [auto: smoke_tap_ctx]
|
||||
- [ ] Binding section redesign (v1.38.0): two radios — Virtual / Pick from
|
||||
the HA list — with a "Show entities" checkbox (tooltip) next to the
|
||||
second; the dropdown (search inside) appears only in HA mode, opens
|
||||
itself when nothing is chosen, closes on pick; Save is blocked until a
|
||||
binding is chosen in HA mode; groups/helpers listed always, device
|
||||
entities only with the checkbox; editing pre-selects everything [auto: smoke_binding_ui]
|
||||
- [ ] True dashed boundary (v1.37.3): the open stretch is a REAL dash — the
|
||||
rooms' solid strokes are trimmed out beneath it (hover doesn't bring
|
||||
them back), walls elsewhere stay solid; the dashes render ABOVE the
|
||||
glow pools [auto: smoke_openwall]
|
||||
- [ ] Open-wall hover (v1.37.1): with the tool active the cursor is default;
|
||||
near a shared wall it turns pointer and the exact stretch that would
|
||||
open is previewed (amber dashed); an already-open boundary previews red
|
||||
solid (the click will close it); preview follows the cursor and clears
|
||||
on miss [auto: smoke_openwall]
|
||||
- [ ] Open boundaries (v1.37.0): the Plan editor's "Open boundary" tool
|
||||
toggles a virtual wall between two rooms by clicking their shared wall
|
||||
(pull like Split; miss → toast); open stretches render dashed (amber
|
||||
highlight while the tool is active); glow light floods the whole
|
||||
connected open zone transitively, door sectors work from the zone's
|
||||
outer walls; merge/split keep links by room id [auto: smoke_openwall]
|
||||
- [ ] Sector wedge fix (v1.36.3): door sectors never darken the light INSIDE
|
||||
the room — room outline and sectors are separate clipPath children
|
||||
(union), not subpaths of one nonzero path [auto: smoke_glow]
|
||||
- [ ] Per-source glow radius (v1.36.2): the device dialog has a "Glow radius"
|
||||
field (HA units; empty = general-settings default shown as placeholder);
|
||||
an override changes that source's pool and door sectors only [auto: smoke_glow]
|
||||
- [ ] Hidden-light primary (v1.36.1): a lamp whose light entity is HIDDEN in
|
||||
the registry (folded into a light group) still toggles/reflects the lamp,
|
||||
not its do-not-disturb switch or identify button; visible entities of the
|
||||
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
|
||||
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
|
||||
one uniform darkness color; lit lamps glow with a radial gradient
|
||||
(rgb_color → color temp → default color; brightness scales opacity),
|
||||
clipped by the source's room plus door sectors into NEIGHBOUR rooms
|
||||
(entrance doors leak nothing; windows don't spill); radius set in
|
||||
general settings in HA units (m/ft, stored in cm); no shadow casting —
|
||||
islands don't block light (documented limitation) [auto: smoke_glow]
|
||||
- [ ] Island rooms (v1.34.0): a contour drawn fully inside an existing room
|
||||
(or around one) saves as a nested room — column in a ring, inner room;
|
||||
the parent's fill renders with an evenodd hole so the ring paints
|
||||
correctly; the island stays clickable; partial overlaps and duplicate
|
||||
outlines are still rejected at closing [auto: smoke_island_rooms]
|
||||
- [ ] Icon stays on edit (v1.33.4): rebinding a device (HA device/entity) or
|
||||
changing its room within the same space never moves the icon — the saved
|
||||
or auto position migrates to the new marker id; only a brand-new icon or
|
||||
a move to another space centers it in the target room [auto: smoke_marker_stay]
|
||||
- [ ] Icon picker placeholder (v1.33.3): with no explicit icon the device
|
||||
dialog's icon picker shows the auto-derived icon as its placeholder, plus
|
||||
an "Auto: mdi:..." hint line with the icon preview; the hint disappears
|
||||
once an explicit icon is picked [auto: smoke_icon_placeholder]
|
||||
- [ ] No Reset button (v1.33.2): the Device editor toolbar has three tools —
|
||||
add, show all, icon rules; the layout-wiping Reset is gone [auto: smoke_editor_tabs]
|
||||
- [ ] Grid in all editors + decor fade (v1.33.1): the dot grid shows in the
|
||||
Device and Background editors too (instant "I'm editing" cue), not in
|
||||
View; in the Background editor rooms/devices/openings/labels fade to 35%
|
||||
while decor shapes stay fully opaque; no fade in the other editors [auto: smoke_decor / smoke_grid_fade]
|
||||
- [ ] Background editor (v1.33.0): third tab with its own toolbar (select /
|
||||
line / rect / oval / text / erase + color, width, fill, X); shapes drag-
|
||||
drawn with grid snap and live preview; degenerate shapes dropped; text
|
||||
via dialog (S/M/L, color; dblclick re-edits); Select moves (snap), Delete
|
||||
removes, Erase deletes on click; Esc: draft → selection → select tool →
|
||||
View; decor renders under rooms, visible everywhere, inert outside the
|
||||
editor; stored in space.decor (rev/lock, backend schema) [auto: smoke_decor / smoke_grid_fade]
|
||||
- [ ] Opening hover preview (v1.32.1): with the Opening tool, hovering near a
|
||||
wall shows a dashed 90 cm ghost snapped onto the wall (with a center
|
||||
dot); no ghost far from walls, over an existing opening (click = edit),
|
||||
or in other tools [manual]
|
||||
- [ ] Split polyline + cursors + Esc (v1.32.0): Merge shows a pointer cursor,
|
||||
Split shows pointer until a room is picked then crosshair; the cut can be
|
||||
a polyline — start on a wall, intermediate clicks inside the room, finish
|
||||
on a wall (path drawn live, walls/self-crossing rejected); Esc walks back:
|
||||
last cut point → room pick → back to the Draw tool (same for Merge:
|
||||
selection → tool) [auto: smoke_split_polyline]
|
||||
- [ ] Merge/split pick highlight (v1.31.2): the first room clicked with the
|
||||
Merge tool (and the split-selected room) gets an amber outline + fill;
|
||||
visible over the blue markup outlines [auto: smoke_merge_highlight]
|
||||
- [ ] Card vs tool conflict (v1.31.1): in the Plan editor, dragging/resizing or
|
||||
clicking a room card never feeds the active tool (no draw point, no
|
||||
delete-room confirm, no merge/split pick); clicks past the card work [auto: smoke_merge_highlight]
|
||||
- [ ] Room cards (v1.31.0): with metrics enabled in space settings (4
|
||||
checkboxes: temperature, humidity, avg Zigbee, lights) the room name gets
|
||||
a smaller metrics line under it; lights show On/Off or "1 of 3" when
|
||||
partially lit; rooms without an HA area show the name only; in the Plan
|
||||
editor cards show the name only, are draggable and resizable via corner
|
||||
handles on hover (uniform scale 0.5–3, stored in layout, survives drag);
|
||||
View mode has no handles/hover [auto: smoke_room_cards]
|
||||
- [ ] Esc closes dialogs (v1.30.4): Escape closes the topmost dialog (opening
|
||||
info, device info, icon rules, general settings, device editor, opening
|
||||
editor, space dialog incl. abandoning an import queue); stacked dialogs
|
||||
close one per press; Esc while drawing still undoes the last point [manual]
|
||||
- [ ] General settings gear (v1.30.3): the header cog is visible in every mode
|
||||
(admins), opens the palette dialog from View too [auto: smoke_gear_tabs / smoke_gs_always]
|
||||
- [ ] Editor tabs (v1.30.2): only two tabs — "Plan editor" / "Device editor"
|
||||
(no View button; View is the default state); clicking a tab opens its
|
||||
bottom toolbar (Devices got its own bar with add/show-all/reset/rules);
|
||||
the bar and the active tab both show an X that returns to View; re-click
|
||||
on the active tab does nothing; Plan↔Devices switches directly [auto: smoke_editor_tabs]
|
||||
- [ ] Space gear (v1.30.1): the cog next to the space name is visible in every
|
||||
mode (admins only), vertically centered with the tab text; clicking it
|
||||
opens space settings without switching the tab; "+" tab stays Plan-only [auto: smoke_gear_tabs / smoke_gs_always]
|
||||
- [ ] Lock action (v1.30.0): opening info card (View) shows Unlock (red) when
|
||||
locked / Lock when unlocked; button calls the lock service; disabled while
|
||||
locking/unlocking; hidden when unavailable; plan-icon tap still never
|
||||
toggles a lock [auto: smoke_gear_tabs / smoke_gs_always]
|
||||
- [ ] New-device flag (v1.29.0): a device added to HA after install gets a big red
|
||||
dot top-right of its icon (all clients); opening its editor clears it
|
||||
everywhere; upgrade/first-run seeds the baseline silently — no dot flood [auto: smoke_new_device]
|
||||
- [ ] No devices at all in HA (fresh instance) → plan renders, "0 dev.", no console errors [auto: smoke_new_device]
|
||||
|
||||
## Device dialog (markers) ★
|
||||
|
||||
- [ ] Open via info card → Edit; all fields persist (name, icon, model, link, description)
|
||||
- [ ] Rebind to another device/entity/helper: search filters; already-placed candidates excluded; old position cleaned up [auto backend]
|
||||
- [ ] Virtual device: requires name; room required; renders dashed
|
||||
- [ ] Sub-area rooms (v1.28.0): a room WITHOUT an HA area appears in the marker
|
||||
room list ("no area, manual"); a device placed there lands at its centre,
|
||||
the marker stores room_id, reopening the dialog restores the choice [manual]
|
||||
- [ ] Room override moves the icon to the room center
|
||||
- [ ] Tap-action override select (default/info/more-info/toggle) saves and applies
|
||||
- [ ] PDF/manual upload: ok path; >50 MB → readable error; .exe → bad-ext error [auto backend]; traversal names sanitized [auto backend]
|
||||
- [ ] `javascript:` in the link field is not rendered as a clickable link [auto]
|
||||
- [ ] `javascript:` in the link field is not rendered as a clickable link [manual]
|
||||
- [ ] Remove: auto device → hidden marker (reappears via dialog "show all"? no — stays hidden until re-added); virtual → gone incl. its layout entry [auto backend]
|
||||
|
||||
## Icon rules ★
|
||||
|
||||
- [ ] ⬡ opens the editor with current rules (defaults if none saved)
|
||||
- [ ] Test field resolves live; add/delete/reorder rows; first match wins [auto]
|
||||
- [ ] Invalid regex highlights red and is skipped at runtime (other rules still work) [auto]
|
||||
- [ ] Reset to defaults; saving defaults stores nothing (settings key removed) [auto]
|
||||
- [ ] Custom rules re-icon existing devices immediately; per-device icon override still wins; lock devices keep mdi:lock [auto]
|
||||
- [ ] Test field resolves live; add/delete/reorder rows; first match wins [manual]
|
||||
- [ ] Invalid regex highlights red and is skipped at runtime (other rules still work) [manual]
|
||||
- [ ] Reset to defaults; saving defaults stores nothing (settings key removed) [manual]
|
||||
- [ ] Custom rules re-icon existing devices immediately; per-device icon override still wins; lock devices keep mdi:lock [manual]
|
||||
- [ ] Rules survive reload; second browser sees them after live-sync
|
||||
|
||||
## Tap actions & gestures ★
|
||||
|
||||
- [ ] Default: tap → info card; card option `toggle`: tap toggles lights/switches/fans/humidifiers only [auto]
|
||||
- [ ] Locks/alarms never toggle, even with per-device override [auto]; covers/valves toggle only with explicit per-device override [auto]
|
||||
- [ ] Long-press (600 ms) always opens the info card, also when tap=toggle [auto]
|
||||
- [ ] Default: tap → info card; card option `toggle`: tap toggles lights/switches/fans/humidifiers only [manual]
|
||||
- [ ] Locks/alarms never toggle, even with per-device override [manual]; covers/valves toggle only with explicit per-device override [manual]
|
||||
- [ ] Long-press (600 ms) always opens the info card, also when tap=toggle [manual]
|
||||
- [ ] Drag > 3 px cancels both tap and long-press; pinch/pan never triggers taps
|
||||
- [ ] `pointercancel` (touch interrupted) does not leave a phantom info card [auto]
|
||||
- [ ] `pointercancel` (touch interrupted) does not leave a phantom info card [manual]
|
||||
|
||||
## Zoom / pan / labels
|
||||
|
||||
@@ -164,7 +599,7 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
||||
- [ ] Pinch zoom + two-finger pan on touch; one-finger pan when zoomed
|
||||
- [ ] Zoom level persists per space (localStorage), restored on reload
|
||||
- [ ] Window resize / sidebar collapse refits without distortion
|
||||
- [ ] Room name labels: default at room center; dragging moves and persists (server layout, `rl_*`) [auto]; hidden in markup mode
|
||||
- [ ] Room name labels: default at room center; dragging moves and persists (server layout, `rl_*`) [manual]; hidden in markup mode
|
||||
- [ ] Labels legible on light and dark plans (no text shadow) at min/max zoom
|
||||
|
||||
## Multi-client & concurrency ★
|
||||
@@ -176,13 +611,13 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
||||
|
||||
## Edge cases
|
||||
|
||||
- [ ] HA instance with zero devices/areas → onboarding works, rooms can be drawn, no crashes [auto]
|
||||
- [ ] HA instance with zero devices/areas → onboarding works, rooms can be drawn, no crashes [manual]
|
||||
- [ ] Space with zero rooms → renders; markup hint visible
|
||||
- [ ] Room without area + borders ON → drawn, click does nothing, no area tooltip signal
|
||||
- [ ] No zigbee devices anywhere → no LQI badges, lqi fill leaves all rooms unfilled [auto]
|
||||
- [ ] 100+ devices in one space → build under ~50 ms [auto], drag stays smooth
|
||||
- [ ] No zigbee devices anywhere → no LQI badges, lqi fill leaves all rooms unfilled [manual]
|
||||
- [ ] 100+ devices in one space → build under ~50 ms [manual], drag stays smooth
|
||||
- [ ] Very long device/room names → ellipsis/wrap, no layout explosion
|
||||
- [ ] HTML/emoji in names (`<b>xss</b>`, 🚿) → rendered as text, never as markup [auto]
|
||||
- [ ] HTML/emoji in names (`<b>xss</b>`, 🚿) → rendered as text, never as markup [manual]
|
||||
- [ ] Plan file deleted from disk → Repairs issue appears after config save/restart; re-upload clears it [auto backend]
|
||||
- [ ] Corrupted `.storage/houseplan.config` → entry retries (ConfigEntryNotReady), no crash loop [auto backend]
|
||||
- [ ] HA restart while a dialog is open → next save gets a clean error/conflict, no data loss
|
||||
@@ -200,7 +635,7 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
||||
|
||||
## Last self-run
|
||||
|
||||
**v1.21.1 (2026-07-16), full audit of v1.16–v1.21.** All `[auto]` items pass (73 frontend
|
||||
**v1.21.1 (2026-07-16), full audit of v1.16–v1.21.** All `[manual]` items pass (73 frontend
|
||||
tests, 12 backend). New smokes on the synthetic home: `smoke_merge_split` (merge fuses
|
||||
adjacent rooms keeping the survivor's id; non-adjacent refused with a toast; split creates
|
||||
the new room, cancel keeps the room whole, along-wall cut refused) and `smoke_split_nonsnap`.
|
||||
@@ -210,7 +645,7 @@ polygons) — the click now snaps to the nearest wall instead of the grid, and `
|
||||
still rejects a bad cut. README (en+ru) gained the merge/split/ruler/scale documentation it
|
||||
was missing. The earlier self-run record follows.
|
||||
|
||||
**v1.14.0 (2026-07-06), headless demo harness + unit suites.** All `[auto]` items pass
|
||||
**v1.14.0 (2026-07-06), headless demo harness + unit suites.** All `[manual]` items pass
|
||||
(43 frontend tests, 11 pure + 12 HA-harness backend tests, `smoke_space_settings`,
|
||||
tap/hold/wizard/rules smokes). Bugs found during the run, fixed in the same release:
|
||||
1. Edit dialog: switching an existing space from image to "draw" kept the old
|
||||
@@ -226,14 +661,14 @@ require hands on real hardware — they remain for the human pass.
|
||||
|
||||
## houseplan-space-card (read-only embedded)
|
||||
- [ ] `type: custom:houseplan-space-card, space: <id>` renders the space identical to the full
|
||||
card's plan (background + configured borders/names + room fills + icons), no header/controls [auto]
|
||||
card's plan (background + configured borders/names + room fills + icons), no header/controls [manual]
|
||||
- [ ] The schematic is fully non-interactive: click/hover anywhere does nothing — no more-info,
|
||||
no tooltip, no drag (`.hp-static-stage` is pointer-events:none) [auto]
|
||||
- [ ] Footer button opens the full component already showing that space (deep-link `#space=<id>`) [auto]
|
||||
no tooltip, no drag (`.hp-static-stage` is pointer-events:none) [manual]
|
||||
- [ ] Footer button opens the full component already showing that space (deep-link `#space=<id>`) [manual]
|
||||
- [ ] Several cards with different `space` coexist on one board; one shared config WS request
|
||||
- [ ] Unknown `space` → tidy error card [auto]
|
||||
- [ ] Unknown `space` → tidy error card [manual]
|
||||
- [ ] `show_button: false` hides the footer
|
||||
- [ ] Full card honours `#space=<id>` on load and on hashchange; invalid id ignored [auto]
|
||||
- [ ] Full card honours `#space=<id>` on load and on hashchange; invalid id ignored [manual]
|
||||
|
||||
## Presence ripples / per-device icon (v1.22.0)
|
||||
|
||||
|
||||
+28
-5
@@ -12,17 +12,30 @@ A segmented control in the card header with three tabs; the active one is visual
|
||||
highlighted, and edit modes add a colored frame around the stage so the mode is
|
||||
obvious at a glance:
|
||||
|
||||
**[ 👁 View ] [ 📐 Plan ] [ 🔧 Devices ]**
|
||||
**[ 📐 Plan editor ] [ 🔧 Device editor ] [ ✏️ Background editor ]** — View has
|
||||
NO tab (since v1.30.2). The Background editor (v1.33.0) manages a purely visual
|
||||
decor layer (lines/rects/ovals/text in `space.decor`, drawn under the rooms,
|
||||
inert everywhere outside its editor).
|
||||
|
||||
- **View** is the default and the only mode after every load (edit modes are never
|
||||
restored across reloads).
|
||||
- **Plan** and **Devices** are shown only to admins when `admin_only` is on.
|
||||
- **View** is the implicit default state: no editor tab is active. Since
|
||||
v1.38.2 the last space AND editor mode are restored across reloads
|
||||
(localStorage, admins only for edit modes) — closing and reopening the tab
|
||||
lands you where you were (owner's decision, reversing the earlier
|
||||
"never restore" rule).
|
||||
- Activating an editor tab highlights it and opens that editor's bottom toolbar
|
||||
(both editors have one since v1.30.2). The toolbar and the active tab each
|
||||
carry an **X** that closes the editor back to View; re-clicking the active tab
|
||||
does nothing; Plan↔Devices switches directly.
|
||||
- **Plan editor** and **Device editor** are shown only to admins when
|
||||
`admin_only` is on.
|
||||
|
||||
## View — display and device interaction only
|
||||
|
||||
Allowed: pan/zoom (wheel, pinch, buttons), switching spaces, device tap
|
||||
(info / more-info / toggle per settings), long-press → info card, opening tap →
|
||||
door/lock info card, room tap → HA area, hover tooltips (name, temperature, signal).
|
||||
door/lock info card (with an explicit Unlock/Lock button when a lock is bound —
|
||||
the only way to operate a lock from the card; plan-icon taps never toggle locks),
|
||||
room-card link icon → HA area (room taps do nothing since v1.40.1), hover tooltips (name, temperature, signal).
|
||||
|
||||
Removed from this mode (they move, not die):
|
||||
- icon dragging ("drag anywhere", v1.9 — consciously reversed),
|
||||
@@ -74,3 +87,13 @@ Header in View: space tabs, device count, zoom cluster. Nothing else.
|
||||
editing under Plan; colored frame indicator.
|
||||
- **It.3 — Devices tab:** drag + direct-edit click + curation tools under Devices.
|
||||
- **It.4+:** follow-up features 1–5 above, each its own release.
|
||||
|
||||
|
||||
## Kiosk mode (v1.41.0)
|
||||
|
||||
`kiosk: true` on the card is the fourth interaction surface: the full View
|
||||
experience with the header removed and editors hard-blocked (even for
|
||||
admins). Swipe switches spaces at 1:1 zoom only (zoomed gestures pan;
|
||||
double tap resets zoom); `cycle: N` auto-advances spaces with a 60 s pause
|
||||
after any touch; a 3 s long-press on empty plan opens the per-screen size
|
||||
popover (localStorage). Nav persistence never restores an editor here.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 853 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 853 KiB After Width: | Height: | Size: 1.3 MiB |
Generated
+17
-48
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.23.2",
|
||||
"version": "1.45.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "houseplan-card",
|
||||
"version": "1.23.2",
|
||||
"version": "1.45.4",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lit": "^3.1.3",
|
||||
@@ -18,7 +18,7 @@
|
||||
"@rollup/plugin-node-resolve": "^15.2.3",
|
||||
"@rollup/plugin-terser": "^0.4.4",
|
||||
"@rollup/plugin-typescript": "^11.1.6",
|
||||
"playwright": "^1.61.1",
|
||||
"playwright": "^1.62.0",
|
||||
"rollup": "^4.18.0",
|
||||
"tslib": "^2.6.2",
|
||||
"typescript": "^5.4.5"
|
||||
@@ -761,35 +761,35 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.61.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
|
||||
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
|
||||
"version": "1.62.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz",
|
||||
"integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.61.1"
|
||||
"playwright-core": "1.62.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.61.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
|
||||
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
|
||||
"version": "1.62.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz",
|
||||
"integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
@@ -817,16 +817,6 @@
|
||||
"splaytree-ts": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/randombytes": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
|
||||
"integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.12",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
||||
@@ -894,35 +884,14 @@
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/serialize-javascript": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz",
|
||||
"integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==",
|
||||
"version": "7.0.7",
|
||||
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.7.tgz",
|
||||
"integrity": "sha512-YAy8Od6KV+uuwUuU50np8fGB/Aues6Y0nAhA9y/hId74PlKUcme4pXcBD46NWKr1Q4osN/iseZ17YqO1XfmI8g==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"randombytes": "^2.1.0"
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/smob": {
|
||||
|
||||
+7
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.26.0",
|
||||
"version": "1.49.0",
|
||||
"description": "Interactive house plan Lovelace card for Home Assistant",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -8,7 +8,8 @@
|
||||
"build": "tsc --noEmit && rollup -c",
|
||||
"watch": "rollup -c --watch",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node --test test/*.test.mjs"
|
||||
"test": "tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node --test test/*.test.mjs",
|
||||
"inventory": "node scripts/inventory.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mdi/js": "^7.4.47",
|
||||
@@ -16,7 +17,7 @@
|
||||
"@rollup/plugin-node-resolve": "^15.2.3",
|
||||
"@rollup/plugin-terser": "^0.4.4",
|
||||
"@rollup/plugin-typescript": "^11.1.6",
|
||||
"playwright": "^1.61.1",
|
||||
"playwright": "^1.62.0",
|
||||
"rollup": "^4.18.0",
|
||||
"tslib": "^2.6.2",
|
||||
"typescript": "^5.4.5"
|
||||
@@ -24,5 +25,8 @@
|
||||
"dependencies": {
|
||||
"lit": "^3.1.3",
|
||||
"polyclip-ts": "^0.16.8"
|
||||
},
|
||||
"overrides": {
|
||||
"serialize-javascript": "^7.0.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// Current test inventory, printed on demand.
|
||||
//
|
||||
// docs/STATUS.md used to carry these numbers inline; they went stale within a
|
||||
// couple of releases while the version line next to them was kept current,
|
||||
// which is worse than no number at all — a maintainer reading the snapshot
|
||||
// underestimates the coverage that exists (review R5-2). The counts live here
|
||||
// now, one command away, and STATUS.md describes the layers instead.
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
|
||||
const count = (dir, match, re) =>
|
||||
readdirSync(dir)
|
||||
.filter((f) => match.test(f))
|
||||
.reduce((n, f) => n + (readFileSync(`${dir}/${f}`, 'utf8').match(re) || []).length, 0);
|
||||
|
||||
const files = (dir, match) => readdirSync(dir).filter((f) => match.test(f)).length;
|
||||
|
||||
const rows = [
|
||||
['frontend unit (node:test)', count('test', /\.test\.mjs$/, /^test\(/gm)],
|
||||
['pure backend (pytest, no HA)', count('tests_backend', /^test_validation\.py$/, /^def test_/gm)],
|
||||
['HA-harness backend (CI, py3.13)', count('tests_backend', /^test_ha_.*\.py$/, /^async def test_|^def test_/gm)],
|
||||
['browser smokes (headless chromium)', files('demo', /^smoke_.*\.mjs$/)],
|
||||
];
|
||||
const w = Math.max(...rows.map(([n]) => n.length));
|
||||
for (const [name, n] of rows) console.log(`${name.padEnd(w)} ${n}`);
|
||||
+10
-4
@@ -47,11 +47,17 @@ async function fetchFresh(hass: any): Promise<HpConfigSnapshot> {
|
||||
};
|
||||
if (!subscribed && hass.connection?.subscribeEvents) {
|
||||
subscribed = true;
|
||||
const invalidate = () => {
|
||||
cache = null; // invalidate; listeners reload
|
||||
listeners.forEach((l) => l());
|
||||
};
|
||||
try {
|
||||
await hass.connection.subscribeEvents(() => {
|
||||
cache = null; // invalidate; listeners reload
|
||||
listeners.forEach((l) => l());
|
||||
}, 'houseplan_config_updated');
|
||||
await hass.connection.subscribeEvents(invalidate, 'houseplan_config_updated');
|
||||
// Layout is separate state: dragging an icon on the full card writes only
|
||||
// the layout, so a static card on the same dashboard kept showing the old
|
||||
// position until the config changed or the page was reloaded — possibly
|
||||
// forever on a wall tablet (HP-1454-08).
|
||||
await hass.connection.subscribeEvents(invalidate, 'houseplan_layout_updated');
|
||||
} catch {
|
||||
subscribed = false;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user